Compare commits
20 Commits
f7b05f1e08
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 554289ee1e | |||
| 6975358a7f | |||
| 51327c7d01 | |||
| 1d5b29e4a7 | |||
| 7cb35efd30 | |||
|
|
d8874fbe60 | ||
| 00fd53bbd4 | |||
|
|
742d8f6bcc | ||
| f3cf10e5e6 | |||
| 862c4e42a4 | |||
|
|
e725819c01 | ||
|
|
1a534eccb0 | ||
| c26de5aefc | |||
|
|
f3b0e7b7eb | ||
| 45746a6a0f | |||
| fd80f63bbe | |||
| c19cc7e00a | |||
|
|
4d821646cf | ||
| c02b61163d | |||
|
|
53bf2d18e6 |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -3,5 +3,6 @@
|
|||||||
"source.fixAll": "explicit",
|
"source.fixAll": "explicit",
|
||||||
"source.organizeImports": "explicit",
|
"source.organizeImports": "explicit",
|
||||||
"source.sortMembers": "explicit"
|
"source.sortMembers": "explicit"
|
||||||
}
|
},
|
||||||
|
"postman.settings.dotenv-detection-notification-visibility": false
|
||||||
}
|
}
|
||||||
|
|||||||
224
LOCALIZATION.md
Normal file
224
LOCALIZATION.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# Localization Setup Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Ứng dụng đã được cấu hình hỗ trợ 2 ngôn ngữ: **English (en)** và **Tiếng Việt (vi)** sử dụng `expo-localization` và `i18n-js`.
|
||||||
|
|
||||||
|
## Cấu trúc thư mục
|
||||||
|
|
||||||
|
```
|
||||||
|
/config
|
||||||
|
/localization
|
||||||
|
- i18n.ts # Cấu hình i18n (khởi tạo locale, enable fallback, etc.)
|
||||||
|
- localization.ts # Export main exports
|
||||||
|
|
||||||
|
/locales
|
||||||
|
- en.json # Các string tiếng Anh
|
||||||
|
- vi.json # Các string tiếng Việt
|
||||||
|
|
||||||
|
/hooks
|
||||||
|
- use-i18n.ts # Hook để sử dụng i18n trong components
|
||||||
|
|
||||||
|
/state
|
||||||
|
- use-locale-store.ts # Zustand store cho global locale state management
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cách sử dụng
|
||||||
|
|
||||||
|
### 1. Import i18n hook trong component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
|
||||||
|
export default function MyComponent() {
|
||||||
|
const { t, locale, setLocale } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text>{t("common.ok")}</Text>
|
||||||
|
<Text>{t("navigation.home")}</Text>
|
||||||
|
<Text>Current locale: {locale}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Thêm translation keys
|
||||||
|
|
||||||
|
#### Mở file `/locales/en.json` và thêm:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"myFeature": {
|
||||||
|
"title": "My Feature Title",
|
||||||
|
"description": "My Feature Description"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mở file `/locales/vi.json` và thêm translation tương ứng:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"myFeature": {
|
||||||
|
"title": "Tiêu đề Tính năng Của Tôi",
|
||||||
|
"description": "Mô tả Tính năng Của Tôi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Sử dụng trong component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return <Text>{t("myFeature.title")}</Text>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cách thay đổi ngôn ngữ
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { setLocale } = useI18n();
|
||||||
|
|
||||||
|
// Thay đổi sang Tiếng Việt
|
||||||
|
<Button onPress={() => setLocale('vi')} title="Tiếng Việt" />
|
||||||
|
|
||||||
|
// Thay đổi sang English
|
||||||
|
<Button onPress={() => setLocale('en')} title="English" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lưu ý:** Ngôn ngữ được chọn sẽ được **lưu tự động vào storage**. Khi người dùng tắt app và mở lại, app sẽ sử dụng ngôn ngữ được chọn trước đó.
|
||||||
|
|
||||||
|
## Persistence (Lưu trữ tùy chọn ngôn ngữ) - Zustand Global State
|
||||||
|
|
||||||
|
Localization sử dụng **Zustand** cho global state management. Ngôn ngữ được chọn tự động được lưu vào `AsyncStorage` với key `app_locale_preference`.
|
||||||
|
|
||||||
|
**Quy trình:**
|
||||||
|
|
||||||
|
1. Khi app khởi động, `useLocaleStore.getState().initLocale()` được gọi trong `app/_layout.tsx`
|
||||||
|
2. Store sẽ load locale từ storage nếu có
|
||||||
|
3. Nếu không có, store sẽ detect ngôn ngữ thiết bị (`getLocales()`)
|
||||||
|
4. Khi người dùng gọi `setLocale('vi')`, nó sẽ:
|
||||||
|
- Cập nhật Zustand store ngay lập tức
|
||||||
|
- **Tự động lưu vào storage** để dùng lần tiếp theo
|
||||||
|
- Tất cả components lắng nghe sẽ re-render ngay lập tức
|
||||||
|
|
||||||
|
**Kết quả:** Khi bạn toggle switch language trong settings:
|
||||||
|
|
||||||
|
- ✅ Tab labels cập nhật ngay lập tức
|
||||||
|
- ✅ UI labels cập nhật ngay lập tức
|
||||||
|
- ✅ Không cần click vào tab hoặc navigate
|
||||||
|
- ✅ Locale được persist vào storage
|
||||||
|
|
||||||
|
### Zustand Store Structure
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// /state/use-locale-store.ts
|
||||||
|
const { locale, setLocale, isLoaded } = useLocaleStore((state) => ({
|
||||||
|
locale: state.locale, // Locale hiện tại
|
||||||
|
setLocale: state.setLocale, // Async function để thay đổi locale
|
||||||
|
isLoaded: state.isLoaded, // Flag để biết khi locale đã load từ storage
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fallback Mechanism
|
||||||
|
|
||||||
|
Nếu một key không tồn tại trong ngôn ngữ hiện tại, ứng dụng sẽ tự động sử dụng giá trị từ ngôn ngữ English (default locale).
|
||||||
|
|
||||||
|
### Ví dụ:
|
||||||
|
|
||||||
|
- Nếu key `auth.newFeature` chỉ tồn tại trong `en.json` mà không có trong `vi.json`
|
||||||
|
- Khi ngôn ngữ được set là Vietnamese (`vi`), nó sẽ hiển thị giá trị từ English
|
||||||
|
|
||||||
|
## Persistence (Lưu trữ tùy chọn ngôn ngữ)
|
||||||
|
|
||||||
|
Ngôn ngữ được chọn tự động được lưu vào `AsyncStorage` với key `app_locale_preference`.
|
||||||
|
|
||||||
|
**Quy trình:**
|
||||||
|
|
||||||
|
1. Khi app khởi động, hook `useI18n` sẽ load giá trị từ storage
|
||||||
|
2. Nếu có giá trị lưu trữ, app sẽ sử dụng ngôn ngữ đó
|
||||||
|
3. Nếu không có, app sẽ detect ngôn ngữ thiết bị (`getLocales()`)
|
||||||
|
4. Khi người dùng gọi `setLocale('vi')`, nó sẽ:
|
||||||
|
- Thay đổi ngôn ngữ hiện tại
|
||||||
|
- **Tự động lưu vào storage** để dùng lần tiếp theo
|
||||||
|
|
||||||
|
**Kết quả:** Người dùng có thể tắt app, mở lại, và app sẽ vẫn sử dụng ngôn ngữ đã chọn trước đó.
|
||||||
|
|
||||||
|
## Supported Locales
|
||||||
|
|
||||||
|
Hiện tại app hỗ trợ:
|
||||||
|
|
||||||
|
- **en** - English
|
||||||
|
- **vi** - Tiếng Việt
|
||||||
|
|
||||||
|
Nếu muốn thêm ngôn ngữ khác:
|
||||||
|
|
||||||
|
1. Tạo file `locales/[language-code].json` (ví dụ: `locales/ja.json`)
|
||||||
|
2. Thêm translations
|
||||||
|
3. Import trong `config/localization/i18n.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import ja from "@/locales/ja.json";
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
en,
|
||||||
|
vi,
|
||||||
|
ja,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Cập nhật `app.json` để thêm locale mới:
|
||||||
|
```json
|
||||||
|
"supportedLocales": {
|
||||||
|
"ios": ["en", "vi", "ja"],
|
||||||
|
"android": ["en", "vi", "ja"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Translation Keys Hiện Có
|
||||||
|
|
||||||
|
Xem file `locales/en.json` và `locales/vi.json` để xem danh sách tất cả keys có sẵn.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Luôn add key vào cả 2 file** `en.json` và `vi.json` cùng lúc
|
||||||
|
2. **Giữ cấu trúc JSON nhất quán** giữa các language files
|
||||||
|
3. **Sử dụng snake_case cho keys** (không sử dụng camelCase)
|
||||||
|
4. **Nhóm liên quan keys** vào categories (ví dụ: `common`, `navigation`, `auth`, etc.)
|
||||||
|
5. **Để fallback enable** để tránh lỗi nếu key bị thiếu
|
||||||
|
|
||||||
|
## Device Language Detection
|
||||||
|
|
||||||
|
Ngôn ngữ của thiết bị sẽ được tự động detect khi app khởi động. Nếu thiết bị set language là Tiếng Việt, app sẽ tự động sử dụng `vi` locale.
|
||||||
|
|
||||||
|
Device language được lấy từ:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
|
|
||||||
|
const deviceLanguage = getLocales()[0].languageCode; // 'en', 'vi', etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### App không detect đúng ngôn ngữ thiết bị
|
||||||
|
|
||||||
|
- Kiểm tra console log trong `config/localization/i18n.ts`
|
||||||
|
- Đảm bảo language code của thiết bị khớp với các supported locales
|
||||||
|
|
||||||
|
### Translation key không hiện
|
||||||
|
|
||||||
|
- Kiểm tra xem key có tồn tại trong cả 2 files `en.json` và `vi.json` không
|
||||||
|
- Kiểm tra spelling của key (case-sensitive)
|
||||||
|
- Kiểm tra syntax JSON có hợp lệ không
|
||||||
|
|
||||||
|
### Fallback không hoạt động
|
||||||
|
|
||||||
|
- Đảm bảo `i18n.enableFallback = true` trong `config/localization/i18n.ts`
|
||||||
|
- Kiểm tra key có tồn tại trong `en.json` không (default fallback language)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Expo Localization Guide](https://docs.expo.dev/guides/localization/)
|
||||||
|
- [i18n-js Documentation](https://github.com/fnando/i18n-js)
|
||||||
409
MODAL_USAGE.md
Normal file
409
MODAL_USAGE.md
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
# Modal Component
|
||||||
|
|
||||||
|
Modal component tương tự như Modal của Ant Design, được tạo cho React Native/Expo.
|
||||||
|
|
||||||
|
## Cài đặt
|
||||||
|
|
||||||
|
Component này sử dụng `@expo/vector-icons` cho các icon. Đảm bảo bạn đã cài đặt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install @expo/vector-icons
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Các tính năng chính
|
||||||
|
|
||||||
|
### 1. Basic Modal
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, Text, Button } from "react-native";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
|
||||||
|
export default function BasicExample() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Button title="Open Modal" onPress={() => setOpen(true)} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Basic Modal"
|
||||||
|
onOk={() => {
|
||||||
|
console.log("OK clicked");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>Some contents...</Text>
|
||||||
|
<Text>Some contents...</Text>
|
||||||
|
<Text>Some contents...</Text>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Async Close (với confirmLoading)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, Text, Button } from "react-native";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
|
||||||
|
export default function AsyncExample() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
// Giả lập API call
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
setLoading(false);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Button title="Open Modal" onPress={() => setOpen(true)} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Async Modal"
|
||||||
|
confirmLoading={loading}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>Modal will be closed after 2 seconds when you click OK.</Text>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Customized Footer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { View, Text, Button, TouchableOpacity, StyleSheet } from "react-native";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
|
||||||
|
export default function CustomFooterExample() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Button title="Open Modal" onPress={() => setOpen(true)} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Custom Footer Modal"
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
footer={
|
||||||
|
<View style={styles.customFooter}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.customButton}
|
||||||
|
onPress={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Return</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.customButton, styles.submitButton]}
|
||||||
|
onPress={() => {
|
||||||
|
console.log("Submit");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[styles.buttonText, styles.submitText]}>Submit</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>Custom footer buttons</Text>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
customFooter: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
customButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#d9d9d9",
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
backgroundColor: "#1890ff",
|
||||||
|
borderColor: "#1890ff",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#000",
|
||||||
|
},
|
||||||
|
submitText: {
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. No Footer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Modal Without Footer"
|
||||||
|
footer={null}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>Modal content without footer buttons</Text>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Centered Modal
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Centered Modal"
|
||||||
|
centered
|
||||||
|
onOk={() => setOpen(false)}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>This modal is centered on the screen</Text>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Custom Width
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Custom Width Modal"
|
||||||
|
width={700}
|
||||||
|
onOk={() => setOpen(false)}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>This modal has custom width</Text>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Confirm Modal với useModal Hook
|
||||||
|
|
||||||
|
**Đây là cách khuyến nghị sử dụng trong React Native để có context đầy đủ:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from "react";
|
||||||
|
import { View, Button } from "react-native";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
|
||||||
|
export default function HookExample() {
|
||||||
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
|
|
||||||
|
const showConfirm = () => {
|
||||||
|
modal.confirm({
|
||||||
|
title: "Do you want to delete these items?",
|
||||||
|
content:
|
||||||
|
"When clicked the OK button, this dialog will be closed after 1 second",
|
||||||
|
onOk: async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
console.log("OK");
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
console.log("Cancel");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showInfo = () => {
|
||||||
|
modal.info({
|
||||||
|
title: "This is a notification message",
|
||||||
|
content: "Some additional information...",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showSuccess = () => {
|
||||||
|
modal.success({
|
||||||
|
title: "Success",
|
||||||
|
content: "Operation completed successfully!",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = () => {
|
||||||
|
modal.error({
|
||||||
|
title: "Error",
|
||||||
|
content: "Something went wrong!",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showWarning = () => {
|
||||||
|
modal.warning({
|
||||||
|
title: "Warning",
|
||||||
|
content: "This is a warning message!",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ padding: 20, gap: 10 }}>
|
||||||
|
{/* contextHolder phải được đặt trong component */}
|
||||||
|
{contextHolder}
|
||||||
|
|
||||||
|
<Button title="Confirm" onPress={showConfirm} />
|
||||||
|
<Button title="Info" onPress={showInfo} />
|
||||||
|
<Button title="Success" onPress={showSuccess} />
|
||||||
|
<Button title="Error" onPress={showError} />
|
||||||
|
<Button title="Warning" onPress={showWarning} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Update Modal Instance
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from "react";
|
||||||
|
import { View, Button } from "react-native";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
|
||||||
|
export default function UpdateExample() {
|
||||||
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
|
|
||||||
|
const showModal = () => {
|
||||||
|
const instance = modal.success({
|
||||||
|
title: "Loading...",
|
||||||
|
content: "Please wait...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
instance.update({
|
||||||
|
title: "Success!",
|
||||||
|
content: "Operation completed successfully!",
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// Close after 4 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
instance.destroy();
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{contextHolder}
|
||||||
|
<Button title="Show Updating Modal" onPress={showModal} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Modal Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
| --------------- | ----------------------------- | --------- | ------------------------------------------------------------ |
|
||||||
|
| open | boolean | false | Whether the modal dialog is visible or not |
|
||||||
|
| title | ReactNode | - | The modal dialog's title |
|
||||||
|
| closable | boolean | true | Whether a close (x) button is visible on top right or not |
|
||||||
|
| closeIcon | ReactNode | - | Custom close icon |
|
||||||
|
| maskClosable | boolean | true | Whether to close the modal dialog when the mask is clicked |
|
||||||
|
| centered | boolean | false | Centered Modal |
|
||||||
|
| width | number \| string | 520 | Width of the modal dialog |
|
||||||
|
| confirmLoading | boolean | false | Whether to apply loading visual effect for OK button |
|
||||||
|
| okText | string | 'OK' | Text of the OK button |
|
||||||
|
| cancelText | string | 'Cancel' | Text of the Cancel button |
|
||||||
|
| okType | 'primary' \| 'default' | 'primary' | Button type of the OK button |
|
||||||
|
| footer | ReactNode \| null | - | Footer content, set as footer={null} to hide default buttons |
|
||||||
|
| mask | boolean | true | Whether show mask or not |
|
||||||
|
| zIndex | number | 1000 | The z-index of the Modal |
|
||||||
|
| onOk | (e?) => void \| Promise<void> | - | Callback when clicking OK button |
|
||||||
|
| onCancel | (e?) => void | - | Callback when clicking cancel button or close icon |
|
||||||
|
| afterOpenChange | (open: boolean) => void | - | Callback when animation ends |
|
||||||
|
| afterClose | () => void | - | Callback when modal is closed completely |
|
||||||
|
| destroyOnClose | boolean | false | Whether to unmount child components on close |
|
||||||
|
| keyboard | boolean | true | Whether support press back button to close (Android) |
|
||||||
|
|
||||||
|
### Modal.useModal()
|
||||||
|
|
||||||
|
Khi bạn cần sử dụng Context, bạn có thể dùng `Modal.useModal()` để tạo `contextHolder` và chèn vào children. Modal được tạo bởi hooks sẽ có tất cả context nơi `contextHolder` được đặt.
|
||||||
|
|
||||||
|
**Returns:** `[modalMethods, contextHolder]`
|
||||||
|
|
||||||
|
- `modalMethods`: Object chứa các methods
|
||||||
|
|
||||||
|
- `info(config)`: Show info modal
|
||||||
|
- `success(config)`: Show success modal
|
||||||
|
- `error(config)`: Show error modal
|
||||||
|
- `warning(config)`: Show warning modal
|
||||||
|
- `confirm(config)`: Show confirm modal
|
||||||
|
|
||||||
|
- `contextHolder`: React element cần được render trong component tree
|
||||||
|
|
||||||
|
### Modal Methods Config
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
| ---------- | -------------------------------------------------------- | --------- | ----------------------------- |
|
||||||
|
| type | 'info' \| 'success' \| 'error' \| 'warning' \| 'confirm' | 'confirm' | Type of the modal |
|
||||||
|
| title | ReactNode | - | Title |
|
||||||
|
| content | ReactNode | - | Content |
|
||||||
|
| icon | ReactNode | - | Custom icon |
|
||||||
|
| okText | string | 'OK' | Text of the OK button |
|
||||||
|
| cancelText | string | 'Cancel' | Text of the Cancel button |
|
||||||
|
| onOk | (e?) => void \| Promise<void> | - | Callback when clicking OK |
|
||||||
|
| onCancel | (e?) => void | - | Callback when clicking Cancel |
|
||||||
|
|
||||||
|
### Modal Instance
|
||||||
|
|
||||||
|
Modal instance được trả về bởi `Modal.useModal()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ModalInstance {
|
||||||
|
destroy: () => void;
|
||||||
|
update: (config: ConfirmModalProps) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lưu ý
|
||||||
|
|
||||||
|
1. **React Native Limitations**: Các static methods như `Modal.info()`, `Modal.confirm()` gọi trực tiếp (không qua hook) không được hỗ trợ đầy đủ trong React Native do không thể render imperatively. Hãy sử dụng `Modal.useModal()` hook thay thế.
|
||||||
|
|
||||||
|
2. **Context Support**: Khi cần sử dụng Context (như Redux, Theme Context), bắt buộc phải dùng `Modal.useModal()` hook và đặt `contextHolder` trong component tree.
|
||||||
|
|
||||||
|
3. **Animation**: Modal sử dụng React Native's built-in Modal với `animationType="fade"`.
|
||||||
|
|
||||||
|
4. **Icons**: Component sử dụng `@expo/vector-icons` (Ionicons). Đảm bảo đã cài đặt package này.
|
||||||
|
|
||||||
|
5. **Keyboard**: Prop `keyboard` trong React Native chỉ hoạt động với nút back của Android (không có ESC key như web).
|
||||||
|
|
||||||
|
## So sánh với Ant Design Modal
|
||||||
|
|
||||||
|
| Feature | Ant Design (Web) | This Component (RN) |
|
||||||
|
| --------------------------- | ---------------- | --------------------------- |
|
||||||
|
| Basic Modal | ✅ | ✅ |
|
||||||
|
| Centered | ✅ | ✅ |
|
||||||
|
| Custom Footer | ✅ | ✅ |
|
||||||
|
| Confirm Dialog | ✅ | ✅ (via useModal) |
|
||||||
|
| Info/Success/Error/Warning | ✅ | ✅ (via useModal) |
|
||||||
|
| Async close | ✅ | ✅ |
|
||||||
|
| Custom width | ✅ | ✅ |
|
||||||
|
| Mask closable | ✅ | ✅ |
|
||||||
|
| Keyboard close | ✅ (ESC) | ✅ (Back button on Android) |
|
||||||
|
| Static methods without hook | ✅ | ⚠️ (Limited support) |
|
||||||
|
| useModal hook | ✅ | ✅ (Recommended) |
|
||||||
|
| Draggable | ✅ | ❌ (Not applicable) |
|
||||||
|
| destroyAll() | ✅ | ❌ |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
32
README.md
32
README.md
@@ -48,3 +48,35 @@ Join our community of developers creating universal apps.
|
|||||||
|
|
||||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||||
|
|
||||||
|
## Build app
|
||||||
|
|
||||||
|
- Add eas.json file to root folder and add this:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.27.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
},
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|||||||
502
THEME_GUIDE.md
Normal file
502
THEME_GUIDE.md
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
# Theme System Documentation
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
|
||||||
|
Hệ thống theme hỗ trợ **Light Mode**, **Dark Mode** và **System Mode** (tự động theo hệ thống). Theme preference được lưu trong AsyncStorage và tự động khôi phục khi khởi động lại ứng dụng.
|
||||||
|
|
||||||
|
## Kiến trúc Theme System
|
||||||
|
|
||||||
|
### 1. Theme Provider (`hooks/use-theme-context.tsx`)
|
||||||
|
|
||||||
|
Theme Provider là core của hệ thống theme, quản lý state và đồng bộ với system theme.
|
||||||
|
|
||||||
|
**Các tính năng chính:**
|
||||||
|
|
||||||
|
- Quản lý `themeMode`: `'light' | 'dark' | 'system'`
|
||||||
|
- Tự động detect system theme thông qua nhiều nguồn:
|
||||||
|
- `Appearance.getColorScheme()` - iOS/Android system theme
|
||||||
|
- `useColorScheme()` hook từ React Native
|
||||||
|
- `Appearance.addChangeListener()` - listen system theme changes
|
||||||
|
- `AppState` listener - sync lại khi app active
|
||||||
|
- Lưu và restore theme preference từ AsyncStorage
|
||||||
|
- Export ra `colorScheme` cuối cùng: `'light' | 'dark'`
|
||||||
|
|
||||||
|
**ThemeContextType:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ThemeContextType {
|
||||||
|
themeMode: ThemeMode; // User's choice: 'light' | 'dark' | 'system'
|
||||||
|
colorScheme: ColorScheme; // Final theme: 'light' | 'dark'
|
||||||
|
colors: typeof Colors.light; // Theme colors object
|
||||||
|
setThemeMode: (mode: ThemeMode) => Promise<void>;
|
||||||
|
getColor: (colorName: ColorName) => string;
|
||||||
|
isHydrated: boolean; // AsyncStorage đã load xong
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cách hoạt động:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Xác định colorScheme cuối cùng
|
||||||
|
const colorScheme: ColorScheme =
|
||||||
|
themeMode === "system" ? systemScheme : themeMode;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Colors Configuration (`constants/theme.ts`)
|
||||||
|
|
||||||
|
Định nghĩa tất cả colors cho light và dark theme:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const Colors = {
|
||||||
|
light: {
|
||||||
|
text: "#11181C",
|
||||||
|
textSecondary: "#687076",
|
||||||
|
background: "#fff",
|
||||||
|
backgroundSecondary: "#f5f5f5",
|
||||||
|
surface: "#ffffff",
|
||||||
|
surfaceSecondary: "#f8f9fa",
|
||||||
|
tint: "#0a7ea4",
|
||||||
|
primary: "#007AFF",
|
||||||
|
secondary: "#5AC8FA",
|
||||||
|
success: "#34C759",
|
||||||
|
warning: "#ff6600",
|
||||||
|
error: "#FF3B30",
|
||||||
|
icon: "#687076",
|
||||||
|
border: "#C6C6C8",
|
||||||
|
separator: "#E5E5E7",
|
||||||
|
card: "#ffffff",
|
||||||
|
// ... more colors
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: "#ECEDEE",
|
||||||
|
textSecondary: "#8E8E93",
|
||||||
|
background: "#000000",
|
||||||
|
backgroundSecondary: "#1C1C1E",
|
||||||
|
surface: "#1C1C1E",
|
||||||
|
surfaceSecondary: "#2C2C2E",
|
||||||
|
tint: "#fff",
|
||||||
|
primary: "#0A84FF",
|
||||||
|
secondary: "#64D2FF",
|
||||||
|
success: "#30D158",
|
||||||
|
warning: "#ff6600",
|
||||||
|
error: "#FF453A",
|
||||||
|
icon: "#8E8E93",
|
||||||
|
border: "#38383A",
|
||||||
|
separator: "#38383A",
|
||||||
|
card: "#1C1C1E",
|
||||||
|
// ... more colors
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorName = keyof typeof Colors.light;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Setup trong App (`app/_layout.tsx`)
|
||||||
|
|
||||||
|
Theme Provider phải wrap toàn bộ app:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<AppThemeProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AppThemeProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { colorScheme } = useThemeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||||
|
<Stack>{/* ... routes */}</Stack>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cách sử dụng Theme
|
||||||
|
|
||||||
|
### 1. useThemeContext (Core Hook)
|
||||||
|
|
||||||
|
Hook chính để access theme state:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const {
|
||||||
|
themeMode, // 'light' | 'dark' | 'system'
|
||||||
|
colorScheme, // 'light' | 'dark'
|
||||||
|
colors, // Colors object
|
||||||
|
setThemeMode, // Change theme
|
||||||
|
getColor, // Get color by name
|
||||||
|
isHydrated, // AsyncStorage loaded
|
||||||
|
} = useThemeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ backgroundColor: colors.background }}>
|
||||||
|
<Text style={{ color: colors.text }}>
|
||||||
|
Mode: {themeMode}, Scheme: {colorScheme}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. useColorScheme Hook
|
||||||
|
|
||||||
|
Alias để lấy colorScheme nhanh:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useColorScheme } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const colorScheme = useColorScheme(); // 'light' | 'dark'
|
||||||
|
|
||||||
|
return <Text>Current theme: {colorScheme}</Text>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Lưu ý:** `useColorScheme` từ `use-theme-context.tsx`, KHÔNG phải từ `react-native`.
|
||||||
|
|
||||||
|
### 3. useThemeColor Hook
|
||||||
|
|
||||||
|
Override colors cho specific themes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
// Với override
|
||||||
|
const backgroundColor = useThemeColor(
|
||||||
|
{ light: "#ffffff", dark: "#1C1C1E" },
|
||||||
|
"surface"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Không override, dùng color từ theme
|
||||||
|
const textColor = useThemeColor({}, "text");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ backgroundColor }}>
|
||||||
|
<Text style={{ color: textColor }}>Text</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cách hoạt động:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Ưu tiên props override trước, sau đó mới dùng Colors
|
||||||
|
const colorFromProps = props[colorScheme];
|
||||||
|
return colorFromProps || Colors[colorScheme][colorName];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. useAppTheme Hook (Recommended)
|
||||||
|
|
||||||
|
Hook tiện lợi với pre-built styles và utilities:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { colors, styles, utils } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<TouchableOpacity style={styles.primaryButton}>
|
||||||
|
<Text style={styles.primaryButtonText}>Primary Button</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={{ color: colors.text }}>
|
||||||
|
Theme is {utils.isDark ? "Dark" : "Light"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: utils.getOpacityColor("primary", 0.1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>Transparent background</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Themed Components
|
||||||
|
|
||||||
|
**ThemedView** và **ThemedText** - Tự động apply theme colors:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
return (
|
||||||
|
<ThemedView>
|
||||||
|
<ThemedText type="title">Title Text</ThemedText>
|
||||||
|
<ThemedText type="subtitle">Subtitle</ThemedText>
|
||||||
|
<ThemedText type="default">Regular Text</ThemedText>
|
||||||
|
<ThemedText type="link">Link Text</ThemedText>
|
||||||
|
|
||||||
|
{/* Override với custom colors */}
|
||||||
|
<ThemedText lightColor="#000000" darkColor="#FFFFFF">
|
||||||
|
Custom colored text
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ThemedText types:**
|
||||||
|
|
||||||
|
- `default` - 16px regular
|
||||||
|
- `title` - 32px bold
|
||||||
|
- `subtitle` - 20px bold
|
||||||
|
- `defaultSemiBold` - 16px semibold
|
||||||
|
- `link` - 16px với color #0a7ea4
|
||||||
|
|
||||||
|
### 6. Theme Toggle Component
|
||||||
|
|
||||||
|
Component có sẵn để user chọn theme:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
|
function SettingsScreen() {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ThemeToggle />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Component này hiển thị 3 options: Light, Dark, System với icons và labels đa ngôn ngữ.
|
||||||
|
|
||||||
|
## Available Styles từ useAppTheme
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { styles } = useAppTheme();
|
||||||
|
|
||||||
|
// Container styles
|
||||||
|
styles.container; // Flex 1 container với background
|
||||||
|
styles.surface; // Surface với padding 16, borderRadius 12
|
||||||
|
styles.card; // Card với shadow, elevation
|
||||||
|
|
||||||
|
// Button styles
|
||||||
|
styles.primaryButton; // Primary button với colors.primary
|
||||||
|
styles.secondaryButton; // Secondary button với border
|
||||||
|
styles.primaryButtonText; // White text cho primary button
|
||||||
|
styles.secondaryButtonText; // Theme text cho secondary button
|
||||||
|
|
||||||
|
// Input styles
|
||||||
|
styles.textInput; // Text input với border, padding
|
||||||
|
|
||||||
|
// Status styles
|
||||||
|
styles.successContainer; // Success background với border
|
||||||
|
styles.warningContainer; // Warning background với border
|
||||||
|
styles.errorContainer; // Error background với border
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
styles.separator; // 1px line separator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme Utilities
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { utils } = useAppTheme();
|
||||||
|
|
||||||
|
// Check theme
|
||||||
|
utils.isDark; // boolean - true nếu dark mode
|
||||||
|
utils.isLight; // boolean - true nếu light mode
|
||||||
|
|
||||||
|
// Toggle theme (ignores system mode)
|
||||||
|
utils.toggleTheme(); // Switch giữa light ↔ dark
|
||||||
|
|
||||||
|
// Get color với opacity
|
||||||
|
utils.getOpacityColor("primary", 0.1); // rgba(0, 122, 255, 0.1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Luồng hoạt động của Theme System
|
||||||
|
|
||||||
|
```
|
||||||
|
1. App khởi động
|
||||||
|
└─→ ThemeProvider mount
|
||||||
|
├─→ Load saved themeMode từ AsyncStorage ('light'/'dark'/'system')
|
||||||
|
├─→ Detect systemScheme từ OS
|
||||||
|
│ ├─→ Appearance.getColorScheme()
|
||||||
|
│ ├─→ useColorScheme() hook
|
||||||
|
│ └─→ Appearance.addChangeListener()
|
||||||
|
└─→ Tính toán colorScheme cuối cùng
|
||||||
|
└─→ themeMode === 'system' ? systemScheme : themeMode
|
||||||
|
|
||||||
|
2. User thay đổi system theme
|
||||||
|
└─→ Appearance listener fire
|
||||||
|
└─→ Update systemScheme state
|
||||||
|
└─→ Nếu themeMode === 'system'
|
||||||
|
└─→ colorScheme tự động update
|
||||||
|
└─→ Components re-render với colors mới
|
||||||
|
|
||||||
|
3. User chọn theme trong app
|
||||||
|
└─→ setThemeMode('light'/'dark'/'system')
|
||||||
|
├─→ Update themeMode state
|
||||||
|
├─→ Save vào AsyncStorage
|
||||||
|
└─→ colorScheme update
|
||||||
|
└─→ Components re-render
|
||||||
|
|
||||||
|
4. App về foreground
|
||||||
|
└─→ AppState listener fire
|
||||||
|
└─→ Sync lại systemScheme (phòng user đổi system theme khi app background)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Theme preference được lưu với key: `'theme_mode'`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Tự động xử lý bởi ThemeProvider
|
||||||
|
await setStorageItem("theme_mode", "light" | "dark" | "system");
|
||||||
|
const savedMode = await getStorageItem("theme_mode");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Sử dụng hooks đúng context:**
|
||||||
|
|
||||||
|
- `useThemeContext()` - Khi cần full control (themeMode, setThemeMode)
|
||||||
|
- `useColorScheme()` - Chỉ cần biết light/dark
|
||||||
|
- `useAppTheme()` - Recommended cho UI components (có styles + utils)
|
||||||
|
- `useThemeColor()` - Khi cần override colors
|
||||||
|
|
||||||
|
2. **Sử dụng Themed Components:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good ✅
|
||||||
|
<ThemedView>
|
||||||
|
<ThemedText>Hello</ThemedText>
|
||||||
|
</ThemedView>;
|
||||||
|
|
||||||
|
// Also good ✅
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
<View style={{ backgroundColor: colors.background }}>
|
||||||
|
<Text style={{ color: colors.text }}>Hello</Text>
|
||||||
|
</View>;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Tận dụng pre-built styles:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good ✅
|
||||||
|
const { styles } = useAppTheme();
|
||||||
|
<TouchableOpacity style={styles.primaryButton}>
|
||||||
|
|
||||||
|
// Less good ❌
|
||||||
|
<TouchableOpacity style={{
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12
|
||||||
|
}}>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Sử dụng opacity colors:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { utils } = useAppTheme();
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: utils.getOpacityColor("primary", 0.1),
|
||||||
|
}}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Check theme correctly:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good ✅
|
||||||
|
const { utils } = useAppTheme();
|
||||||
|
if (utils.isDark) { ... }
|
||||||
|
|
||||||
|
// Also good ✅
|
||||||
|
const { colorScheme } = useThemeContext();
|
||||||
|
if (colorScheme === 'dark') { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Theme không được lưu
|
||||||
|
|
||||||
|
- Kiểm tra AsyncStorage permissions
|
||||||
|
- Check logs trong console: `[Theme] Failed to save theme mode:`
|
||||||
|
|
||||||
|
### Flash màu sắc khi khởi động
|
||||||
|
|
||||||
|
- ThemeProvider đã xử lý với `isHydrated` state
|
||||||
|
- Chờ AsyncStorage load xong trước khi render
|
||||||
|
|
||||||
|
### System theme không update
|
||||||
|
|
||||||
|
- Check Appearance listener đã register: `[Theme] Registering appearance listener`
|
||||||
|
- Check logs: `[Theme] System theme changed to: ...`
|
||||||
|
- iOS: Restart app sau khi đổi system theme
|
||||||
|
- Android: Cần `expo-system-ui` plugin trong `app.json`
|
||||||
|
|
||||||
|
### Colors không đúng
|
||||||
|
|
||||||
|
- Đảm bảo app wrapped trong `<AppThemeProvider>`
|
||||||
|
- Check `colorScheme` trong console logs
|
||||||
|
- Verify Colors object trong `constants/theme.ts`
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
Nếu đang dùng old theme system:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Old ❌
|
||||||
|
import { useColorScheme } from "react-native";
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const backgroundColor = colorScheme === "dark" ? "#000" : "#fff";
|
||||||
|
|
||||||
|
// New ✅
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const backgroundColor = colors.background;
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Old ❌
|
||||||
|
const [theme, setTheme] = useState("light");
|
||||||
|
|
||||||
|
// New ✅
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
const { themeMode, setThemeMode } = useThemeContext();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debug Logs
|
||||||
|
|
||||||
|
Enable logs để debug theme issues:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Trong use-theme-context.tsx, uncomment các dòng:
|
||||||
|
console.log("[Theme] Appearance.getColorScheme():", scheme);
|
||||||
|
console.log("[Theme] System theme changed to:", newScheme);
|
||||||
|
console.log("[Theme] Mode:", themeMode);
|
||||||
|
console.log("[Theme] Derived colorScheme:", colorScheme);
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Trong use-theme-color.ts:
|
||||||
|
console.log("Detected theme:", theme); // Đã có sẵn
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Trong _layout.tsx:
|
||||||
|
console.log("Color Scheme: ", colorScheme); // Đã có sẵn
|
||||||
|
```
|
||||||
38
app.json
38
app.json
@@ -9,7 +9,14 @@
|
|||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"infoPlist": {
|
||||||
|
"CFBundleLocalizations": [
|
||||||
|
"en",
|
||||||
|
"vi"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bundleIdentifier": "com.minhnn86.sgwapp"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
@@ -19,7 +26,12 @@
|
|||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.RECORD_AUDIO"
|
||||||
|
],
|
||||||
|
"package": "com.minhnn86.sgwapp"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"output": "static",
|
"output": "static",
|
||||||
@@ -28,6 +40,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
"expo-system-ui",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -45,11 +58,32 @@
|
|||||||
{
|
{
|
||||||
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
|
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-localization",
|
||||||
|
{
|
||||||
|
"supportedLocales": {
|
||||||
|
"ios": [
|
||||||
|
"en",
|
||||||
|
"vi"
|
||||||
|
],
|
||||||
|
"android": [
|
||||||
|
"en",
|
||||||
|
"vi"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
"reactCompiler": true
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "d4ef1318-5427-4c96-ad88-97ec117829cc"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
import { Tabs } from "expo-router";
|
import { Tabs, useSegments } from "expo-router";
|
||||||
|
|
||||||
import { HapticTab } from "@/components/haptic-tab";
|
import { HapticTab } from "@/components/haptic-tab";
|
||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
import { Colors } from "@/constants/theme";
|
import { Colors } from "@/constants/theme";
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
import { 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() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
const segments = useSegments() as string[];
|
||||||
|
const prev = useRef<string | null>(null);
|
||||||
|
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
useEffect(() => {
|
||||||
|
if (prev.current !== currentSegment) {
|
||||||
|
// console.log("Tab changed ->", { from: prev.current, to: currentSegment });
|
||||||
|
// TODO: xử lý khi chuyển tab ở đây
|
||||||
|
if (prev.current === "(tabs)" && currentSegment !== "(tabs)") {
|
||||||
|
stopEvents();
|
||||||
|
console.log("Stop events");
|
||||||
|
} else if (prev.current !== "(tabs)" && currentSegment === "(tabs)") {
|
||||||
|
// we came back into the tabs group — restart polling
|
||||||
|
startEvents();
|
||||||
|
console.log("start events");
|
||||||
|
}
|
||||||
|
prev.current = currentSegment;
|
||||||
|
}
|
||||||
|
}, [currentSegment]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -19,7 +41,7 @@ export default function TabLayout() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: "Giám sát",
|
title: t("navigation.home"),
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => (
|
||||||
<IconSymbol size={28} name="map.fill" color={color} />
|
<IconSymbol size={28} name="map.fill" color={color} />
|
||||||
),
|
),
|
||||||
@@ -29,7 +51,7 @@ export default function TabLayout() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="tripInfo"
|
name="tripInfo"
|
||||||
options={{
|
options={{
|
||||||
title: "Chuyến Đi",
|
title: t("navigation.trip"),
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => (
|
||||||
<IconSymbol size={28} name="ferry.fill" color={color} />
|
<IconSymbol size={28} name="ferry.fill" color={color} />
|
||||||
),
|
),
|
||||||
@@ -38,7 +60,7 @@ export default function TabLayout() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="diary"
|
name="diary"
|
||||||
options={{
|
options={{
|
||||||
title: "Nhật Ký",
|
title: t("navigation.diary"),
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => (
|
||||||
<IconSymbol size={28} name="book.closed.fill" color={color} />
|
<IconSymbol size={28} name="book.closed.fill" color={color} />
|
||||||
),
|
),
|
||||||
@@ -47,7 +69,7 @@ export default function TabLayout() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="sensor"
|
name="sensor"
|
||||||
options={{
|
options={{
|
||||||
title: "Cảm biến",
|
title: t("navigation.sensor"),
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => (
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
size={28}
|
size={28}
|
||||||
@@ -60,7 +82,7 @@ export default function TabLayout() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="setting"
|
name="setting"
|
||||||
options={{
|
options={{
|
||||||
title: "Cài đặt",
|
title: t("navigation.setting"),
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => (
|
||||||
<IconSymbol size={28} name="gear" color={color} />
|
<IconSymbol size={28} name="gear" color={color} />
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,57 +1,13 @@
|
|||||||
import AlarmList from "@/components/AlarmList";
|
import CreateOrUpdateHaulModal from "@/components/tripInfo/modal/CreateOrUpdateHaulModal";
|
||||||
import { Link } from "expo-router";
|
import { useState } from "react";
|
||||||
import {
|
import { Button, Platform, StyleSheet, View } from "react-native";
|
||||||
Platform,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const alarmExample = {
|
|
||||||
alarms: [
|
|
||||||
{
|
|
||||||
name: "Ngập nước có cảnh báo",
|
|
||||||
t: 1762226488,
|
|
||||||
level: 1,
|
|
||||||
id: "0:8:1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Tầu cảnh báo sos",
|
|
||||||
t: 1762226596,
|
|
||||||
level: 3,
|
|
||||||
id: "50:15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Khói có cảnh báo",
|
|
||||||
t: 1762226589,
|
|
||||||
level: 1,
|
|
||||||
id: "0:1:1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Cửa có cảnh báo",
|
|
||||||
t: 1762226547,
|
|
||||||
level: 1,
|
|
||||||
id: "0:7:1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
level: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Warning() {
|
export default function Warning() {
|
||||||
|
const [isShowModal, setIsShowModal] = useState(false);
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.titleText}>Nhật Ký Chuyến Đi</Text>
|
|
||||||
|
|
||||||
<Link href="/modal" asChild>
|
|
||||||
<TouchableOpacity style={styles.button}>
|
|
||||||
<Text style={styles.buttonText}>Mở Modal</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Link>
|
|
||||||
<AlarmList alarmsData={alarmExample.alarms} />
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -88,3 +44,4 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme.web";
|
import { useColorScheme } from "@/hooks/use-color-scheme.web";
|
||||||
import { usePlatform } from "@/hooks/use-platform";
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import {
|
import {
|
||||||
getAlarmEventBus,
|
getAlarmEventBus,
|
||||||
getBanzonesEventBus,
|
getBanzonesEventBus,
|
||||||
@@ -46,21 +47,17 @@ export default function HomeScreen() {
|
|||||||
const [circleRadius, setCircleRadius] = useState(100);
|
const [circleRadius, setCircleRadius] = useState(100);
|
||||||
const [zoomLevel, setZoomLevel] = useState(10);
|
const [zoomLevel, setZoomLevel] = useState(10);
|
||||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||||
const [polylineCoordinates, setPolylineCoordinates] =
|
const [polylineCoordinates, setPolylineCoordinates] = useState<
|
||||||
useState<PolylineWithLabelProps | null>(null);
|
PolylineWithLabelProps[]
|
||||||
|
>([]);
|
||||||
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
||||||
PolygonWithLabelProps[]
|
PolygonWithLabelProps[]
|
||||||
>([]);
|
>([]);
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const theme = useColorScheme();
|
const theme = useThemeContext().colorScheme;
|
||||||
const scale = useRef(new Animated.Value(0)).current;
|
const scale = useRef(new Animated.Value(0)).current;
|
||||||
const opacity = useRef(new Animated.Value(1)).current;
|
const opacity = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
// console.log("Platform: ", platform);
|
|
||||||
// console.log("Theme: ", theme);
|
|
||||||
|
|
||||||
// const [number, setNumber] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getGpsEventBus();
|
getGpsEventBus();
|
||||||
getAlarmEventBus();
|
getAlarmEventBus();
|
||||||
@@ -74,7 +71,7 @@ export default function HomeScreen() {
|
|||||||
} else {
|
} else {
|
||||||
setGpsData(null);
|
setGpsData(null);
|
||||||
setPolygonCoordinates([]);
|
setPolygonCoordinates([]);
|
||||||
setPolylineCoordinates(null);
|
setPolylineCoordinates([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const queryAlarmData = (alarmData: Model.AlarmResponse) => {
|
const queryAlarmData = (alarmData: Model.AlarmResponse) => {
|
||||||
@@ -95,7 +92,7 @@ export default function HomeScreen() {
|
|||||||
if (TrackPointsData && TrackPointsData.length > 0) {
|
if (TrackPointsData && TrackPointsData.length > 0) {
|
||||||
setTrackPointsData(TrackPointsData);
|
setTrackPointsData(TrackPointsData);
|
||||||
} else {
|
} else {
|
||||||
setTrackPointsData(null);
|
setTrackPointsData([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,8 +121,9 @@ export default function HomeScreen() {
|
|||||||
// console.log("Unsubscribed EVENT_TRACK_POINTS_DATA");
|
// console.log("Unsubscribed EVENT_TRACK_POINTS_DATA");
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPolylineCoordinates(null);
|
setPolylineCoordinates([]);
|
||||||
setPolygonCoordinates([]);
|
setPolygonCoordinates([]);
|
||||||
if (!entityData) return;
|
if (!entityData) return;
|
||||||
if (!banzoneData) return;
|
if (!banzoneData) return;
|
||||||
@@ -143,13 +141,16 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
// Nếu danh sách zone rỗng, clear tất cả
|
// Nếu danh sách zone rỗng, clear tất cả
|
||||||
if (zones.length === 0) {
|
if (zones.length === 0) {
|
||||||
setPolylineCoordinates(null);
|
setPolylineCoordinates([]);
|
||||||
setPolygonCoordinates([]);
|
setPolygonCoordinates([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let polylines: PolylineWithLabelProps[] = [];
|
||||||
|
let polygons: PolygonWithLabelProps[] = [];
|
||||||
|
|
||||||
for (const zone of zones) {
|
for (const zone of zones) {
|
||||||
console.log("Zone Data: ", zone);
|
// console.log("Zone Data: ", zone);
|
||||||
const geom = banzoneData.find((b) => b.id === zone.zone_id);
|
const geom = banzoneData.find((b) => b.id === zone.zone_id);
|
||||||
if (!geom) {
|
if (!geom) {
|
||||||
continue;
|
continue;
|
||||||
@@ -165,7 +166,7 @@ export default function HomeScreen() {
|
|||||||
geom_lines || ""
|
geom_lines || ""
|
||||||
);
|
);
|
||||||
if (coordinates.length > 0) {
|
if (coordinates.length > 0) {
|
||||||
setPolylineCoordinates({
|
polylines.push({
|
||||||
coordinates: coordinates.map((coord) => ({
|
coordinates: coordinates.map((coord) => ({
|
||||||
latitude: coord[0],
|
latitude: coord[0],
|
||||||
longitude: coord[1],
|
longitude: coord[1],
|
||||||
@@ -173,25 +174,31 @@ export default function HomeScreen() {
|
|||||||
label: zone?.zone_name ?? "",
|
label: zone?.zone_name ?? "",
|
||||||
content: zone?.message ?? "",
|
content: zone?.message ?? "",
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
console.log("Không tìm thấy polyline trong alarm");
|
||||||
}
|
}
|
||||||
} else if (geom_type === 1) {
|
} else if (geom_type === 1) {
|
||||||
// foundPolygon = true;
|
// foundPolygon = true;
|
||||||
const coordinates = convertWKTtoLatLngString(geom_poly || "");
|
const coordinates = convertWKTtoLatLngString(geom_poly || "");
|
||||||
if (coordinates.length > 0) {
|
if (coordinates.length > 0) {
|
||||||
console.log("Polygon Coordinate: ", coordinates);
|
// console.log("Polygon Coordinate: ", coordinates);
|
||||||
setPolygonCoordinates(
|
const zonePolygons = coordinates.map((polygon) => ({
|
||||||
coordinates.map((polygon) => ({
|
coordinates: polygon.map((coord) => ({
|
||||||
coordinates: polygon.map((coord) => ({
|
latitude: coord[0],
|
||||||
latitude: coord[0],
|
longitude: coord[1],
|
||||||
longitude: coord[1],
|
})),
|
||||||
})),
|
label: zone?.zone_name ?? "",
|
||||||
label: zone?.zone_name ?? "",
|
content: zone?.message ?? "",
|
||||||
content: zone?.message ?? "",
|
}));
|
||||||
}))
|
polygons.push(...zonePolygons);
|
||||||
);
|
} else {
|
||||||
|
console.log("Không tìm thấy polygon trong alarm");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPolylineCoordinates(polylines);
|
||||||
|
setPolygonCoordinates(polygons);
|
||||||
}
|
}
|
||||||
}, [banzoneData, entityData]);
|
}, [banzoneData, entityData]);
|
||||||
|
|
||||||
@@ -305,7 +312,7 @@ export default function HomeScreen() {
|
|||||||
// console.log(`Rendering circle ${index}:`, point);
|
// console.log(`Rendering circle ${index}:`, point);
|
||||||
return (
|
return (
|
||||||
<Circle
|
<Circle
|
||||||
key={index}
|
key={`circle-${index}`}
|
||||||
center={{
|
center={{
|
||||||
latitude: point.lat,
|
latitude: point.lat,
|
||||||
longitude: point.lon,
|
longitude: point.lon,
|
||||||
@@ -319,27 +326,27 @@ export default function HomeScreen() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{polylineCoordinates && (
|
{polylineCoordinates.length > 0 && (
|
||||||
<PolylineWithLabel
|
<>
|
||||||
key={`polyline-${gpsData?.lat || 0}-${gpsData?.lon || 0}`}
|
{polylineCoordinates.map((polyline, index) => (
|
||||||
coordinates={polylineCoordinates.coordinates}
|
<PolylineWithLabel
|
||||||
label={polylineCoordinates.label}
|
key={`polyline-${index}-${gpsData?.lat || 0}-${
|
||||||
content={polylineCoordinates.content}
|
gpsData?.lon || 0
|
||||||
strokeColor="#FF5733"
|
}`}
|
||||||
strokeWidth={4}
|
coordinates={polyline.coordinates}
|
||||||
showDistance={false}
|
label={polyline.label}
|
||||||
// zIndex={50}
|
content={polyline.content}
|
||||||
/>
|
strokeColor="#FF5733"
|
||||||
|
strokeWidth={4}
|
||||||
|
showDistance={false}
|
||||||
|
// zIndex={50}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{polygonCoordinates.length > 0 && (
|
{polygonCoordinates.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{polygonCoordinates.map((polygon, index) => {
|
{polygonCoordinates.map((polygon, index) => {
|
||||||
// Tạo key ổn định từ tọa độ đầu tiên của polygon
|
|
||||||
const polygonKey =
|
|
||||||
polygon.coordinates.length > 0
|
|
||||||
? `polygon-${polygon.coordinates[0].latitude}-${polygon.coordinates[0].longitude}-${index}`
|
|
||||||
: `polygon-${index}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PolygonWithLabel
|
<PolygonWithLabel
|
||||||
key={`polygon-${index}-${gpsData?.lat || 0}-${
|
key={`polygon-${index}-${gpsData?.lat || 0}-${
|
||||||
@@ -369,11 +376,6 @@ export default function HomeScreen() {
|
|||||||
latitude: gpsData.lat,
|
latitude: gpsData.lat,
|
||||||
longitude: gpsData.lon,
|
longitude: gpsData.lon,
|
||||||
}}
|
}}
|
||||||
title={
|
|
||||||
platform === IOS_PLATFORM
|
|
||||||
? "Tàu của mình - iOS"
|
|
||||||
: "Tàu của mình - Android"
|
|
||||||
}
|
|
||||||
zIndex={20}
|
zIndex={20}
|
||||||
anchor={
|
anchor={
|
||||||
platform === IOS_PLATFORM
|
platform === IOS_PLATFORM
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet } from "react-native";
|
import { useEffect, useState } from "react";
|
||||||
|
import { 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 { ThemedText } from "@/components/themed-text";
|
||||||
import { ThemedView } from "@/components/themed-view";
|
import { ThemedView } from "@/components/themed-view";
|
||||||
import { api } from "@/config";
|
import { DOMAIN, TOKEN } from "@/constants";
|
||||||
import { TOKEN } from "@/constants";
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import { removeStorageItem } from "@/utils/storage";
|
import { removeStorageItem } from "@/utils/storage";
|
||||||
import { useState } from "react";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
type Todo = {
|
type Todo = {
|
||||||
userId: number;
|
userId: number;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -18,54 +23,131 @@ type Todo = {
|
|||||||
export default function SettingScreen() {
|
export default function SettingScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [data, setData] = useState<Todo | null>(null);
|
const [data, setData] = useState<Todo | null>(null);
|
||||||
|
const { t, locale, setLocale } = useI18n();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [isEnabled, setIsEnabled] = useState(locale === "vi");
|
||||||
|
|
||||||
// useEffect(() => {
|
// Sync isEnabled state khi locale thay đổi
|
||||||
// getData();
|
useEffect(() => {
|
||||||
// }, []);
|
setIsEnabled(locale === "vi");
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
const getData = async () => {
|
const toggleSwitch = async () => {
|
||||||
try {
|
const newLocale = isEnabled ? "en" : "vi";
|
||||||
const response = await api.get("/todos/1");
|
await setLocale(newLocale);
|
||||||
setData(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
|
||||||
<ThemedText type="title">Settings</ThemedText>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedView
|
<ScrollView
|
||||||
style={styles.button}
|
style={styles.scrollView}
|
||||||
onTouchEnd={() => {
|
contentContainerStyle={styles.scrollContent}
|
||||||
removeStorageItem(TOKEN);
|
showsVerticalScrollIndicator={false}
|
||||||
router.replace("/auth/login");
|
>
|
||||||
}}
|
<ThemedText type="title" style={styles.title}>
|
||||||
>
|
{t("navigation.setting")}
|
||||||
<ThemedText type="defaultSemiBold">Đăng xuất</ThemedText>
|
</ThemedText>
|
||||||
|
|
||||||
|
{/* Theme Toggle Section */}
|
||||||
|
<ThemeToggle style={styles.themeSection} />
|
||||||
|
|
||||||
|
{/* Language Section */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.settingItem,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ThemedText type="default">{t("common.language")}</ThemedText>
|
||||||
|
<RotateSwitch
|
||||||
|
initialValue={isEnabled}
|
||||||
|
onChange={toggleSwitch}
|
||||||
|
size="sm"
|
||||||
|
offImage={EnIcon}
|
||||||
|
onImage={VnIcon}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<ThemedView
|
||||||
|
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||||
|
onTouchEnd={async () => {
|
||||||
|
await removeStorageItem(TOKEN);
|
||||||
|
await removeStorageItem(DOMAIN);
|
||||||
|
router.navigate("/auth/login");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemedText type="defaultSemiBold" style={styles.buttonText}>
|
||||||
|
{t("auth.logout")}
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<ThemedView
|
||||||
|
style={[styles.debugSection, { backgroundColor: colors.surface }]}
|
||||||
|
>
|
||||||
|
<ThemedText type="default">{data.title}</ThemedText>
|
||||||
|
<ThemedText type="default">{data.completed}</ThemedText>
|
||||||
|
<ThemedText type="default">{data.id}</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
{data && (
|
</SafeAreaView>
|
||||||
<ThemedView style={{ marginTop: 20 }}>
|
|
||||||
<ThemedText type="default">{data.title}</ThemedText>
|
|
||||||
<ThemedText type="default">{data.completed}</ThemedText>
|
|
||||||
<ThemedText type="default">{data.id}</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
)}
|
|
||||||
</ThemedView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: 5,
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
},
|
||||||
justifyContent: "center",
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
themeSection: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
settingItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
padding: 10,
|
paddingVertical: 14,
|
||||||
backgroundColor: "#007AFF",
|
paddingHorizontal: 20,
|
||||||
borderRadius: 5,
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
debugSection: {
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
gap: 8,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import CrewListTable from "@/components/tripInfo/CrewListTable";
|
|||||||
import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
|
import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
|
||||||
import NetListTable from "@/components/tripInfo/NetListTable";
|
import NetListTable from "@/components/tripInfo/NetListTable";
|
||||||
import TripCostTable from "@/components/tripInfo/TripCostTable";
|
import TripCostTable from "@/components/tripInfo/TripCostTable";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function TripInfoScreen() {
|
export default function TripInfoScreen() {
|
||||||
// const { trip, getTrip } = useTrip();
|
const { t } = useI18n();
|
||||||
// useEffect(() => {
|
const { colors } = useThemeContext();
|
||||||
// getTrip();
|
|
||||||
// }, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
|
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.titleText}>Thông Tin Chuyến Đi</Text>
|
<Text style={[styles.titleText, { color: colors.text }]}>
|
||||||
|
{t("trip.infoTrip")}
|
||||||
|
</Text>
|
||||||
<View style={styles.buttonWrapper}>
|
<View style={styles.buttonWrapper}>
|
||||||
<ButtonCreateNewHaulOrTrip />
|
<ButtonCreateNewHaulOrTrip />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -9,52 +9,62 @@ import { useEffect } from "react";
|
|||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
// import Toast from "react-native-toast-message";
|
// import Toast from "react-native-toast-message";
|
||||||
|
|
||||||
import { GluestackUIProvider } from "@/components/ui/gluestack-ui-provider/gluestack-ui-provider";
|
// import { toastConfig } from "@/config";
|
||||||
import { toastConfig } from "@/config";
|
import { toastConfig } from "@/config";
|
||||||
import { setRouterInstance } from "@/config/auth";
|
import { setRouterInstance } from "@/config/auth";
|
||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
import { I18nProvider } from "@/hooks/use-i18n";
|
||||||
|
import { ThemeProvider as AppThemeProvider, useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import "../global.css";
|
import "../global.css";
|
||||||
export default function RootLayout() {
|
function AppContent() {
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { colorScheme } = useThemeContext();
|
||||||
|
console.log("Color Scheme: ", colorScheme);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRouterInstance(router);
|
setRouterInstance(router);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GluestackUIProvider>
|
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
<Stack
|
||||||
<Stack
|
screenOptions={{ headerShown: false }}
|
||||||
screenOptions={{ headerShown: false }}
|
initialRouteName="auth/login"
|
||||||
initialRouteName="auth/login"
|
>
|
||||||
>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="auth/login"
|
||||||
name="auth/login"
|
options={{
|
||||||
options={{
|
title: "Login",
|
||||||
title: "Login",
|
headerShown: false,
|
||||||
headerShown: false,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(tabs)"
|
name="(tabs)"
|
||||||
options={{
|
options={{
|
||||||
title: "Home",
|
title: "Home",
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="modal"
|
name="modal"
|
||||||
options={{ presentation: "formSheet", title: "Modal" }}
|
options={{ presentation: "formSheet", title: "Modal" }}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
|
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</GluestackUIProvider>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<AppThemeProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AppThemeProvider>
|
||||||
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
|
import EnIcon from "@/assets/icons/en_icon.png";
|
||||||
|
import VnIcon from "@/assets/icons/vi_icon.png";
|
||||||
|
import RotateSwitch from "@/components/rotate-switch";
|
||||||
|
import ScanQRCode from "@/components/ScanQRCode";
|
||||||
import { ThemedText } from "@/components/themed-text";
|
import { ThemedText } from "@/components/themed-text";
|
||||||
import { ThemedView } from "@/components/themed-view";
|
import { ThemedView } from "@/components/themed-view";
|
||||||
import { 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 { login } from "@/controller/AuthController";
|
||||||
import { showErrorToast } from "@/services/toast_service";
|
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 {
|
import {
|
||||||
getStorageItem,
|
getStorageItem,
|
||||||
removeStorageItem,
|
removeStorageItem,
|
||||||
setStorageItem,
|
setStorageItem,
|
||||||
} from "@/utils/storage";
|
} from "@/utils/storage";
|
||||||
import { parseJwtToken } from "@/utils/token";
|
import { parseJwtToken } from "@/utils/token";
|
||||||
|
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
Image,
|
||||||
@@ -23,40 +36,92 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isShowingQRScanner, setIsShowingQRScanner] = useState(false);
|
||||||
|
const { t, setLocale, locale } = useI18n();
|
||||||
|
const { colors, colorScheme } = useTheme();
|
||||||
|
const { 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 checkLogin = useCallback(async () => {
|
||||||
const token = await getStorageItem(TOKEN);
|
const token = await getStorageItem(TOKEN);
|
||||||
console.log("Token:", token);
|
const domain = await getStorageItem(DOMAIN);
|
||||||
|
// console.log("Token:", token);
|
||||||
|
// removeStorageItem(DOMAIN);
|
||||||
|
console.log("Domain:", domain);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!domain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const parsed = parseJwtToken(token);
|
const parsed = parseJwtToken(token);
|
||||||
console.log("Parse Token: ", parsed);
|
|
||||||
|
|
||||||
const { exp } = parsed;
|
const { exp } = parsed;
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const oneHour = 60 * 60;
|
const oneHour = 60 * 60;
|
||||||
if (exp - now < oneHour) {
|
if (exp - now < oneHour) {
|
||||||
await removeStorageItem(TOKEN);
|
await removeStorageItem(TOKEN);
|
||||||
|
await removeStorageItem(DOMAIN);
|
||||||
} else {
|
} else {
|
||||||
router.replace("/(tabs)");
|
router.replace("/(tabs)");
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVNLang(locale === "vi");
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkLogin();
|
checkLogin();
|
||||||
}, [checkLogin]);
|
}, [checkLogin]);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleQRCodeScanned = async (data: string) => {
|
||||||
|
console.log("QR Code Scanned Data:", data);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed.username && parsed.password) {
|
||||||
|
// update UI fields
|
||||||
|
setUsername(parsed.username);
|
||||||
|
setPassword(parsed.password);
|
||||||
|
console.log("Domain: ", parsed.device_ip);
|
||||||
|
|
||||||
|
// close scanner so user sees the filled form
|
||||||
|
await setStorageItem(DOMAIN, parsed.device_ip);
|
||||||
|
|
||||||
|
// // call login directly with scanned credentials to avoid waiting for state to update
|
||||||
|
await handleLogin({
|
||||||
|
username: parsed.username,
|
||||||
|
password: parsed.password,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showWarningToast("Mã QR không hợp lệ");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showWarningToast("Mã QR không hợp lệ");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async (creds?: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}) => {
|
||||||
|
const user = creds?.username ?? username;
|
||||||
|
const pass = creds?.password ?? password;
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (!username.trim() || !password.trim()) {
|
if (!user?.trim() || !pass?.trim()) {
|
||||||
showErrorToast("Vui lòng nhập tài khoản và mật khẩu");
|
showErrorToast("Vui lòng nhập tài khoản và mật khẩu");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,8 +129,8 @@ export default function LoginScreen() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const body: Model.LoginRequestBody = {
|
const body: Model.LoginRequestBody = {
|
||||||
username: username,
|
username: user,
|
||||||
password: password,
|
password: pass,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await login(body);
|
const response = await login(body);
|
||||||
@@ -89,12 +154,24 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSwitchLanguage = (isVN: boolean) => {
|
||||||
|
if (isVN) {
|
||||||
|
setLocale("vi");
|
||||||
|
} else {
|
||||||
|
setLocale("en");
|
||||||
|
}
|
||||||
|
setIsVNLang(isVN);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
<ScrollView
|
||||||
|
style={{ flex: 1, backgroundColor: colors.background }}
|
||||||
|
contentContainerStyle={styles.scrollContainer}
|
||||||
|
>
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.headerContainer}>
|
<View style={styles.headerContainer}>
|
||||||
@@ -105,10 +182,7 @@ export default function LoginScreen() {
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
<ThemedText type="title" style={styles.title}>
|
<ThemedText type="title" style={styles.title}>
|
||||||
Hệ thống giám sát tàu cá
|
{t("common.app_name")}
|
||||||
</ThemedText>
|
|
||||||
<ThemedText style={styles.subtitle}>
|
|
||||||
Đăng nhập để tiếp tục
|
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -116,11 +190,11 @@ export default function LoginScreen() {
|
|||||||
<View style={styles.formContainer}>
|
<View style={styles.formContainer}>
|
||||||
{/* Username Input */}
|
{/* Username Input */}
|
||||||
<View style={styles.inputGroup}>
|
<View style={styles.inputGroup}>
|
||||||
<ThemedText style={styles.label}>Tài khoản</ThemedText>
|
<ThemedText style={styles.label}>{t("auth.username")}</ThemedText>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Nhập tài khoản"
|
placeholder={t("auth.username_placeholder")}
|
||||||
placeholderTextColor="#999"
|
placeholderTextColor={placeholderColor}
|
||||||
value={username}
|
value={username}
|
||||||
onChangeText={setUsername}
|
onChangeText={setUsername}
|
||||||
editable={!loading}
|
editable={!loading}
|
||||||
@@ -130,137 +204,265 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
{/* Password Input */}
|
{/* Password Input */}
|
||||||
<View style={styles.inputGroup}>
|
<View style={styles.inputGroup}>
|
||||||
<ThemedText style={styles.label}>Mật khẩu</ThemedText>
|
<ThemedText style={styles.label}>{t("auth.password")}</ThemedText>
|
||||||
<TextInput
|
<View className="relative">
|
||||||
style={styles.input}
|
<TextInput
|
||||||
placeholder="Nhập mật khẩu"
|
style={styles.input}
|
||||||
placeholderTextColor="#999"
|
placeholder={t("auth.password_placeholder")}
|
||||||
value={password}
|
placeholderTextColor={placeholderColor}
|
||||||
onChangeText={setPassword}
|
value={password}
|
||||||
secureTextEntry
|
onChangeText={setPassword}
|
||||||
editable={!loading}
|
secureTextEntry={!showPassword}
|
||||||
autoCapitalize="none"
|
editable={!loading}
|
||||||
/>
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
{/* Position absolute with top:0 and bottom:0 and justifyContent:center
|
||||||
|
ensures the icon remains vertically centered inside the input */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={showPassword ? "eye-off" : "eye"}
|
||||||
|
size={22}
|
||||||
|
color={colors.icon}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Login Button */}
|
{/* Login Button (3/4) + QR Scan (1/4) */}
|
||||||
<TouchableOpacity
|
<View
|
||||||
style={[
|
style={{
|
||||||
styles.loginButton,
|
flexDirection: "row",
|
||||||
loading && styles.loginButtonDisabled,
|
alignItems: "center",
|
||||||
]}
|
justifyContent: "center",
|
||||||
onPress={handleLogin}
|
}}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
{loading ? (
|
<TouchableOpacity
|
||||||
<ActivityIndicator color="#fff" size="small" />
|
style={[
|
||||||
) : (
|
styles.loginButton,
|
||||||
<Text style={styles.loginButtonText}>Đăng nhập</Text>
|
loading && styles.loginButtonDisabled,
|
||||||
)}
|
{ flex: 5, marginRight: 12, marginTop: 0 },
|
||||||
</TouchableOpacity>
|
]}
|
||||||
|
onPress={() => handleLogin()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={buttonTextColor} size="small" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[styles.loginButtonText, { color: buttonTextColor }]}
|
||||||
|
>
|
||||||
|
{t("auth.login")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Footer text */}
|
<TouchableOpacity
|
||||||
<View style={styles.footerContainer}>
|
style={{
|
||||||
<ThemedText style={styles.footerText}>
|
flex: 1,
|
||||||
Chưa có tài khoản?{" "}
|
paddingVertical: 10,
|
||||||
<Text style={styles.linkText}>Đăng ký ngay</Text>
|
marginTop: 0,
|
||||||
</ThemedText>
|
borderColor: colors.border,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
onPress={() => setIsShowingQRScanner(true)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="qr-code-scanner"
|
||||||
|
size={28}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<View style={styles.languageSwitcherContainer}>
|
||||||
|
<RotateSwitch
|
||||||
|
initialValue={isVNLang}
|
||||||
|
onChange={handleSwitchLanguage}
|
||||||
|
size="sm"
|
||||||
|
offImage={EnIcon}
|
||||||
|
onImage={VnIcon}
|
||||||
|
/>
|
||||||
|
<SliceSwitch
|
||||||
|
size="sm"
|
||||||
|
leftIcon="moon"
|
||||||
|
leftIconColor={
|
||||||
|
colorScheme === "dark" ? colors.background : colors.surface
|
||||||
|
}
|
||||||
|
rightIcon="sunny"
|
||||||
|
rightIconColor={
|
||||||
|
colorScheme === "dark" ? colors.warning : "orange"
|
||||||
|
}
|
||||||
|
activeBackgroundColor={colors.text}
|
||||||
|
inactiveBackgroundColor={colors.surface}
|
||||||
|
inactiveOverlayColor={colors.textSecondary}
|
||||||
|
activeOverlayColor={colors.background}
|
||||||
|
value={colorScheme === "light"}
|
||||||
|
onChange={(val) => {
|
||||||
|
setThemeMode(val ? "light" : "dark");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Copyright */}
|
{/* Copyright */}
|
||||||
<View style={styles.copyrightContainer}>
|
<View style={styles.copyrightContainer}>
|
||||||
<ThemedText style={styles.copyrightText}>
|
<ThemedText style={styles.copyrightText}>
|
||||||
© {new Date().getFullYear()} - Sản phẩm của Mobifone
|
© {new Date().getFullYear()} - {t("common.footer_text")}
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
<ScanQRCode
|
||||||
|
visible={isShowingQRScanner}
|
||||||
|
onClose={() => setIsShowingQRScanner(false)}
|
||||||
|
onScanned={handleQRCodeScanned}
|
||||||
|
/>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const createStyles = (colors: typeof Colors.light, scheme: ThemeColorScheme) =>
|
||||||
scrollContainer: {
|
StyleSheet.create({
|
||||||
flexGrow: 1,
|
scrollContainer: {
|
||||||
justifyContent: "center",
|
flexGrow: 1,
|
||||||
},
|
justifyContent: "center",
|
||||||
container: {
|
backgroundColor: colors.background,
|
||||||
flex: 1,
|
},
|
||||||
paddingHorizontal: 20,
|
container: {
|
||||||
justifyContent: "center",
|
flex: 1,
|
||||||
},
|
paddingHorizontal: 20,
|
||||||
headerContainer: {
|
justifyContent: "center",
|
||||||
marginBottom: 40,
|
},
|
||||||
alignItems: "center",
|
headerContainer: {
|
||||||
},
|
marginBottom: 40,
|
||||||
logo: {
|
alignItems: "center",
|
||||||
width: 120,
|
},
|
||||||
height: 120,
|
logo: {
|
||||||
marginBottom: 20,
|
width: 120,
|
||||||
},
|
height: 120,
|
||||||
title: {
|
marginBottom: 20,
|
||||||
fontSize: 28,
|
},
|
||||||
fontWeight: "bold",
|
title: {
|
||||||
marginBottom: 8,
|
fontSize: 28,
|
||||||
},
|
fontWeight: "bold",
|
||||||
subtitle: {
|
marginBottom: 8,
|
||||||
fontSize: 16,
|
},
|
||||||
opacity: 0.7,
|
subtitle: {
|
||||||
},
|
fontSize: 16,
|
||||||
formContainer: {
|
opacity: 0.7,
|
||||||
gap: 16,
|
},
|
||||||
},
|
formContainer: {
|
||||||
inputGroup: {
|
gap: 16,
|
||||||
gap: 8,
|
},
|
||||||
},
|
inputGroup: {
|
||||||
label: {
|
gap: 8,
|
||||||
fontSize: 14,
|
},
|
||||||
fontWeight: "600",
|
label: {
|
||||||
},
|
fontSize: 14,
|
||||||
input: {
|
fontWeight: "600",
|
||||||
borderWidth: 1,
|
},
|
||||||
borderColor: "#ddd",
|
input: {
|
||||||
borderRadius: 8,
|
borderWidth: 1,
|
||||||
paddingHorizontal: 12,
|
borderColor: colors.border,
|
||||||
paddingVertical: 12,
|
borderRadius: 8,
|
||||||
fontSize: 16,
|
paddingHorizontal: 12,
|
||||||
backgroundColor: "#f5f5f5",
|
paddingVertical: 12,
|
||||||
color: "#000",
|
fontSize: 16,
|
||||||
},
|
backgroundColor: colors.surface,
|
||||||
loginButton: {
|
color: colors.text,
|
||||||
backgroundColor: "#007AFF",
|
},
|
||||||
paddingVertical: 14,
|
loginButton: {
|
||||||
borderRadius: 8,
|
backgroundColor: colors.primary,
|
||||||
alignItems: "center",
|
paddingVertical: 14,
|
||||||
marginTop: 16,
|
borderRadius: 8,
|
||||||
},
|
alignItems: "center",
|
||||||
loginButtonDisabled: {
|
marginTop: 16,
|
||||||
opacity: 0.6,
|
},
|
||||||
},
|
loginButtonDisabled: {
|
||||||
loginButtonText: {
|
opacity: 0.6,
|
||||||
color: "#fff",
|
},
|
||||||
fontSize: 16,
|
loginButtonText: {
|
||||||
fontWeight: "600",
|
fontSize: 16,
|
||||||
},
|
fontWeight: "600",
|
||||||
footerContainer: {
|
},
|
||||||
marginTop: 16,
|
footerContainer: {
|
||||||
alignItems: "center",
|
marginTop: 16,
|
||||||
},
|
alignItems: "center",
|
||||||
footerText: {
|
},
|
||||||
fontSize: 14,
|
footerText: {
|
||||||
},
|
fontSize: 14,
|
||||||
linkText: {
|
},
|
||||||
color: "#007AFF",
|
linkText: {
|
||||||
fontWeight: "600",
|
color: colors.primary,
|
||||||
},
|
fontWeight: "600",
|
||||||
copyrightContainer: {
|
},
|
||||||
marginTop: 20,
|
copyrightContainer: {
|
||||||
alignItems: "center",
|
marginTop: 20,
|
||||||
},
|
alignItems: "center",
|
||||||
copyrightText: {
|
},
|
||||||
fontSize: 12,
|
copyrightText: {
|
||||||
opacity: 0.6,
|
fontSize: 12,
|
||||||
textAlign: "center",
|
opacity: 0.6,
|
||||||
},
|
textAlign: "center",
|
||||||
});
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
languageSwitcherContainer: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 24,
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
languageSwitcherLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
languageButtonsContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
languageButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
languageButtonActive: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
languageButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
languageButtonTextActive: {
|
||||||
|
color: scheme === "dark" ? colors.text : colors.surface,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
BIN
assets/icons/en_icon.png
Normal file
BIN
assets/icons/en_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/icons/vi_icon.png
Normal file
BIN
assets/icons/vi_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -1,3 +1,4 @@
|
|||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
@@ -7,16 +8,18 @@ interface ButtonCancelTripProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
|
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
|
||||||
title = "Hủy chuyến đi",
|
title,
|
||||||
onPress,
|
onPress,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const displayTitle = title || t("trip.buttonCancelTrip.title");
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.button}
|
style={styles.button}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={styles.text}>{title}</Text>
|
<Text style={styles.text}>{displayTitle}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
queryStartNewHaul,
|
queryStartNewHaul,
|
||||||
queryUpdateTripState,
|
queryUpdateTripState,
|
||||||
} from "@/controller/TripController";
|
} from "@/controller/TripController";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import {
|
import {
|
||||||
showErrorToast,
|
showErrorToast,
|
||||||
showSuccessToast,
|
showSuccessToast,
|
||||||
@@ -32,6 +33,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isStarted, setIsStarted] = useState(false);
|
const [isStarted, setIsStarted] = useState(false);
|
||||||
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
|
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { trip, getTrip } = useTrip();
|
const { trip, getTrip } = useTrip();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,34 +46,30 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
if (isStarted) {
|
if (isStarted) {
|
||||||
Alert.alert(
|
Alert.alert(t("trip.endHaulTitle"), t("trip.endHaulConfirm"), [
|
||||||
"Kết thúc mẻ lưới",
|
|
||||||
"Bạn có chắc chắn muốn kết thúc mẻ lưới này?",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Hủy",
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Kết thúc",
|
|
||||||
onPress: () => {
|
|
||||||
setIsStarted(false);
|
|
||||||
Alert.alert("Thành công", "Đã kết thúc mẻ lưới!");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Alert.alert("Bắt đầu mẻ lưới", "Bạn có muốn bắt đầu mẻ lưới mới?", [
|
|
||||||
{
|
{
|
||||||
text: "Hủy",
|
text: t("trip.cancelButton"),
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Bắt đầu",
|
text: t("trip.endButton"),
|
||||||
|
onPress: () => {
|
||||||
|
setIsStarted(false);
|
||||||
|
Alert.alert(t("trip.successTitle"), t("trip.endHaulSuccess"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Alert.alert(t("trip.startHaulTitle"), t("trip.startHaulConfirm"), [
|
||||||
|
{
|
||||||
|
text: t("trip.cancelButton"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("trip.startButton"),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
setIsStarted(true);
|
setIsStarted(true);
|
||||||
Alert.alert("Thành công", "Đã bắt đầu mẻ lưới mới!");
|
Alert.alert(t("trip.successTitle"), t("trip.startHaulSuccess"));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -84,7 +82,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|||||||
|
|
||||||
const handleStartTrip = async (state: number, note?: string) => {
|
const handleStartTrip = async (state: number, note?: string) => {
|
||||||
if (trip?.trip_status !== 2) {
|
if (trip?.trip_status !== 2) {
|
||||||
showWarningToast("Chuyến đi đã được bắt đầu hoặc hoàn thành.");
|
showWarningToast(t("trip.alreadyStarted"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -93,8 +91,8 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|||||||
note: note || "",
|
note: note || "",
|
||||||
});
|
});
|
||||||
if (resp.status === 200) {
|
if (resp.status === 200) {
|
||||||
showSuccessToast("Bắt đầu chuyến đi thành công!");
|
showSuccessToast(t("trip.startTripSuccess"));
|
||||||
getTrip();
|
await getTrip();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error stating trip :", error);
|
console.error("Error stating trip :", error);
|
||||||
@@ -104,9 +102,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|||||||
|
|
||||||
const createNewHaul = async () => {
|
const createNewHaul = async () => {
|
||||||
if (trip?.fishing_logs?.some((f) => f.status === 0)) {
|
if (trip?.fishing_logs?.some((f) => f.status === 0)) {
|
||||||
showWarningToast(
|
showWarningToast(t("trip.finishCurrentHaul"));
|
||||||
"Vui lòng kết thúc mẻ lưới hiện tại trước khi bắt đầu mẻ mới"
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!gpsData) {
|
if (!gpsData) {
|
||||||
@@ -119,19 +115,19 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|||||||
start_at: new Date(),
|
start_at: new Date(),
|
||||||
start_lat: gpsData.lat,
|
start_lat: gpsData.lat,
|
||||||
start_lon: gpsData.lon,
|
start_lon: gpsData.lon,
|
||||||
weather_description: "Nắng đẹp",
|
weather_description: t("trip.weatherDescription"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const resp = await queryStartNewHaul(body);
|
const resp = await queryStartNewHaul(body);
|
||||||
if (resp.status === 200) {
|
if (resp.status === 200) {
|
||||||
showSuccessToast("Bắt đầu mẻ lưới mới thành công!");
|
showSuccessToast(t("trip.startHaulSuccess"));
|
||||||
getTrip();
|
await getTrip();
|
||||||
} else {
|
} else {
|
||||||
showErrorToast("Tạo mẻ lưới mới thất bại!");
|
showErrorToast(t("trip.createHaulFailed"));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
// showErrorToast("Tạo mẻ lưới mới thất bại!");
|
// showErrorToast(t("trip.createHaulFailed"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,7 +145,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|||||||
style={{ backgroundColor: "green", borderRadius: 10 }}
|
style={{ backgroundColor: "green", borderRadius: 10 }}
|
||||||
onPress={async () => handleStartTrip(3)}
|
onPress={async () => handleStartTrip(3)}
|
||||||
>
|
>
|
||||||
Bắt đầu chuyến đi
|
{t("trip.startTrip")}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
) : checkHaulFinished() ? (
|
) : checkHaulFinished() ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -158,7 +154,7 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|||||||
style={{ borderRadius: 10 }}
|
style={{ borderRadius: 10 }}
|
||||||
onPress={() => setIsFinishHaulModalOpen(true)}
|
onPress={() => setIsFinishHaulModalOpen(true)}
|
||||||
>
|
>
|
||||||
Kết thúc mẻ lưới
|
{t("trip.endHaul")}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
) : (
|
) : (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -169,10 +165,12 @@ const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|||||||
createNewHaul();
|
createNewHaul();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Bắt đầu mẻ lưới
|
{t("trip.startHaul")}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<CreateOrUpdateHaulModal
|
<CreateOrUpdateHaulModal
|
||||||
|
fishingLog={trip?.fishing_logs?.find((f) => f.status === 0)!}
|
||||||
|
fishingLogIndex={trip?.fishing_logs?.length!}
|
||||||
isVisible={isFinishHaulModalOpen}
|
isVisible={isFinishHaulModalOpen}
|
||||||
onClose={function (): void {
|
onClose={function (): void {
|
||||||
setIsFinishHaulModalOpen(false);
|
setIsFinishHaulModalOpen(false);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
@@ -6,17 +7,16 @@ interface ButtonEndTripProps {
|
|||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({
|
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({ title, onPress }) => {
|
||||||
title = "Kết thúc",
|
const { t } = useI18n();
|
||||||
onPress,
|
const displayTitle = title || t("trip.buttonEndTrip.title");
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.button}
|
style={styles.button}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<Text style={styles.text}>{title}</Text>
|
<Text style={styles.text}>{displayTitle}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export default function ScanQRCode({
|
|||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [scanned, setScanned] = useState(false);
|
const [scanned, setScanned] = useState(false);
|
||||||
const cameraRef = useRef(null);
|
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
|
// Request camera permission when component mounts or when visible changes to true
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +40,13 @@ export default function ScanQRCode({
|
|||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
|
// Mỗi khi reset scanned state thì reset luôn ref guard
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scanned) {
|
||||||
|
hasScannedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [scanned]);
|
||||||
|
|
||||||
const handleBarCodeScanned = ({
|
const handleBarCodeScanned = ({
|
||||||
type,
|
type,
|
||||||
data,
|
data,
|
||||||
@@ -45,11 +54,12 @@ export default function ScanQRCode({
|
|||||||
type: string;
|
type: string;
|
||||||
data: string;
|
data: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (!scanned) {
|
// Nếu đã scan rồi, bỏ qua
|
||||||
setScanned(true);
|
if (hasScannedRef.current || scanned) return;
|
||||||
onScanned(data);
|
hasScannedRef.current = true;
|
||||||
onClose();
|
setScanned(true);
|
||||||
}
|
onScanned(data);
|
||||||
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!permission) {
|
if (!permission) {
|
||||||
@@ -102,7 +112,8 @@ export default function ScanQRCode({
|
|||||||
<CameraView
|
<CameraView
|
||||||
ref={cameraRef}
|
ref={cameraRef}
|
||||||
style={styles.camera}
|
style={styles.camera}
|
||||||
onBarcodeScanned={handleBarCodeScanned}
|
// 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={{
|
barcodeScannerSettings={{
|
||||||
barcodeTypes: ["qr"],
|
barcodeTypes: ["qr"],
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import { AntDesign } from "@expo/vector-icons";
|
import { AntDesign } from "@expo/vector-icons";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +32,7 @@ export interface SelectProps {
|
|||||||
mode?: "single" | "multiple"; // multiple not implemented yet
|
mode?: "single" | "multiple"; // multiple not implemented yet
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
size?: "small" | "middle" | "large";
|
size?: "small" | "middle" | "large";
|
||||||
|
listStyle?: StyleProp<ViewStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +52,7 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
showSearch = false,
|
showSearch = false,
|
||||||
mode = "single",
|
mode = "single",
|
||||||
style,
|
style,
|
||||||
|
listStyle,
|
||||||
size = "middle",
|
size = "middle",
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedValue, setSelectedValue] = useState<
|
const [selectedValue, setSelectedValue] = useState<
|
||||||
@@ -91,6 +94,12 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
|
|
||||||
const sz = sizeMap[size];
|
const sz = sizeMap[size];
|
||||||
|
|
||||||
|
// Theme colors from context (consistent with other components)
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const selectBackgroundColor = disabled
|
||||||
|
? colors.backgroundSecondary
|
||||||
|
: colors.surface;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrapper}>
|
<View style={styles.wrapper}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -99,7 +108,8 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
{
|
{
|
||||||
height: sz.height,
|
height: sz.height,
|
||||||
paddingHorizontal: sz.paddingHorizontal,
|
paddingHorizontal: sz.paddingHorizontal,
|
||||||
opacity: disabled ? 0.6 : 1,
|
backgroundColor: selectBackgroundColor,
|
||||||
|
borderColor: disabled ? colors.border : colors.primary,
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
@@ -110,14 +120,18 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator size="small" color="#4ecdc4" />
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.text,
|
styles.text,
|
||||||
{
|
{
|
||||||
fontSize: sz.fontSize,
|
fontSize: sz.fontSize,
|
||||||
color: selectedValue ? "#111" : "#999",
|
color: disabled
|
||||||
|
? colors.textSecondary
|
||||||
|
: selectedValue
|
||||||
|
? colors.text
|
||||||
|
: colors.textSecondary,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@@ -129,37 +143,59 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
<View style={styles.suffix}>
|
<View style={styles.suffix}>
|
||||||
{allowClear && selectedValue && !loading ? (
|
{allowClear && selectedValue && !loading ? (
|
||||||
<TouchableOpacity onPress={handleClear} style={styles.icon}>
|
<TouchableOpacity onPress={handleClear} style={styles.icon}>
|
||||||
<AntDesign name="close" size={16} color="#999" />
|
<AntDesign name="close" size={16} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null}
|
) : null}
|
||||||
<AntDesign
|
<AntDesign
|
||||||
name={isOpen ? "up" : "down"}
|
name={isOpen ? "up" : "down"}
|
||||||
size={14}
|
size={14}
|
||||||
color="#999"
|
color={colors.textSecondary}
|
||||||
style={styles.arrow}
|
style={styles.arrow}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<View style={[styles.dropdown, { top: containerHeight }]}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.dropdown,
|
||||||
|
{
|
||||||
|
top: containerHeight,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.searchInput}
|
style={[
|
||||||
|
styles.searchInput,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderColor: colors.border,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
]}
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChangeText={setSearchText}
|
onChangeText={setSearchText}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ScrollView style={styles.list}>
|
<ScrollView style={[styles.list, listStyle]}>
|
||||||
{filteredOptions.map((item) => (
|
{filteredOptions.map((item) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.value}
|
key={item.value}
|
||||||
style={[
|
style={[
|
||||||
styles.option,
|
styles.option,
|
||||||
|
{
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
item.disabled && styles.optionDisabled,
|
item.disabled && styles.optionDisabled,
|
||||||
selectedValue === item.value && styles.optionSelected,
|
selectedValue === item.value && {
|
||||||
|
backgroundColor: colors.primary + "20", // Add transparency to primary color
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
onPress={() => !item.disabled && handleSelect(item.value)}
|
onPress={() => !item.disabled && handleSelect(item.value)}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
@@ -167,14 +203,22 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.optionText,
|
styles.optionText,
|
||||||
item.disabled && styles.optionTextDisabled,
|
{
|
||||||
selectedValue === item.value && styles.optionTextSelected,
|
color: colors.text,
|
||||||
|
},
|
||||||
|
item.disabled && {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
selectedValue === item.value && {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
{selectedValue === item.value && (
|
{selectedValue === item.value && (
|
||||||
<AntDesign name="check" size={16} color="#4ecdc4" />
|
<AntDesign name="check" size={16} color={colors.primary} />
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
@@ -191,9 +235,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#e6e6e6",
|
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
backgroundColor: "#fff",
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
@@ -202,7 +244,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
color: "#111",
|
// Color is set dynamically via theme
|
||||||
},
|
},
|
||||||
suffix: {
|
suffix: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -218,9 +260,7 @@ const styles = StyleSheet.create({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#e6e6e6",
|
|
||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
borderBottomLeftRadius: 8,
|
borderBottomLeftRadius: 8,
|
||||||
@@ -234,7 +274,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
searchInput: {
|
searchInput: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#e6e6e6",
|
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
margin: 8,
|
margin: 8,
|
||||||
@@ -245,7 +284,6 @@ const styles = StyleSheet.create({
|
|||||||
option: {
|
option: {
|
||||||
padding: 12,
|
padding: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: "#f0f0f0",
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -253,20 +291,11 @@ const styles = StyleSheet.create({
|
|||||||
optionDisabled: {
|
optionDisabled: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
optionSelected: {
|
// optionSelected is handled dynamically via inline styles
|
||||||
backgroundColor: "#f6ffed",
|
|
||||||
},
|
|
||||||
optionText: {
|
optionText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: "#111",
|
|
||||||
},
|
|
||||||
optionTextDisabled: {
|
|
||||||
color: "#999",
|
|
||||||
},
|
|
||||||
optionTextSelected: {
|
|
||||||
color: "#4ecdc4",
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
},
|
||||||
|
// optionTextDisabled and optionTextSelected are handled dynamically via inline styles
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Select;
|
export default Select;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Text, View } from "react-native";
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
interface DescriptionProps {
|
interface DescriptionProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -8,10 +10,15 @@ export const Description = ({
|
|||||||
title = "",
|
title = "",
|
||||||
description = "",
|
description = "",
|
||||||
}: DescriptionProps) => {
|
}: DescriptionProps) => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
return (
|
return (
|
||||||
<View className="flex-row gap-2 ">
|
<View className="flex-row gap-2 ">
|
||||||
<Text className="opacity-50 text-lg">{title}:</Text>
|
<ThemedText
|
||||||
<Text className="text-lg">{description}</Text>
|
style={{ color: colors.textSecondary, fontSize: 16 }}
|
||||||
|
>
|
||||||
|
{title}:
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={{ fontSize: 16 }}>{description}</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import { convertToDMS, kmhToKnot } from "@/utils/geom";
|
import { convertToDMS, kmhToKnot } from "@/utils/geom";
|
||||||
import { MaterialIcons } from "@expo/vector-icons";
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -13,6 +15,8 @@ const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
|
|||||||
const [panelHeight, setPanelHeight] = useState(0);
|
const [panelHeight, setPanelHeight] = useState(0);
|
||||||
const translateY = useRef(new Animated.Value(0)).current;
|
const translateY = useRef(new Animated.Value(0)).current;
|
||||||
const blockBottom = useRef(new Animated.Value(0)).current;
|
const blockBottom = useRef(new Animated.Value(0)).current;
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors, styles } = useAppTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Animated.timing(translateY, {
|
Animated.timing(translateY, {
|
||||||
@@ -43,58 +47,48 @@ const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: blockBottom,
|
bottom: blockBottom,
|
||||||
left: 5,
|
left: 5,
|
||||||
// width: 48,
|
|
||||||
// height: 48,
|
|
||||||
// backgroundColor: "blue",
|
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
zIndex: 30,
|
zIndex: 30,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ButtonCreateNewHaulOrTrip gpsData={gpsData} />
|
<ButtonCreateNewHaulOrTrip gpsData={gpsData} />
|
||||||
{/* <TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
// showInfoToast("oad");
|
|
||||||
showWarningToast("This is a warning toast!");
|
|
||||||
}}
|
|
||||||
className="absolute top-2 right-2 z-10 bg-white rounded-full p-1"
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={isExpanded ? "close" : "close"}
|
|
||||||
size={20}
|
|
||||||
color="#666"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity> */}
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={[
|
||||||
transform: [{ translateY }],
|
styles.card,
|
||||||
}}
|
{
|
||||||
className="absolute bottom-0 gap-3 right-0 p-3 left-0 h-auto w-full rounded-t-xl bg-white shadow-md"
|
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)}
|
onLayout={(event) => setPanelHeight(event.nativeEvent.layout.height)}
|
||||||
>
|
>
|
||||||
{/* Nút toggle ở top-right */}
|
{/* Nút toggle ở top-right */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={togglePanel}
|
onPress={togglePanel}
|
||||||
className="absolute top-2 right-2 z-10 bg-white rounded-full p-1"
|
className="absolute top-2 right-2 z-10 rounded-full p-1"
|
||||||
|
style={{ backgroundColor: colors.card }}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name={isExpanded ? "close" : "close"}
|
name={isExpanded ? "close" : "close"}
|
||||||
size={20}
|
size={20}
|
||||||
color="#666"
|
color={colors.icon}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View className="flex-row justify-between">
|
<View className="flex-row justify-between">
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Description
|
<Description
|
||||||
title="Kinh độ"
|
title={t("home.latitude")}
|
||||||
description={convertToDMS(gpsData?.lat ?? 0, true)}
|
description={convertToDMS(gpsData?.lat ?? 0, true)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Description
|
<Description
|
||||||
title="Vĩ độ"
|
title={t("home.longitude")}
|
||||||
description={convertToDMS(gpsData?.lon ?? 0, false)}
|
description={convertToDMS(gpsData?.lon ?? 0, false)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -102,12 +96,15 @@ const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
|
|||||||
<View className="flex-row justify-between">
|
<View className="flex-row justify-between">
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Description
|
<Description
|
||||||
title="Tốc độ"
|
title={t("home.speed")}
|
||||||
description={`${kmhToKnot(gpsData?.s ?? 0).toString()} knot`}
|
description={`${kmhToKnot(gpsData?.s ?? 0).toString()} knot`}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Description title="Hướng" description={`${gpsData?.h ?? 0}°`} />
|
<Description
|
||||||
|
title={t("home.heading")}
|
||||||
|
description={`${gpsData?.h ?? 0}°`}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -116,9 +113,10 @@ const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
|
|||||||
{!isExpanded && (
|
{!isExpanded && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={togglePanel}
|
onPress={togglePanel}
|
||||||
className="absolute bottom-5 right-2 z-20 bg-white rounded-full p-2 shadow-lg"
|
className="absolute bottom-5 right-2 z-20 rounded-full p-2 shadow-lg"
|
||||||
|
style={{ backgroundColor: colors.card }}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="info-outline" size={24} />
|
<MaterialIcons name="info-outline" size={24} color={colors.icon} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
import { showToastError } from "@/config";
|
|
||||||
import {
|
import {
|
||||||
queryDeleteSos,
|
queryDeleteSos,
|
||||||
queryGetSos,
|
queryGetSos,
|
||||||
querySendSosMessage,
|
querySendSosMessage,
|
||||||
} from "@/controller/DeviceController";
|
} from "@/controller/DeviceController";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { showErrorToast } from "@/services/toast_service";
|
||||||
import { sosMessage } from "@/utils/sosUtils";
|
import { sosMessage } from "@/utils/sosUtils";
|
||||||
import { MaterialIcons } from "@expo/vector-icons";
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
FlatList,
|
import IconButton from "../IconButton";
|
||||||
ScrollView,
|
import Select from "../Select";
|
||||||
StyleSheet,
|
import Modal from "../ui/modal";
|
||||||
Text,
|
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Button, ButtonText } from "../ui/gluestack-ui-provider/button";
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
ModalBackdrop,
|
|
||||||
ModalBody,
|
|
||||||
ModalContent,
|
|
||||||
ModalFooter,
|
|
||||||
ModalHeader,
|
|
||||||
} from "../ui/gluestack-ui-provider/modal";
|
|
||||||
|
|
||||||
const SosButton = () => {
|
const SosButton = () => {
|
||||||
const [sosData, setSosData] = useState<Model.SosResponse | null>();
|
const [sosData, setSosData] = useState<Model.SosResponse | null>();
|
||||||
@@ -33,17 +21,33 @@ const SosButton = () => {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [customMessage, setCustomMessage] = useState("");
|
const [customMessage, setCustomMessage] = useState("");
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
|
||||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
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 = [
|
const sosOptions = [
|
||||||
...sosMessage.map((msg) => ({ ma: msg.ma, moTa: msg.moTa })),
|
...sosMessage.map((msg) => ({
|
||||||
{ ma: 999, moTa: "Khác" },
|
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 () => {
|
const getSosData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await queryGetSos();
|
const response = await queryGetSos();
|
||||||
|
// console.log("SoS ResponseL: ", response);
|
||||||
|
|
||||||
setSosData(response.data);
|
setSosData(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch SOS data:", error);
|
console.error("Failed to fetch SOS data:", error);
|
||||||
@@ -57,10 +61,8 @@ const SosButton = () => {
|
|||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const newErrors: { [key: string]: string } = {};
|
const newErrors: { [key: string]: string } = {};
|
||||||
|
|
||||||
// Không cần validate sosMessage vì luôn có default value (11)
|
|
||||||
|
|
||||||
if (selectedSosMessage === 999 && customMessage.trim() === "") {
|
if (selectedSosMessage === 999 && customMessage.trim() === "") {
|
||||||
newErrors.customMessage = "Vui lòng nhập trạng thái";
|
newErrors.customMessage = t("home.sos.statusRequired");
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
@@ -68,27 +70,34 @@ const SosButton = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmSos = async () => {
|
const handleConfirmSos = async () => {
|
||||||
if (validateForm()) {
|
if (!validateForm()) {
|
||||||
let messageToSend = "";
|
console.log("Form chưa validate");
|
||||||
if (selectedSosMessage === 999) {
|
return; // Không đóng modal nếu validate fail
|
||||||
messageToSend = customMessage.trim();
|
|
||||||
} else {
|
|
||||||
const selectedOption = sosOptions.find(
|
|
||||||
(opt) => opt.ma === selectedSosMessage
|
|
||||||
);
|
|
||||||
messageToSend = selectedOption ? selectedOption.moTa : "";
|
|
||||||
}
|
|
||||||
// Gửi dữ liệu đi
|
|
||||||
setShowConfirmSosDialog(false);
|
|
||||||
// Reset form
|
|
||||||
setSelectedSosMessage(null);
|
|
||||||
setCustomMessage("");
|
|
||||||
setErrors({});
|
|
||||||
await sendSosMessage(messageToSend);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
const handleClickButton = async (isActive: boolean) => {
|
||||||
|
console.log("Is Active: ", isActive);
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
const resp = await queryDeleteSos();
|
const resp = await queryDeleteSos();
|
||||||
if (resp.status === 200) {
|
if (resp.status === 200) {
|
||||||
@@ -108,178 +117,102 @@ const SosButton = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error when send sos: ", error);
|
console.error("Error when send sos: ", error);
|
||||||
showToastError("Không thể gửi tín hiệu SOS", "Lỗi");
|
showErrorToast(t("home.sos.sendError"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<IconButton
|
||||||
className="shadow-md rounded-full"
|
icon={<MaterialIcons name="warning" size={20} color="white" />}
|
||||||
size="lg"
|
type="danger"
|
||||||
action="negative"
|
size="middle"
|
||||||
onPress={() => handleClickButton(sosData?.active || false)}
|
onPress={() => handleClickButton(sosData?.active || false)}
|
||||||
|
style={{ borderRadius: 20 }}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="warning" size={15} color="white" />
|
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
|
||||||
<ButtonText className="text-center">
|
</IconButton>
|
||||||
{sosData?.active ? "Đang trong trạng thái khẩn cấp" : "Khẩn cấp"}
|
|
||||||
</ButtonText>
|
|
||||||
{/* <ButtonSpinner /> */}
|
|
||||||
{/* <ButtonIcon /> */}
|
|
||||||
</Button>
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showConfirmSosDialog}
|
open={showConfirmSosDialog}
|
||||||
onClose={() => {
|
onCancel={() => {
|
||||||
setShowConfirmSosDialog(false);
|
setShowConfirmSosDialog(false);
|
||||||
setSelectedSosMessage(null);
|
setSelectedSosMessage(null);
|
||||||
setCustomMessage("");
|
setCustomMessage("");
|
||||||
setErrors({});
|
setErrors({});
|
||||||
}}
|
}}
|
||||||
|
okText={t("home.sos.confirm")}
|
||||||
|
cancelText={t("home.sos.cancel")}
|
||||||
|
title={t("home.sos.title")}
|
||||||
|
centered
|
||||||
|
onOk={handleConfirmSos}
|
||||||
>
|
>
|
||||||
<ModalBackdrop />
|
{/* Select Nội dung SOS */}
|
||||||
<ModalContent>
|
<View style={styles.formGroup}>
|
||||||
<ModalHeader className="flex-col gap-0.5 items-center">
|
<Text style={styles.label}>{t("home.sos.content")}</Text>
|
||||||
<Text
|
|
||||||
style={{ fontSize: 18, fontWeight: "bold", textAlign: "center" }}
|
|
||||||
>
|
|
||||||
Thông báo khẩn cấp
|
|
||||||
</Text>
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalBody className="mb-4">
|
|
||||||
<ScrollView style={{ maxHeight: 400 }}>
|
|
||||||
{/* Dropdown Nội dung SOS */}
|
|
||||||
<View style={styles.formGroup}>
|
|
||||||
<Text style={styles.label}>Nội dung:</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.dropdownButton,
|
|
||||||
errors.sosMessage ? styles.errorBorder : {},
|
|
||||||
]}
|
|
||||||
onPress={() => setShowDropdown(!showDropdown)}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.dropdownButtonText,
|
|
||||||
!selectedSosMessage && styles.placeholderText,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{selectedSosMessage !== null
|
|
||||||
? sosOptions.find((opt) => opt.ma === selectedSosMessage)
|
|
||||||
?.moTa || "Chọn lý do"
|
|
||||||
: "Chọn lý do"}
|
|
||||||
</Text>
|
|
||||||
<MaterialIcons
|
|
||||||
name={showDropdown ? "expand-less" : "expand-more"}
|
|
||||||
size={20}
|
|
||||||
color="#666"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{errors.sosMessage && (
|
|
||||||
<Text style={styles.errorText}>{errors.sosMessage}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Input Custom Message nếu chọn "Khác" */}
|
<Select
|
||||||
{selectedSosMessage === 999 && (
|
value={selectedSosMessage ?? undefined}
|
||||||
<View style={styles.formGroup}>
|
options={sosOptions}
|
||||||
<Text style={styles.label}>Nhập trạng thái</Text>
|
placeholder={t("home.sos.selectReason")}
|
||||||
<TextInput
|
onChange={(value) => {
|
||||||
style={[
|
setSelectedSosMessage(value as number);
|
||||||
styles.input,
|
// Clear custom message nếu chọn khác lý do
|
||||||
errors.customMessage ? styles.errorInput : {},
|
if (value !== 999) {
|
||||||
]}
|
|
||||||
placeholder="Mô tả trạng thái khẩn cấp"
|
|
||||||
placeholderTextColor="#999"
|
|
||||||
value={customMessage}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
setCustomMessage(text);
|
|
||||||
if (text.trim() !== "") {
|
|
||||||
setErrors((prev) => {
|
|
||||||
const newErrors = { ...prev };
|
|
||||||
delete newErrors.customMessage;
|
|
||||||
return newErrors;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
multiline
|
|
||||||
numberOfLines={4}
|
|
||||||
/>
|
|
||||||
{errors.customMessage && (
|
|
||||||
<Text style={styles.errorText}>{errors.customMessage}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter className="flex-row items-start gap-2">
|
|
||||||
<Button
|
|
||||||
onPress={handleConfirmSos}
|
|
||||||
// className="w-1/3"
|
|
||||||
action="negative"
|
|
||||||
>
|
|
||||||
<ButtonText>Xác nhận</ButtonText>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
setShowConfirmSosDialog(false);
|
|
||||||
setSelectedSosMessage(null);
|
|
||||||
setCustomMessage("");
|
setCustomMessage("");
|
||||||
setErrors({});
|
}
|
||||||
}}
|
// Clear error if exists
|
||||||
// className="w-1/3"
|
if (errors.sosMessage) {
|
||||||
action="secondary"
|
setErrors((prev) => {
|
||||||
>
|
const newErrors = { ...prev };
|
||||||
<ButtonText>Hủy</ButtonText>
|
delete newErrors.sosMessage;
|
||||||
</Button>
|
return newErrors;
|
||||||
</ModalFooter>
|
});
|
||||||
</ModalContent>
|
}
|
||||||
</Modal>
|
}}
|
||||||
|
showSearch={false}
|
||||||
|
style={[errors.sosMessage ? styles.errorBorder : undefined]}
|
||||||
|
/>
|
||||||
|
{errors.sosMessage && (
|
||||||
|
<Text style={styles.errorText}>{errors.sosMessage}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Dropdown Modal - Nổi lên */}
|
{/* Input Custom Message nếu chọn "Khác" */}
|
||||||
{showDropdown && showConfirmSosDialog && (
|
{selectedSosMessage === 999 && (
|
||||||
<Modal isOpen={showDropdown} onClose={() => setShowDropdown(false)}>
|
<View style={styles.formGroup}>
|
||||||
<TouchableOpacity
|
<Text style={styles.label}>{t("home.sos.statusInput")}</Text>
|
||||||
style={styles.dropdownOverlay}
|
<TextInput
|
||||||
activeOpacity={1}
|
style={[
|
||||||
onPress={() => setShowDropdown(false)}
|
styles.input,
|
||||||
>
|
errors.customMessage ? styles.errorInput : {},
|
||||||
<View style={styles.dropdownModalContainer}>
|
]}
|
||||||
<FlatList
|
placeholder={t("home.sos.enterStatus")}
|
||||||
data={sosOptions}
|
placeholderTextColor={textColor + '99'} // Add transparency
|
||||||
keyExtractor={(item) => item.ma.toString()}
|
value={customMessage}
|
||||||
renderItem={({ item }) => (
|
onChangeText={(text) => {
|
||||||
<TouchableOpacity
|
setCustomMessage(text);
|
||||||
style={styles.dropdownModalItem}
|
if (text.trim() !== "") {
|
||||||
onPress={() => {
|
setErrors((prev) => {
|
||||||
setSelectedSosMessage(item.ma);
|
const newErrors = { ...prev };
|
||||||
setShowDropdown(false);
|
delete newErrors.customMessage;
|
||||||
// Clear custom message nếu chọn khác lý do
|
return newErrors;
|
||||||
if (item.ma !== 999) {
|
});
|
||||||
setCustomMessage("");
|
}
|
||||||
}
|
}}
|
||||||
}}
|
multiline
|
||||||
>
|
numberOfLines={4}
|
||||||
<Text
|
/>
|
||||||
style={[
|
{errors.customMessage && (
|
||||||
styles.dropdownModalItemText,
|
<Text style={styles.errorText}>{errors.customMessage}</Text>
|
||||||
selectedSosMessage === item.ma &&
|
)}
|
||||||
styles.selectedItemText,
|
</View>
|
||||||
]}
|
)}
|
||||||
>
|
</Modal>
|
||||||
{item.moTa}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const SosButtonStyles = (textColor: string, borderColor: string, errorColor: string, backgroundColor: string) => StyleSheet.create({
|
||||||
formGroup: {
|
formGroup: {
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
@@ -287,93 +220,27 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
color: "#333",
|
color: textColor,
|
||||||
},
|
|
||||||
dropdownButton: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#ddd",
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
},
|
},
|
||||||
errorBorder: {
|
errorBorder: {
|
||||||
borderColor: "#ff4444",
|
borderColor: errorColor,
|
||||||
},
|
|
||||||
dropdownButtonText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: "#333",
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
placeholderText: {
|
|
||||||
color: "#999",
|
|
||||||
},
|
|
||||||
dropdownList: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#ddd",
|
|
||||||
borderRadius: 8,
|
|
||||||
marginTop: 4,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
dropdownItem: {
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#eee",
|
|
||||||
},
|
|
||||||
dropdownItemText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: "#333",
|
|
||||||
},
|
|
||||||
dropdownOverlay: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
dropdownModalContainer: {
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderRadius: 12,
|
|
||||||
maxHeight: 400,
|
|
||||||
minWidth: 280,
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 10,
|
|
||||||
},
|
|
||||||
dropdownModalItem: {
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 14,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#f0f0f0",
|
|
||||||
},
|
|
||||||
dropdownModalItemText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: "#333",
|
|
||||||
},
|
|
||||||
selectedItemText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#1054C9",
|
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#ddd",
|
borderColor: borderColor,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: "#333",
|
color: textColor,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
textAlignVertical: "top",
|
textAlignVertical: "top",
|
||||||
},
|
},
|
||||||
errorInput: {
|
errorInput: {
|
||||||
borderColor: "#ff4444",
|
borderColor: errorColor,
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
color: "#ff4444",
|
color: errorColor,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import type { PropsWithChildren, ReactElement } from 'react';
|
import type { PropsWithChildren, ReactElement } from 'react';
|
||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useScrollOffset,
|
useScrollOffset,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
import { ThemedView } from '@/components/themed-view';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
import { useColorScheme } from '@/hooks/use-theme-context';
|
||||||
|
|
||||||
const HEADER_HEIGHT = 250;
|
const HEADER_HEIGHT = 250;
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export default function ParallaxScrollView({
|
|||||||
headerBackgroundColor,
|
headerBackgroundColor,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const backgroundColor = useThemeColor({}, 'background');
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
const colorScheme = useColorScheme() ?? 'light';
|
const colorScheme = useColorScheme();
|
||||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||||
const scrollOffset = useScrollOffset(scrollRef);
|
const scrollOffset = useScrollOffset(scrollRef);
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||||
|
|||||||
307
components/rotate-switch.tsx
Normal file
307
components/rotate-switch.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Image,
|
||||||
|
ImageSourcePropType,
|
||||||
|
Pressable,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
||||||
|
|
||||||
|
const SIZE_PRESETS = {
|
||||||
|
sm: { width: 64, height: 32 },
|
||||||
|
md: { width: 80, height: 40 },
|
||||||
|
lg: { width: 96, height: 48 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SwitchSize = keyof typeof SIZE_PRESETS;
|
||||||
|
|
||||||
|
const DEFAULT_TOGGLE_DURATION = 400;
|
||||||
|
const DEFAULT_OFF_IMAGE =
|
||||||
|
"https://images.vexels.com/media/users/3/163966/isolated/preview/6ecbb5ec8c121c0699c9b9179d6b24aa-england-flag-language-icon-circle.png";
|
||||||
|
const DEFAULT_ON_IMAGE =
|
||||||
|
"https://cdn-icons-png.flaticon.com/512/197/197473.png";
|
||||||
|
const DEFAULT_INACTIVE_BG = "#D3DAD9";
|
||||||
|
const DEFAULT_ACTIVE_BG = "#C2E2FA";
|
||||||
|
const PRESSED_SCALE = 0.96;
|
||||||
|
const PRESS_FEEDBACK_DURATION = 120;
|
||||||
|
|
||||||
|
type RotateSwitchProps = {
|
||||||
|
size?: SwitchSize;
|
||||||
|
onImage?: ImageSourcePropType | string;
|
||||||
|
offImage?: ImageSourcePropType | string;
|
||||||
|
initialValue?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
activeBackgroundColor?: string;
|
||||||
|
inactiveBackgroundColor?: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
onChange?: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toImageSource = (
|
||||||
|
input: ImageSourcePropType | string | undefined,
|
||||||
|
fallbackUri: string
|
||||||
|
): ImageSourcePropType => {
|
||||||
|
if (typeof input === "string") {
|
||||||
|
return { uri: input };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uri: fallbackUri };
|
||||||
|
};
|
||||||
|
|
||||||
|
const RotateSwitch = ({
|
||||||
|
size = "md",
|
||||||
|
onImage,
|
||||||
|
offImage,
|
||||||
|
duration,
|
||||||
|
activeBackgroundColor = DEFAULT_ACTIVE_BG,
|
||||||
|
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
|
||||||
|
initialValue = false,
|
||||||
|
style,
|
||||||
|
onChange,
|
||||||
|
}: RotateSwitchProps) => {
|
||||||
|
const { width: containerWidth, height: containerHeight } =
|
||||||
|
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
|
||||||
|
const knobSize = containerHeight;
|
||||||
|
const knobTravel = containerWidth - knobSize;
|
||||||
|
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
|
||||||
|
|
||||||
|
const resolvedOffImage = useMemo(
|
||||||
|
() => toImageSource(offImage, DEFAULT_OFF_IMAGE),
|
||||||
|
[offImage]
|
||||||
|
);
|
||||||
|
const resolvedOnImage = useMemo(
|
||||||
|
() => toImageSource(onImage, DEFAULT_ON_IMAGE),
|
||||||
|
[onImage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isOn, setIsOn] = useState(initialValue);
|
||||||
|
const [bgOn, setBgOn] = useState(initialValue);
|
||||||
|
const [displaySource, setDisplaySource] = useState<ImageSourcePropType>(
|
||||||
|
initialValue ? resolvedOnImage : resolvedOffImage
|
||||||
|
);
|
||||||
|
|
||||||
|
const progress = useRef(new Animated.Value(initialValue ? 1 : 0)).current;
|
||||||
|
const pressScale = useRef(new Animated.Value(1)).current;
|
||||||
|
const listenerIdRef = useRef<string | number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplaySource(bgOn ? resolvedOnImage : resolvedOffImage);
|
||||||
|
}, [bgOn, resolvedOffImage, resolvedOnImage]);
|
||||||
|
|
||||||
|
const removeProgressListener = () => {
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachHalfwaySwapListener = (next: boolean) => {
|
||||||
|
removeProgressListener();
|
||||||
|
let swapped = false;
|
||||||
|
listenerIdRef.current = progress.addListener(({ value }) => {
|
||||||
|
if (swapped) return;
|
||||||
|
const crossedHalfway = next ? value >= 0.5 : value <= 0.5;
|
||||||
|
if (!crossedHalfway) return;
|
||||||
|
swapped = true;
|
||||||
|
setBgOn(next);
|
||||||
|
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
|
||||||
|
removeProgressListener();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up listener on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
removeProgressListener();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep internal state in sync when `initialValue` prop changes.
|
||||||
|
// Users may pass a changing `initialValue` (like from parent state) and
|
||||||
|
// expect the switch to reflect that. Animate `progress` toward the
|
||||||
|
// corresponding value and update images/background when done.
|
||||||
|
useEffect(() => {
|
||||||
|
// If no change, do nothing
|
||||||
|
if (initialValue === isOn) return;
|
||||||
|
|
||||||
|
const next = initialValue;
|
||||||
|
const targetValue = next ? 1 : 0;
|
||||||
|
|
||||||
|
progress.stopAnimation();
|
||||||
|
removeProgressListener();
|
||||||
|
|
||||||
|
if (animationDuration <= 0) {
|
||||||
|
progress.setValue(targetValue);
|
||||||
|
setIsOn(next);
|
||||||
|
setBgOn(next);
|
||||||
|
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update isOn immediately so accessibilityState etc. reflect change.
|
||||||
|
setIsOn(next);
|
||||||
|
|
||||||
|
attachHalfwaySwapListener(next);
|
||||||
|
|
||||||
|
Animated.timing(progress, {
|
||||||
|
toValue: targetValue,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
// Ensure final state reflects the target in case animation skips halfway listener.
|
||||||
|
setBgOn(next);
|
||||||
|
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
initialValue,
|
||||||
|
isOn,
|
||||||
|
animationDuration,
|
||||||
|
progress,
|
||||||
|
resolvedOffImage,
|
||||||
|
resolvedOnImage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const knobTranslateX = progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, knobTravel],
|
||||||
|
});
|
||||||
|
|
||||||
|
const knobRotation = progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ["0deg", "180deg"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatePress = (toValue: number) => {
|
||||||
|
Animated.timing(pressScale, {
|
||||||
|
toValue,
|
||||||
|
duration: PRESS_FEEDBACK_DURATION,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
animatePress(PRESSED_SCALE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressOut = () => {
|
||||||
|
animatePress(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const next = !isOn;
|
||||||
|
const targetValue = next ? 1 : 0;
|
||||||
|
|
||||||
|
progress.stopAnimation();
|
||||||
|
removeProgressListener();
|
||||||
|
|
||||||
|
if (animationDuration <= 0) {
|
||||||
|
progress.setValue(targetValue);
|
||||||
|
setIsOn(next);
|
||||||
|
setBgOn(next);
|
||||||
|
onChange?.(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOn(next);
|
||||||
|
|
||||||
|
attachHalfwaySwapListener(next);
|
||||||
|
|
||||||
|
Animated.timing(progress, {
|
||||||
|
toValue: targetValue,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setBgOn(next);
|
||||||
|
onChange?.(next);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggle}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
|
accessibilityRole="switch"
|
||||||
|
accessibilityState={{ checked: isOn }}
|
||||||
|
style={[styles.pressable, style]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.shadowWrapper,
|
||||||
|
{
|
||||||
|
transform: [{ scale: pressScale }],
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
backgroundColor: bgOn
|
||||||
|
? activeBackgroundColor
|
||||||
|
: inactiveBackgroundColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<AnimatedImage
|
||||||
|
source={displaySource}
|
||||||
|
style={[
|
||||||
|
styles.knob,
|
||||||
|
{
|
||||||
|
width: knobSize,
|
||||||
|
height: knobSize,
|
||||||
|
borderRadius: knobSize / 2,
|
||||||
|
transform: [
|
||||||
|
{ translateX: knobTranslateX },
|
||||||
|
{ rotate: knobRotation },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pressable: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
shadowWrapper: {
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 6,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
knob: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default RotateSwitch;
|
||||||
154
components/theme-example.tsx
Normal file
154
components/theme-example.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Example component demonstrating theme usage
|
||||||
|
* Shows different ways to use the theme system
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { View, Text, TouchableOpacity, ScrollView } from "react-native";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||||
|
|
||||||
|
export function ThemeExampleComponent() {
|
||||||
|
const { colors, styles, utils } = useAppTheme();
|
||||||
|
|
||||||
|
// Example of using useThemeColor hook
|
||||||
|
const customTextColor = useThemeColor({}, "textSecondary");
|
||||||
|
const customBackgroundColor = useThemeColor({}, "surfaceSecondary");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container}>
|
||||||
|
<ThemedView style={styles.surface}>
|
||||||
|
<ThemedText type="title">Theme Examples</ThemedText>
|
||||||
|
|
||||||
|
{/* Using themed components */}
|
||||||
|
<ThemedText type="subtitle">Themed Components</ThemedText>
|
||||||
|
<ThemedView style={styles.card}>
|
||||||
|
<ThemedText>This is a themed text</ThemedText>
|
||||||
|
<ThemedText type="defaultSemiBold">
|
||||||
|
This is bold themed text
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
{/* Using theme colors directly */}
|
||||||
|
<ThemedText type="subtitle">Direct Color Usage</ThemedText>
|
||||||
|
<View
|
||||||
|
style={[styles.card, { borderColor: colors.primary, borderWidth: 2 }]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.text, fontSize: 16 }}>
|
||||||
|
Using colors.text directly
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{ color: colors.primary, fontSize: 14, fontWeight: "600" }}
|
||||||
|
>
|
||||||
|
Primary color text
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Using pre-styled components */}
|
||||||
|
<ThemedText type="subtitle">Pre-styled Components</ThemedText>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<TouchableOpacity style={styles.primaryButton}>
|
||||||
|
<Text style={styles.primaryButtonText}>Primary Button</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.secondaryButton}>
|
||||||
|
<Text style={styles.secondaryButtonText}>Secondary Button</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status containers */}
|
||||||
|
<ThemedText type="subtitle">Status Indicators</ThemedText>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.successContainer}>
|
||||||
|
<Text style={{ color: colors.success, fontWeight: "600" }}>
|
||||||
|
Success Message
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.warningContainer}>
|
||||||
|
<Text style={{ color: colors.warning, fontWeight: "600" }}>
|
||||||
|
Warning Message
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={{ color: colors.error, fontWeight: "600" }}>
|
||||||
|
Error Message
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Using opacity colors */}
|
||||||
|
<ThemedText type="subtitle">Opacity Colors</ThemedText>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.surface,
|
||||||
|
{ backgroundColor: utils.getOpacityColor("primary", 0.1) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.primary }}>
|
||||||
|
Primary with 10% opacity background
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.surface,
|
||||||
|
{ backgroundColor: utils.getOpacityColor("error", 0.2) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.error }}>
|
||||||
|
Error with 20% opacity background
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Theme utilities */}
|
||||||
|
<ThemedText type="subtitle">Theme Utilities</ThemedText>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={{ color: colors.text }}>
|
||||||
|
Is Dark Mode: {utils.isDark ? "Yes" : "No"}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.text }}>
|
||||||
|
Is Light Mode: {utils.isLight ? "Yes" : "No"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.primaryButton, { marginTop: 10 }]}
|
||||||
|
onPress={utils.toggleTheme}
|
||||||
|
>
|
||||||
|
<Text style={styles.primaryButtonText}>
|
||||||
|
Toggle Theme (Light/Dark)
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Custom themed component example */}
|
||||||
|
<ThemedText type="subtitle">Custom Component</ThemedText>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: customBackgroundColor,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: customTextColor,
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Custom component using useThemeColor
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ThemedView>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
components/theme-toggle.tsx
Normal file
109
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Theme Toggle Component for switching between light, dark, and system themes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { View, TouchableOpacity, StyleSheet } from "react-native";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
interface ThemeToggleProps {
|
||||||
|
style?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle({ style }: ThemeToggleProps) {
|
||||||
|
const { themeMode, setThemeMode, colors } = useThemeContext();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const themeOptions = [
|
||||||
|
{
|
||||||
|
mode: "light" as const,
|
||||||
|
label: t("common.theme_light"),
|
||||||
|
icon: "sunny-outline" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "dark" as const,
|
||||||
|
label: t("common.theme_dark"),
|
||||||
|
icon: "moon-outline" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "system" as const,
|
||||||
|
label: t("common.theme_system"),
|
||||||
|
icon: "phone-portrait-outline" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.container, style, { backgroundColor: colors.surface }]}
|
||||||
|
>
|
||||||
|
<ThemedText style={styles.title}>{t("common.theme")}</ThemedText>
|
||||||
|
<View style={styles.optionsContainer}>
|
||||||
|
{themeOptions.map((option) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.mode}
|
||||||
|
style={[
|
||||||
|
styles.option,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
themeMode === option.mode
|
||||||
|
? colors.primary
|
||||||
|
: colors.backgroundSecondary,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => setThemeMode(option.mode)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={option.icon}
|
||||||
|
size={20}
|
||||||
|
color={themeMode === option.mode ? "#fff" : colors.icon}
|
||||||
|
/>
|
||||||
|
<ThemedText
|
||||||
|
style={[
|
||||||
|
styles.optionText,
|
||||||
|
{ color: themeMode === option.mode ? "#fff" : colors.text },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
optionsContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import { useTrip } from "@/state/use-trip";
|
import { useTrip } from "@/state/use-trip";
|
||||||
import React, { useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
import CrewDetailModal from "./modal/CrewDetailModal";
|
import CrewDetailModal from "./modal/CrewDetailModal";
|
||||||
import styles from "./style/CrewListTable.styles";
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { createTableStyles } from "./ThemedTable";
|
||||||
|
|
||||||
const CrewListTable: React.FC = () => {
|
const CrewListTable: React.FC = () => {
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
@@ -13,6 +16,10 @@ const CrewListTable: React.FC = () => {
|
|||||||
const [selectedCrew, setSelectedCrew] = useState<Model.TripCrews | null>(
|
const [selectedCrew, setSelectedCrew] = useState<Model.TripCrews | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
const { trip } = useTrip();
|
const { trip } = useTrip();
|
||||||
|
|
||||||
@@ -51,14 +58,14 @@ const CrewListTable: React.FC = () => {
|
|||||||
onPress={handleToggle}
|
onPress={handleToggle}
|
||||||
style={styles.headerRow}
|
style={styles.headerRow}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>Danh sách thuyền viên</Text>
|
<Text style={styles.title}>{t("trip.crewList.title")}</Text>
|
||||||
{collapsed && (
|
{collapsed && (
|
||||||
<Text style={styles.totalCollapsed}>{tongThanhVien}</Text>
|
<Text style={styles.totalCollapsed}>{tongThanhVien}</Text>
|
||||||
)}
|
)}
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
name={collapsed ? "chevron.down" : "chevron.up"}
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
size={16}
|
size={16}
|
||||||
color="#000"
|
color={colors.icon}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -75,10 +82,12 @@ const CrewListTable: React.FC = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={[styles.row, styles.tableHeader]}>
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
<View style={styles.cellWrapper}>
|
<View style={styles.cellWrapper}>
|
||||||
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.crewList.nameHeader")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||||
Chức vụ
|
{t("trip.crewList.roleHeader")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -99,7 +108,9 @@ const CrewListTable: React.FC = () => {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={[styles.row]}>
|
<View style={[styles.row]}>
|
||||||
<Text style={[styles.cell, styles.footerText]}>Tổng cộng</Text>
|
<Text style={[styles.cell, styles.footerText]}>
|
||||||
|
{t("trip.crewList.totalLabel")}
|
||||||
|
</Text>
|
||||||
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -109,10 +120,12 @@ const CrewListTable: React.FC = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={[styles.row, styles.tableHeader]}>
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
<View style={styles.cellWrapper}>
|
<View style={styles.cellWrapper}>
|
||||||
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.crewList.nameHeader")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||||
Chức vụ
|
{t("trip.crewList.roleHeader")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -133,7 +146,9 @@ const CrewListTable: React.FC = () => {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={[styles.row]}>
|
<View style={[styles.row]}>
|
||||||
<Text style={[styles.cell, styles.footerText]}>Tổng cộng</Text>
|
<Text style={[styles.cell, styles.footerText]}>
|
||||||
|
{t("trip.crewList.totalLabel")}
|
||||||
|
</Text>
|
||||||
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import { useTrip } from "@/state/use-trip";
|
import { useTrip } from "@/state/use-trip";
|
||||||
import React, { useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
import styles from "./style/FishingToolsTable.styles";
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { createTableStyles } from "./ThemedTable";
|
||||||
|
|
||||||
const FishingToolsTable: React.FC = () => {
|
const FishingToolsTable: React.FC = () => {
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
const animatedHeight = useRef(new Animated.Value(0)).current;
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
const { trip } = useTrip();
|
const { trip } = useTrip();
|
||||||
const data: Model.FishingGear[] = trip?.fishing_gears ?? [];
|
const data: Model.FishingGear[] = trip?.fishing_gears ?? [];
|
||||||
@@ -31,12 +38,12 @@ const FishingToolsTable: React.FC = () => {
|
|||||||
onPress={handleToggle}
|
onPress={handleToggle}
|
||||||
style={styles.headerRow}
|
style={styles.headerRow}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>Danh sách ngư cụ</Text>
|
<Text style={styles.title}>{t("trip.fishingTools.title")}</Text>
|
||||||
{collapsed && <Text style={styles.totalCollapsed}>{tongSoLuong}</Text>}
|
{collapsed && <Text style={styles.totalCollapsed}>{tongSoLuong}</Text>}
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
name={collapsed ? "chevron.down" : "chevron.up"}
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
size={16}
|
size={16}
|
||||||
color="#000"
|
color={colors.icon}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -52,9 +59,11 @@ const FishingToolsTable: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{/* Table Header */}
|
{/* Table Header */}
|
||||||
<View style={[styles.row, styles.tableHeader]}>
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.headerText]}>Tên</Text>
|
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||||
|
{t("trip.fishingTools.nameHeader")}
|
||||||
|
</Text>
|
||||||
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||||
Số lượng
|
{t("trip.fishingTools.quantityHeader")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -69,7 +78,7 @@ const FishingToolsTable: React.FC = () => {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={[styles.row]}>
|
<View style={[styles.row]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||||
Tổng cộng
|
{t("trip.fishingTools.totalLabel")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
|
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
|
||||||
{tongSoLuong}
|
{tongSoLuong}
|
||||||
@@ -81,9 +90,11 @@ const FishingToolsTable: React.FC = () => {
|
|||||||
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||||
{/* Table Header */}
|
{/* Table Header */}
|
||||||
<View style={[styles.row, styles.tableHeader]}>
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.headerText]}>Tên</Text>
|
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||||
|
{t("trip.fishingTools.nameHeader")}
|
||||||
|
</Text>
|
||||||
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||||
Số lượng
|
{t("trip.fishingTools.quantityHeader")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -98,7 +109,7 @@ const FishingToolsTable: React.FC = () => {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={[styles.row]}>
|
<View style={[styles.row]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||||
Tổng cộng
|
{t("trip.fishingTools.totalLabel")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
|
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
|
||||||
{tongSoLuong}
|
{tongSoLuong}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import { useFishes } from "@/state/use-fish";
|
import { useFishes } from "@/state/use-fish";
|
||||||
import { useTrip } from "@/state/use-trip";
|
import { useTrip } from "@/state/use-trip";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
import NetDetailModal from "./modal/NetDetailModal/NetDetailModal";
|
import CreateOrUpdateHaulModal from "./modal/CreateOrUpdateHaulModal";
|
||||||
import styles from "./style/NetListTable.styles";
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { createTableStyles } from "./ThemedTable";
|
||||||
|
|
||||||
const NetListTable: React.FC = () => {
|
const NetListTable: React.FC = () => {
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
@@ -12,13 +15,16 @@ const NetListTable: React.FC = () => {
|
|||||||
const animatedHeight = useRef(new Animated.Value(0)).current;
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [selectedNet, setSelectedNet] = useState<Model.FishingLog | null>(null);
|
const [selectedNet, setSelectedNet] = useState<Model.FishingLog | null>(null);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
const { trip } = useTrip();
|
const { trip } = useTrip();
|
||||||
const { fishSpecies, getFishSpecies } = useFishes();
|
const { fishSpecies, getFishSpecies } = useFishes();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getFishSpecies();
|
getFishSpecies();
|
||||||
}, []);
|
}, []);
|
||||||
const data: Model.FishingLog[] = trip?.fishing_logs ?? [];
|
|
||||||
const tongSoMe = data.length;
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
const toValue = collapsed ? contentHeight : 0;
|
const toValue = collapsed ? contentHeight : 0;
|
||||||
@@ -31,7 +37,7 @@ const NetListTable: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusPress = (id: string) => {
|
const handleStatusPress = (id: string) => {
|
||||||
const net = data.find((item) => item.fishing_log_id === id);
|
const net = trip?.fishing_logs?.find((item) => item.fishing_log_id === id);
|
||||||
if (net) {
|
if (net) {
|
||||||
setSelectedNet(net);
|
setSelectedNet(net);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
@@ -46,12 +52,16 @@ const NetListTable: React.FC = () => {
|
|||||||
onPress={handleToggle}
|
onPress={handleToggle}
|
||||||
style={styles.headerRow}
|
style={styles.headerRow}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>Danh sách mẻ lưới</Text>
|
<Text style={styles.title}>{t("trip.netList.title")}</Text>
|
||||||
{collapsed && <Text style={styles.totalCollapsed}>{tongSoMe}</Text>}
|
{collapsed && (
|
||||||
|
<Text style={styles.totalCollapsed}>
|
||||||
|
{trip?.fishing_logs?.length}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
name={collapsed ? "chevron.down" : "chevron.up"}
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
size={16}
|
size={16}
|
||||||
color="#000"
|
color={colors.icon}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -60,31 +70,54 @@ const NetListTable: React.FC = () => {
|
|||||||
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
|
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
|
||||||
onLayout={(event) => {
|
onLayout={(event) => {
|
||||||
const height = event.nativeEvent.layout.height;
|
const height = event.nativeEvent.layout.height;
|
||||||
if (height > 0 && contentHeight === 0) {
|
// Update measured content height whenever it actually changes.
|
||||||
|
if (height > 0 && height !== contentHeight) {
|
||||||
setContentHeight(height);
|
setContentHeight(height);
|
||||||
|
// If the panel is currently expanded, animate to the new height so
|
||||||
|
// newly added/removed rows become visible immediately.
|
||||||
|
if (!collapsed) {
|
||||||
|
Animated.timing(animatedHeight, {
|
||||||
|
toValue: height,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={[styles.row, styles.tableHeader]}>
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
<Text style={[styles.sttCell, styles.headerText]}>STT</Text>
|
<Text style={[styles.sttCell, styles.headerText]}>
|
||||||
<Text style={[styles.cell, styles.headerText]}>Trạng thái</Text>
|
{t("trip.netList.sttHeader")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.netList.statusHeader")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
{data.map((item, index) => (
|
{trip?.fishing_logs?.map((item, index) => (
|
||||||
<View key={item.fishing_log_id} style={styles.row}>
|
<View key={item.fishing_log_id} style={styles.row}>
|
||||||
{/* Cột STT */}
|
{/* Cột STT */}
|
||||||
<Text style={styles.sttCell}>Mẻ {index + 1}</Text>
|
<Text style={styles.sttCell}>
|
||||||
|
{t("trip.netList.haulPrefix")} {index + 1}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{/* Cột Trạng thái */}
|
{/* Cột Trạng thái */}
|
||||||
<View style={[styles.cell, styles.statusContainer]}>
|
<View style={[styles.cell, styles.statusContainer]}>
|
||||||
<View style={styles.statusDot} />
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusDot,
|
||||||
|
{ backgroundColor: item.status ? "#2ecc71" : "#FFD600" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleStatusPress(item.fishing_log_id)}
|
onPress={() => handleStatusPress(item.fishing_log_id)}
|
||||||
>
|
>
|
||||||
<Text style={styles.statusText}>
|
<Text style={styles.statusText}>
|
||||||
{item.status ? "Đã hoàn thành" : "Chưa hoàn thành"}
|
{item.status
|
||||||
|
? t("trip.netList.completed")
|
||||||
|
: t("trip.netList.pending")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -96,47 +129,67 @@ const NetListTable: React.FC = () => {
|
|||||||
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={[styles.row, styles.tableHeader]}>
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
<Text style={[styles.sttCell, styles.headerText]}>STT</Text>
|
<Text style={[styles.sttCell, styles.headerText]}>
|
||||||
<Text style={[styles.cell, styles.headerText]}>Trạng thái</Text>
|
{t("trip.netList.sttHeader")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.netList.statusHeader")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
{data.map((item, index) => (
|
{trip?.fishing_logs?.map((item, index) => (
|
||||||
<View key={item.fishing_log_id} style={styles.row}>
|
<View key={item.fishing_log_id} style={styles.row}>
|
||||||
{/* Cột STT */}
|
{/* Cột STT */}
|
||||||
<Text style={styles.sttCell}>Mẻ {index + 1}</Text>
|
<Text style={styles.sttCell}>
|
||||||
|
{t("trip.netList.haulPrefix")} {index + 1}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{/* Cột Trạng thái */}
|
{/* Cột Trạng thái */}
|
||||||
<View style={[styles.cell, styles.statusContainer]}>
|
<View style={[styles.cell, styles.statusContainer]}>
|
||||||
<View style={styles.statusDot} />
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusDot,
|
||||||
|
{ backgroundColor: item.status ? "#2ecc71" : "#FFD600" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleStatusPress(item.fishing_log_id)}
|
onPress={() => handleStatusPress(item.fishing_log_id)}
|
||||||
>
|
>
|
||||||
<Text style={styles.statusText}>
|
<Text style={styles.statusText}>
|
||||||
{item.status ? "Đã hoàn thành" : "Chưa hoàn thành"}
|
{item.status
|
||||||
|
? t("trip.netList.completed")
|
||||||
|
: t("trip.netList.pending")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
<CreateOrUpdateHaulModal
|
||||||
|
isVisible={modalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
console.log("OnCLose");
|
||||||
|
setModalVisible(false);
|
||||||
|
}}
|
||||||
|
fishingLog={selectedNet}
|
||||||
|
fishingLogIndex={
|
||||||
|
selectedNet
|
||||||
|
? trip!.fishing_logs!.findIndex(
|
||||||
|
(item) => item.fishing_log_id === selectedNet.fishing_log_id
|
||||||
|
) + 1
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* Modal chi tiết */}
|
{/* Modal chi tiết */}
|
||||||
<NetDetailModal
|
{/* <NetDetailModal
|
||||||
visible={modalVisible}
|
visible={modalVisible}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
console.log("OnCLose");
|
console.log("OnCLose");
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
}}
|
}}
|
||||||
netData={selectedNet}
|
netData={selectedNet}
|
||||||
stt={
|
/> */}
|
||||||
selectedNet
|
|
||||||
? data.findIndex(
|
|
||||||
(item) => item.fishing_log_id === selectedNet.fishing_log_id
|
|
||||||
) + 1
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
29
components/tripInfo/ThemedTable.tsx
Normal file
29
components/tripInfo/ThemedTable.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Wrapper component to easily apply theme-aware table styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { createTableStyles } from "./style/createTableStyles";
|
||||||
|
|
||||||
|
interface ThemedTableProps extends ViewProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemedTable({ style, children, ...props }: ThemedTableProps) {
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const tableStyles = useMemo(
|
||||||
|
() => createTableStyles(colorScheme),
|
||||||
|
[colorScheme]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[tableStyles.container, style]} {...props}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createTableStyles };
|
||||||
|
export type { TableStyles } from "./style/createTableStyles";
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
import { useTrip } from "@/state/use-trip";
|
import { useTrip } from "@/state/use-trip";
|
||||||
import React, { useRef, useState } from "react";
|
import { createTableStyles } from "./style/createTableStyles";
|
||||||
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
|
||||||
import TripCostDetailModal from "./modal/TripCostDetailModal";
|
import TripCostDetailModal from "./modal/TripCostDetailModal";
|
||||||
import styles from "./style/TripCostTable.styles";
|
import React, { useRef, useState, useMemo } from "react";
|
||||||
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
// ---------------------------
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
// 💰 Component chính
|
|
||||||
// ---------------------------
|
|
||||||
|
|
||||||
const TripCostTable: React.FC = () => {
|
const TripCostTable: React.FC = () => {
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const animatedHeight = useRef(new Animated.Value(0)).current;
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
|
||||||
const { trip } = useTrip();
|
const { trip } = useTrip();
|
||||||
|
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
const data: Model.TripCost[] = trip?.trip_cost ?? [];
|
const data: Model.TripCost[] = trip?.trip_cost ?? [];
|
||||||
const tongCong = data.reduce((sum, item) => sum + item.total_cost, 0);
|
const tongCong = data.reduce((sum, item) => sum + item.total_cost, 0);
|
||||||
|
|
||||||
@@ -50,21 +54,16 @@ const TripCostTable: React.FC = () => {
|
|||||||
// marginBottom: 12,
|
// marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>Chi phí chuyến đi</Text>
|
<Text style={styles.title}>{t("trip.costTable.title")}</Text>
|
||||||
{collapsed && (
|
{collapsed && (
|
||||||
<Text
|
<Text style={[styles.totalCollapsed]}>
|
||||||
style={[
|
|
||||||
styles.title,
|
|
||||||
{ color: "#ff6600", fontWeight: "bold", marginLeft: 8 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{tongCong.toLocaleString()}
|
{tongCong.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<IconSymbol
|
<IconSymbol
|
||||||
name={collapsed ? "chevron.down" : "chevron.up"}
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
size={15}
|
size={15}
|
||||||
color="#000000"
|
color={colors.icon}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -81,9 +80,11 @@ const TripCostTable: React.FC = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={[styles.row, styles.header]}>
|
<View style={[styles.row, styles.header]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||||
Loại
|
{t("trip.costTable.typeHeader")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.costTable.totalCostHeader")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
@@ -99,7 +100,7 @@ const TripCostTable: React.FC = () => {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={[styles.row]}>
|
<View style={[styles.row]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||||
Tổng cộng
|
{t("trip.costTable.totalLabel")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cell, styles.total]}>
|
<Text style={[styles.cell, styles.total]}>
|
||||||
{tongCong.toLocaleString()}
|
{tongCong.toLocaleString()}
|
||||||
@@ -112,7 +113,9 @@ const TripCostTable: React.FC = () => {
|
|||||||
style={styles.viewDetailButton}
|
style={styles.viewDetailButton}
|
||||||
onPress={handleViewDetail}
|
onPress={handleViewDetail}
|
||||||
>
|
>
|
||||||
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
|
<Text style={styles.viewDetailText}>
|
||||||
|
{t("trip.costTable.viewDetail")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -121,9 +124,11 @@ const TripCostTable: React.FC = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={[styles.row, styles.header]}>
|
<View style={[styles.row, styles.header]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||||
Loại
|
{t("trip.costTable.typeHeader")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.costTable.totalCostHeader")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
@@ -139,7 +144,7 @@ const TripCostTable: React.FC = () => {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={[styles.row]}>
|
<View style={[styles.row]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||||
Tổng cộng
|
{t("trip.costTable.totalLabel")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.cell, styles.total]}>
|
<Text style={[styles.cell, styles.total]}>
|
||||||
{tongCong.toLocaleString()}
|
{tongCong.toLocaleString()}
|
||||||
@@ -152,7 +157,9 @@ const TripCostTable: React.FC = () => {
|
|||||||
style={styles.viewDetailButton}
|
style={styles.viewDetailButton}
|
||||||
onPress={handleViewDetail}
|
onPress={handleViewDetail}
|
||||||
>
|
>
|
||||||
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
|
<Text style={styles.viewDetailText}>
|
||||||
|
{t("trip.costTable.viewDetail")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,18 +1,487 @@
|
|||||||
|
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 React from "react";
|
||||||
import { Modal, Text } from "react-native";
|
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 {
|
interface CreateOrUpdateHaulModalProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
haulData?: Model.FishingLog | null;
|
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> = ({
|
const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
|
||||||
isVisible,
|
isVisible,
|
||||||
onClose,
|
onClose,
|
||||||
haulData,
|
fishingLog,
|
||||||
|
fishingLogIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const [isCreateMode, setIsCreateMode] = React.useState(!haulData);
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -21,7 +490,96 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
|
|||||||
presentationStyle="pageSheet"
|
presentationStyle="pageSheet"
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<Text>{isCreateMode ? "Create Haul" : "Update Haul"}</Text>
|
<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>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
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 React from "react";
|
||||||
import { Modal, ScrollView, Text, TouchableOpacity, View } from "react-native";
|
import { Modal, ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||||
import styles from "./style/CrewDetailModal.styles";
|
import { createStyles } from "./style/CrewDetailModal.styles";
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// 🧩 Interface
|
// 🧩 Interface
|
||||||
@@ -21,30 +23,48 @@ const CrewDetailModal: React.FC<CrewDetailModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
crewData,
|
crewData,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||||
|
|
||||||
if (!crewData) return null;
|
if (!crewData) return null;
|
||||||
|
|
||||||
const infoItems = [
|
const infoItems = [
|
||||||
{ label: "Mã định danh", value: crewData.Person.personal_id },
|
|
||||||
{ label: "Họ và tên", value: crewData.Person.name },
|
|
||||||
{ label: "Chức vụ", value: crewData.role },
|
|
||||||
{
|
{
|
||||||
label: "Ngày sinh",
|
label: t("trip.crewDetailModal.personalId"),
|
||||||
|
value: crewData.Person.personal_id,
|
||||||
|
},
|
||||||
|
{ label: t("trip.crewDetailModal.fullName"), value: crewData.Person.name },
|
||||||
|
{ label: t("trip.crewDetailModal.role"), value: crewData.role },
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.birthDate"),
|
||||||
value: crewData.Person.birth_date
|
value: crewData.Person.birth_date
|
||||||
? new Date(crewData.Person.birth_date).toLocaleDateString()
|
? new Date(crewData.Person.birth_date).toLocaleDateString()
|
||||||
: "Chưa cập nhật",
|
: t("trip.crewDetailModal.notUpdated"),
|
||||||
},
|
},
|
||||||
{ label: "Số điện thoại", value: crewData.Person.phone || "Chưa cập nhật" },
|
|
||||||
{ label: "Địa chỉ", value: crewData.Person.address || "Chưa cập nhật" },
|
|
||||||
{
|
{
|
||||||
label: "Ngày vào làm",
|
label: t("trip.crewDetailModal.phone"),
|
||||||
|
value: crewData.Person.phone || t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.address"),
|
||||||
|
value: crewData.Person.address || t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.joinedDate"),
|
||||||
value: crewData.joined_at
|
value: crewData.joined_at
|
||||||
? new Date(crewData.joined_at).toLocaleDateString()
|
? new Date(crewData.joined_at).toLocaleDateString()
|
||||||
: "Chưa cập nhật",
|
: t("trip.crewDetailModal.notUpdated"),
|
||||||
},
|
},
|
||||||
{ label: "Ghi chú", value: crewData.note || "Chưa cập nhật" },
|
|
||||||
{
|
{
|
||||||
label: "Tình trạng",
|
label: t("trip.crewDetailModal.note"),
|
||||||
value: crewData.left_at ? "Đã nghỉ" : "Đang làm việc",
|
value: crewData.note || t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.status"),
|
||||||
|
value: crewData.left_at
|
||||||
|
? t("trip.crewDetailModal.resigned")
|
||||||
|
: t("trip.crewDetailModal.working"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -58,7 +78,7 @@ const CrewDetailModal: React.FC<CrewDetailModalProps> = ({
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>Thông tin thuyền viên</Text>
|
<Text style={styles.title}>{t("trip.crewDetailModal.title")}</Text>
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
<View style={styles.closeIconButton}>
|
<View style={styles.closeIconButton}>
|
||||||
<IconSymbol name="xmark" size={28} color="#fff" />
|
<IconSymbol name="xmark" size={28} color="#fff" />
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Modal,
|
|
||||||
ScrollView,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import styles from "../style/NetDetailModal.styles";
|
|
||||||
import { CatchSectionHeader } from "./components/CatchSectionHeader";
|
|
||||||
import { FishCardList } from "./components/FishCardList";
|
|
||||||
import { InfoSection } from "./components/InfoSection";
|
|
||||||
import { NotesSection } from "./components/NotesSection";
|
|
||||||
|
|
||||||
interface NetDetailModalProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
netData: Model.FishingLog | null;
|
|
||||||
stt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// 🧵 Component Modal
|
|
||||||
// ---------------------------
|
|
||||||
const NetDetailModal: React.FC<NetDetailModalProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
netData,
|
|
||||||
stt,
|
|
||||||
}) => {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [editableCatchList, setEditableCatchList] = useState<
|
|
||||||
Model.FishingLogInfo[]
|
|
||||||
>([]);
|
|
||||||
const [selectedFishIndex, setSelectedFishIndex] = useState<number | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [selectedUnitIndex, setSelectedUnitIndex] = useState<number | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
// const [selectedConditionIndex, setSelectedConditionIndex] = useState<
|
|
||||||
// number | null
|
|
||||||
// >(null);
|
|
||||||
// const [selectedGearIndex, setSelectedGearIndex] = useState<number | null>(
|
|
||||||
// null
|
|
||||||
// );
|
|
||||||
const [expandedFishIndices, setExpandedFishIndices] = useState<number[]>([]);
|
|
||||||
|
|
||||||
// Khởi tạo dữ liệu khi netData thay đổi
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (netData?.info) {
|
|
||||||
setEditableCatchList(netData.info);
|
|
||||||
}
|
|
||||||
}, [netData]);
|
|
||||||
|
|
||||||
// Reset state khi modal đóng
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!visible) {
|
|
||||||
setExpandedFishIndices([]);
|
|
||||||
setSelectedFishIndex(null);
|
|
||||||
setSelectedUnitIndex(null);
|
|
||||||
// setSelectedConditionIndex(null);
|
|
||||||
// setSelectedGearIndex(null);
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
// if (!netData) return null;
|
|
||||||
|
|
||||||
const isCompleted = netData?.status === 2; // ví dụ: status=2 là hoàn thành
|
|
||||||
|
|
||||||
// Danh sách tên cá có sẵn
|
|
||||||
const fishNameOptions = [
|
|
||||||
"Cá chim trắng",
|
|
||||||
"Cá song đỏ",
|
|
||||||
"Cá hồng",
|
|
||||||
"Cá nục",
|
|
||||||
"Cá ngừ đại dương",
|
|
||||||
"Cá mú trắng",
|
|
||||||
"Cá hồng phớn",
|
|
||||||
"Cá hổ Napoleon",
|
|
||||||
"Cá nược",
|
|
||||||
"Cá đuối quạt",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Danh sách đơn vị
|
|
||||||
const unitOptions = ["kg", "con", "tấn"];
|
|
||||||
|
|
||||||
// Danh sách tình trạng
|
|
||||||
// const conditionOptions = ["Còn sống", "Chết", "Bị thương"];
|
|
||||||
|
|
||||||
// Danh sách ngư cụ
|
|
||||||
// const gearOptions = [
|
|
||||||
// "Lưới kéo",
|
|
||||||
// "Lưới vây",
|
|
||||||
// "Lưới rê",
|
|
||||||
// "Lưới cào",
|
|
||||||
// "Lưới lồng",
|
|
||||||
// "Câu cần",
|
|
||||||
// "Câu dây",
|
|
||||||
// "Chài cá",
|
|
||||||
// "Lồng bẫy",
|
|
||||||
// "Đăng",
|
|
||||||
// ];
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
setIsEditing(!isEditing);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
// Validate từng cá trong danh sách và thu thập tất cả lỗi
|
|
||||||
const allErrors: { index: number; errors: string[] }[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < editableCatchList.length; i++) {
|
|
||||||
const fish = editableCatchList[i];
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (!fish.fish_name || fish.fish_name.trim() === "") {
|
|
||||||
errors.push("- Tên loài cá");
|
|
||||||
}
|
|
||||||
if (!fish.catch_number || fish.catch_number <= 0) {
|
|
||||||
errors.push("- Số lượng bắt được");
|
|
||||||
}
|
|
||||||
if (!fish.catch_unit || fish.catch_unit.trim() === "") {
|
|
||||||
errors.push("- Đơn vị");
|
|
||||||
}
|
|
||||||
if (!fish.fish_size || fish.fish_size <= 0) {
|
|
||||||
errors.push("- Kích thước cá");
|
|
||||||
}
|
|
||||||
// if (!fish.fish_condition || fish.fish_condition.trim() === "") {
|
|
||||||
// errors.push("- Tình trạng cá");
|
|
||||||
// }
|
|
||||||
// if (!fish.gear_usage || fish.gear_usage.trim() === "") {
|
|
||||||
// errors.push("- Dụng cụ sử dụng");
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
allErrors.push({ index: i, errors });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nếu có lỗi, hiển thị tất cả
|
|
||||||
if (allErrors.length > 0) {
|
|
||||||
const errorMessage = allErrors
|
|
||||||
.map((item) => {
|
|
||||||
return `Cá số ${item.index + 1}:\n${item.errors.join("\n")}`;
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
|
|
||||||
Alert.alert(
|
|
||||||
"Thông tin không đầy đủ",
|
|
||||||
errorMessage,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Tiếp tục chỉnh sửa",
|
|
||||||
onPress: () => {
|
|
||||||
// Mở rộng tất cả các card bị lỗi
|
|
||||||
setExpandedFishIndices((prev) => {
|
|
||||||
const errorIndices = allErrors.map((item) => item.index);
|
|
||||||
const newIndices = [...prev];
|
|
||||||
errorIndices.forEach((idx) => {
|
|
||||||
if (!newIndices.includes(idx)) {
|
|
||||||
newIndices.push(idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return newIndices;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Hủy",
|
|
||||||
onPress: () => {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{ cancelable: false }
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nếu validation pass, lưu dữ liệu
|
|
||||||
setIsEditing(false);
|
|
||||||
console.log("Saved catch list:", editableCatchList);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setIsEditing(false);
|
|
||||||
setEditableCatchList(netData?.info || []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleExpanded = (index: number) => {
|
|
||||||
setExpandedFishIndices((prev) =>
|
|
||||||
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCatchItem = (
|
|
||||||
index: number,
|
|
||||||
field: keyof Model.FishingLogInfo,
|
|
||||||
value: string | number
|
|
||||||
) => {
|
|
||||||
setEditableCatchList((prev) =>
|
|
||||||
prev.map((item, i) => {
|
|
||||||
if (i === index) {
|
|
||||||
const updatedItem = { ...item };
|
|
||||||
if (
|
|
||||||
field === "catch_number" ||
|
|
||||||
field === "fish_size" ||
|
|
||||||
field === "fish_rarity"
|
|
||||||
) {
|
|
||||||
updatedItem[field] = Number(value) || 0;
|
|
||||||
} else {
|
|
||||||
updatedItem[field] = value as never;
|
|
||||||
}
|
|
||||||
return updatedItem;
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddNewFish = () => {
|
|
||||||
const newFish: Model.FishingLogInfo = {
|
|
||||||
fish_species_id: 0,
|
|
||||||
fish_name: "",
|
|
||||||
catch_number: 0,
|
|
||||||
catch_unit: "kg",
|
|
||||||
fish_size: 0,
|
|
||||||
fish_rarity: 0,
|
|
||||||
fish_condition: "",
|
|
||||||
gear_usage: "",
|
|
||||||
};
|
|
||||||
setEditableCatchList((prev) => [...prev, newFish]);
|
|
||||||
// Tự động expand card mới
|
|
||||||
setExpandedFishIndices((prev) => [...prev, editableCatchList.length]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFish = (index: number) => {
|
|
||||||
Alert.alert(
|
|
||||||
"Xác nhận xóa",
|
|
||||||
`Bạn có chắc muốn xóa loài cá này?`,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Hủy",
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Xóa",
|
|
||||||
style: "destructive",
|
|
||||||
onPress: () => {
|
|
||||||
setEditableCatchList((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
// Cập nhật lại expandedFishIndices sau khi xóa
|
|
||||||
setExpandedFishIndices((prev) =>
|
|
||||||
prev
|
|
||||||
.filter((i) => i !== index)
|
|
||||||
.map((i) => (i > index ? i - 1 : i))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{ cancelable: true }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Chỉ tính tổng số lượng cá có đơn vị là 'kg'
|
|
||||||
const totalCatch = editableCatchList.reduce(
|
|
||||||
(sum, item) =>
|
|
||||||
item.catch_unit === "kg" ? sum + (item.catch_number ?? 0) : sum,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
animationType="slide"
|
|
||||||
presentationStyle="pageSheet"
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<View style={styles.container}>
|
|
||||||
{/* Header */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text style={styles.title}>Chi tiết mẻ lưới</Text>
|
|
||||||
<View style={styles.headerButtons}>
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleCancel}
|
|
||||||
style={styles.cancelButton}
|
|
||||||
>
|
|
||||||
<Text style={styles.cancelButtonText}>Hủy</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleSave}
|
|
||||||
style={styles.saveButton}
|
|
||||||
>
|
|
||||||
<Text style={styles.saveButtonText}>Lưu</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity onPress={handleEdit} style={styles.editButton}>
|
|
||||||
<View style={styles.editIconButton}>
|
|
||||||
<IconSymbol
|
|
||||||
name="pencil"
|
|
||||||
size={28}
|
|
||||||
color="#fff"
|
|
||||||
weight="heavy"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
|
||||||
<View style={styles.closeIconButton}>
|
|
||||||
<IconSymbol name="xmark" size={28} color="#fff" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<ScrollView style={styles.content}>
|
|
||||||
{/* Thông tin chung */}
|
|
||||||
<InfoSection
|
|
||||||
netData={netData ?? undefined}
|
|
||||||
isCompleted={isCompleted}
|
|
||||||
stt={stt}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Danh sách cá bắt được */}
|
|
||||||
<CatchSectionHeader totalCatch={totalCatch} />
|
|
||||||
|
|
||||||
{/* Fish cards */}
|
|
||||||
<FishCardList
|
|
||||||
catchList={editableCatchList}
|
|
||||||
isEditing={isEditing}
|
|
||||||
expandedFishIndex={expandedFishIndices}
|
|
||||||
selectedFishIndex={selectedFishIndex}
|
|
||||||
selectedUnitIndex={selectedUnitIndex}
|
|
||||||
// selectedConditionIndex={selectedConditionIndex}
|
|
||||||
// selectedGearIndex={selectedGearIndex}
|
|
||||||
fishNameOptions={fishNameOptions}
|
|
||||||
unitOptions={unitOptions}
|
|
||||||
// conditionOptions={conditionOptions}
|
|
||||||
// gearOptions={gearOptions}
|
|
||||||
onToggleExpanded={handleToggleExpanded}
|
|
||||||
onUpdateCatchItem={updateCatchItem}
|
|
||||||
setSelectedFishIndex={setSelectedFishIndex}
|
|
||||||
setSelectedUnitIndex={setSelectedUnitIndex}
|
|
||||||
// setSelectedConditionIndex={setSelectedConditionIndex}
|
|
||||||
// setSelectedGearIndex={setSelectedGearIndex}
|
|
||||||
onAddNewFish={handleAddNewFish}
|
|
||||||
onDeleteFish={handleDeleteFish}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Ghi chú */}
|
|
||||||
<NotesSection ghiChu={netData?.weather_description} />
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NetDetailModal;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import styles from "../../style/NetDetailModal.styles";
|
|
||||||
|
|
||||||
interface CatchSectionHeaderProps {
|
|
||||||
totalCatch: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CatchSectionHeader: React.FC<CatchSectionHeaderProps> = ({
|
|
||||||
totalCatch,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<View style={styles.sectionHeader}>
|
|
||||||
<Text style={styles.sectionTitle}>Danh sách cá bắt được</Text>
|
|
||||||
<Text style={styles.totalCatchText}>
|
|
||||||
Tổng: {totalCatch.toLocaleString()} kg
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import { useFishes } from "@/state/use-fish";
|
|
||||||
import React from "react";
|
|
||||||
import { Text, TextInput, View } from "react-native";
|
|
||||||
import styles from "../../style/NetDetailModal.styles";
|
|
||||||
import { FishSelectDropdown } from "./FishSelectDropdown";
|
|
||||||
|
|
||||||
interface FishCardFormProps {
|
|
||||||
fish: Model.FishingLogInfo;
|
|
||||||
index: number;
|
|
||||||
isEditing: boolean;
|
|
||||||
fishNameOptions: string[]; // Bỏ gọi API cá
|
|
||||||
unitOptions: string[]; // Bỏ render ở trong này
|
|
||||||
// conditionOptions: string[];
|
|
||||||
// gearOptions: string[];
|
|
||||||
selectedFishIndex: number | null;
|
|
||||||
selectedUnitIndex: number | null;
|
|
||||||
// selectedConditionIndex: number | null;
|
|
||||||
// selectedGearIndex: number | null;
|
|
||||||
setSelectedFishIndex: (index: number | null) => void;
|
|
||||||
setSelectedUnitIndex: (index: number | null) => void;
|
|
||||||
// setSelectedConditionIndex: (index: number | null) => void;
|
|
||||||
// setSelectedGearIndex: (index: number | null) => void;
|
|
||||||
onUpdateCatchItem: (
|
|
||||||
index: number,
|
|
||||||
field: keyof Model.FishingLogInfo,
|
|
||||||
value: string | number
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FishCardForm: React.FC<FishCardFormProps> = ({
|
|
||||||
fish,
|
|
||||||
index,
|
|
||||||
isEditing,
|
|
||||||
unitOptions,
|
|
||||||
// conditionOptions,
|
|
||||||
// gearOptions,
|
|
||||||
selectedFishIndex,
|
|
||||||
selectedUnitIndex,
|
|
||||||
// selectedConditionIndex,
|
|
||||||
// selectedGearIndex,
|
|
||||||
setSelectedFishIndex,
|
|
||||||
setSelectedUnitIndex,
|
|
||||||
// setSelectedConditionIndex,
|
|
||||||
// setSelectedGearIndex,
|
|
||||||
onUpdateCatchItem,
|
|
||||||
}) => {
|
|
||||||
const { fishSpecies } = useFishes();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Tên cá - Select */}
|
|
||||||
<View
|
|
||||||
style={[styles.fieldGroup, { zIndex: 1000 - index }, { marginTop: 15 }]}
|
|
||||||
>
|
|
||||||
<Text style={styles.label}>Tên cá</Text>
|
|
||||||
{isEditing ? (
|
|
||||||
<FishSelectDropdown
|
|
||||||
options={fishSpecies || []}
|
|
||||||
selectedFishId={selectedFishIndex}
|
|
||||||
isOpen={selectedFishIndex === index}
|
|
||||||
onToggle={() =>
|
|
||||||
setSelectedFishIndex(selectedFishIndex === index ? null : index)
|
|
||||||
}
|
|
||||||
onSelect={(value: Model.FishSpeciesResponse) => {
|
|
||||||
onUpdateCatchItem(index, "fish_name", value.name);
|
|
||||||
setSelectedFishIndex(value.id);
|
|
||||||
console.log("Fish Selected: ", fish);
|
|
||||||
}}
|
|
||||||
zIndex={1000 - index}
|
|
||||||
styleOverride={styles.fishNameDropdown}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.infoValue}>{fish.fish_name}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Số lượng & Đơn vị */}
|
|
||||||
<View style={styles.rowGroup}>
|
|
||||||
<View style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}>
|
|
||||||
<Text style={styles.label}>Số lượng</Text>
|
|
||||||
{isEditing ? (
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={String(fish.catch_number)}
|
|
||||||
onChangeText={(value) =>
|
|
||||||
onUpdateCatchItem(index, "catch_number", value)
|
|
||||||
}
|
|
||||||
keyboardType="numeric"
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.infoValue}>{fish.catch_number}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.fieldGroup,
|
|
||||||
{ flex: 1, marginLeft: 8, zIndex: 900 - index },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={styles.label}>Đơn vị</Text>
|
|
||||||
{/* {isEditing ? (
|
|
||||||
<FishSelectDropdown
|
|
||||||
options={unitOptions}
|
|
||||||
selectedValue={fish.catch_unit ?? ""}
|
|
||||||
isOpen={selectedUnitIndex === index}
|
|
||||||
onToggle={() =>
|
|
||||||
setSelectedUnitIndex(selectedUnitIndex === index ? null : index)
|
|
||||||
}
|
|
||||||
onSelect={(value: string) => {
|
|
||||||
onUpdateCatchItem(index, "catch_unit", value);
|
|
||||||
setSelectedUnitIndex(null);
|
|
||||||
}}
|
|
||||||
zIndex={900 - index}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.infoValue}>{fish.catch_unit}</Text>
|
|
||||||
)} */}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Kích thước & Độ hiếm */}
|
|
||||||
<View style={styles.rowGroup}>
|
|
||||||
<View style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}>
|
|
||||||
<Text style={styles.label}>Kích thước (cm)</Text>
|
|
||||||
{isEditing ? (
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={String(fish.fish_size)}
|
|
||||||
onChangeText={(value) =>
|
|
||||||
onUpdateCatchItem(index, "fish_size", value)
|
|
||||||
}
|
|
||||||
keyboardType="numeric"
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.infoValue}>{fish.fish_size} cm</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View style={[styles.fieldGroup, { flex: 1, marginLeft: 8 }]}>
|
|
||||||
<Text style={styles.label}>Độ hiếm</Text>
|
|
||||||
{isEditing ? (
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={String(fish.fish_rarity)}
|
|
||||||
onChangeText={(value) =>
|
|
||||||
onUpdateCatchItem(index, "fish_rarity", value)
|
|
||||||
}
|
|
||||||
keyboardType="numeric"
|
|
||||||
placeholder="1-5"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.infoValue}>{fish.fish_rarity}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Tình trạng */}
|
|
||||||
{/* <View style={[styles.fieldGroup, { zIndex: 800 - index }]}>
|
|
||||||
<Text style={styles.label}>Tình trạng</Text>
|
|
||||||
{isEditing ? (
|
|
||||||
<FishSelectDropdown
|
|
||||||
options={conditionOptions}
|
|
||||||
selectedValue={fish.fish_condition}
|
|
||||||
isOpen={selectedConditionIndex === index}
|
|
||||||
onToggle={() =>
|
|
||||||
setSelectedConditionIndex(
|
|
||||||
selectedConditionIndex === index ? null : index
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSelect={(value: string) => {
|
|
||||||
onUpdateCatchItem(index, "fish_condition", value);
|
|
||||||
setSelectedConditionIndex(null);
|
|
||||||
}}
|
|
||||||
zIndex={800 - index}
|
|
||||||
styleOverride={styles.optionsStatusFishList}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.infoValue}>{fish.fish_condition}</Text>
|
|
||||||
)}
|
|
||||||
</View> */}
|
|
||||||
|
|
||||||
{/* Ngư cụ sử dụng */}
|
|
||||||
{/* <View style={[styles.fieldGroup, { zIndex: 700 - index }]}>
|
|
||||||
<Text style={styles.label}>Ngư cụ sử dụng</Text>
|
|
||||||
{isEditing ? (
|
|
||||||
<FishSelectDropdown
|
|
||||||
options={gearOptions}
|
|
||||||
selectedValue={fish.gear_usage}
|
|
||||||
isOpen={selectedGearIndex === index}
|
|
||||||
onToggle={() =>
|
|
||||||
setSelectedGearIndex(selectedGearIndex === index ? null : index)
|
|
||||||
}
|
|
||||||
onSelect={(value: string) => {
|
|
||||||
onUpdateCatchItem(index, "gear_usage", value);
|
|
||||||
setSelectedGearIndex(null);
|
|
||||||
}}
|
|
||||||
zIndex={700 - index}
|
|
||||||
styleOverride={styles.optionsStatusFishList}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.infoValue}>{fish.gear_usage || "Không có"}</Text>
|
|
||||||
)}
|
|
||||||
</View> */}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import styles from "../../style/NetDetailModal.styles";
|
|
||||||
|
|
||||||
interface FishCardHeaderProps {
|
|
||||||
fish: Model.FishingLogInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FishCardHeader: React.FC<FishCardHeaderProps> = ({ fish }) => {
|
|
||||||
return (
|
|
||||||
<View style={styles.fishCardHeaderContent}>
|
|
||||||
<Text style={styles.fishCardTitle}>{fish.fish_name}:</Text>
|
|
||||||
<Text style={styles.fishCardSubtitle}>
|
|
||||||
{fish.catch_number} {fish.catch_unit}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
|
||||||
import React from "react";
|
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
|
||||||
import styles from "../../style/NetDetailModal.styles";
|
|
||||||
import { FishCardForm } from "./FishCardForm";
|
|
||||||
import { FishCardHeader } from "./FishCardHeader";
|
|
||||||
|
|
||||||
interface FishCardListProps {
|
|
||||||
catchList: Model.FishingLogInfo[];
|
|
||||||
isEditing: boolean;
|
|
||||||
expandedFishIndex: number[];
|
|
||||||
selectedFishIndex: number | null;
|
|
||||||
selectedUnitIndex: number | null;
|
|
||||||
// selectedConditionIndex: number | null;
|
|
||||||
// selectedGearIndex: number | null;
|
|
||||||
fishNameOptions: string[];
|
|
||||||
unitOptions: string[];
|
|
||||||
// conditionOptions: string[];
|
|
||||||
// gearOptions: string[];
|
|
||||||
onToggleExpanded: (index: number) => void;
|
|
||||||
onUpdateCatchItem: (
|
|
||||||
index: number,
|
|
||||||
field: keyof Model.FishingLogInfo,
|
|
||||||
value: string | number
|
|
||||||
) => void;
|
|
||||||
setSelectedFishIndex: (index: number | null) => void;
|
|
||||||
setSelectedUnitIndex: (index: number | null) => void;
|
|
||||||
// setSelectedConditionIndex: (index: number | null) => void;
|
|
||||||
// setSelectedGearIndex: (index: number | null) => void;
|
|
||||||
onAddNewFish?: () => void;
|
|
||||||
onDeleteFish?: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FishCardList: React.FC<FishCardListProps> = ({
|
|
||||||
catchList,
|
|
||||||
isEditing,
|
|
||||||
expandedFishIndex,
|
|
||||||
selectedFishIndex,
|
|
||||||
selectedUnitIndex,
|
|
||||||
// selectedConditionIndex,
|
|
||||||
// selectedGearIndex,
|
|
||||||
fishNameOptions,
|
|
||||||
unitOptions,
|
|
||||||
// conditionOptions,
|
|
||||||
// gearOptions,
|
|
||||||
onToggleExpanded,
|
|
||||||
onUpdateCatchItem,
|
|
||||||
setSelectedFishIndex,
|
|
||||||
setSelectedUnitIndex,
|
|
||||||
// setSelectedConditionIndex,
|
|
||||||
// setSelectedGearIndex,
|
|
||||||
onAddNewFish,
|
|
||||||
onDeleteFish,
|
|
||||||
}) => {
|
|
||||||
// Chuyển về logic đơn giản, không animation
|
|
||||||
const handleToggleCard = (index: number) => {
|
|
||||||
onToggleExpanded(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{catchList.map((fish, index) => (
|
|
||||||
<View key={index} style={styles.fishCard}>
|
|
||||||
{/* Delete + Chevron buttons - always on top, right side, horizontal row */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 9999,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 8,
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
pointerEvents="box-none"
|
|
||||||
>
|
|
||||||
{isEditing && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => onDeleteFish?.(index)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#FF3B30",
|
|
||||||
borderRadius: 8,
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.08,
|
|
||||||
shadowRadius: 2,
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
elevation: 2,
|
|
||||||
}}
|
|
||||||
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<IconSymbol name="trash" size={24} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleToggleCard(index)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#007AFF",
|
|
||||||
borderRadius: 8,
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.08,
|
|
||||||
shadowRadius: 2,
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
elevation: 2,
|
|
||||||
}}
|
|
||||||
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<IconSymbol
|
|
||||||
name={
|
|
||||||
expandedFishIndex.includes(index)
|
|
||||||
? "chevron.up"
|
|
||||||
: "chevron.down"
|
|
||||||
}
|
|
||||||
size={24}
|
|
||||||
color="#fff"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Header - Only visible when collapsed */}
|
|
||||||
{!expandedFishIndex.includes(index) && <FishCardHeader fish={fish} />}
|
|
||||||
|
|
||||||
{/* Form - Only show when expanded */}
|
|
||||||
{expandedFishIndex.includes(index) && (
|
|
||||||
<FishCardForm
|
|
||||||
fish={fish}
|
|
||||||
index={index}
|
|
||||||
isEditing={isEditing}
|
|
||||||
fishNameOptions={fishNameOptions}
|
|
||||||
unitOptions={unitOptions}
|
|
||||||
// conditionOptions={conditionOptions}
|
|
||||||
// gearOptions={gearOptions}
|
|
||||||
selectedFishIndex={selectedFishIndex}
|
|
||||||
selectedUnitIndex={selectedUnitIndex}
|
|
||||||
// selectedConditionIndex={selectedConditionIndex}
|
|
||||||
// selectedGearIndex={selectedGearIndex}
|
|
||||||
setSelectedFishIndex={setSelectedFishIndex}
|
|
||||||
setSelectedUnitIndex={setSelectedUnitIndex}
|
|
||||||
// setSelectedConditionIndex={setSelectedConditionIndex}
|
|
||||||
// setSelectedGearIndex={setSelectedGearIndex}
|
|
||||||
onUpdateCatchItem={onUpdateCatchItem}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Nút thêm loài cá mới - hiển thị khi đang chỉnh sửa */}
|
|
||||||
{isEditing && (
|
|
||||||
<TouchableOpacity onPress={onAddNewFish} style={styles.addFishButton}>
|
|
||||||
<View style={styles.addFishButtonContent}>
|
|
||||||
<IconSymbol name="plus" size={24} color="#fff" />
|
|
||||||
<Text style={styles.addFishButtonText}>Thêm loài cá</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
|
||||||
import React from "react";
|
|
||||||
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
|
|
||||||
import styles from "../../style/NetDetailModal.styles";
|
|
||||||
|
|
||||||
interface FishSelectDropdownProps {
|
|
||||||
options: Model.FishSpeciesResponse[];
|
|
||||||
selectedFishId: number | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onSelect: (value: Model.FishSpeciesResponse) => void;
|
|
||||||
zIndex: number;
|
|
||||||
styleOverride?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FishSelectDropdown: React.FC<FishSelectDropdownProps> = ({
|
|
||||||
options,
|
|
||||||
selectedFishId,
|
|
||||||
isOpen,
|
|
||||||
onToggle,
|
|
||||||
onSelect,
|
|
||||||
zIndex,
|
|
||||||
styleOverride,
|
|
||||||
}) => {
|
|
||||||
const dropdownStyle = styleOverride || styles.optionsList;
|
|
||||||
const findFishNameById = (id: number | null) => {
|
|
||||||
const fish = options.find((item) => item.id === id);
|
|
||||||
return fish?.name || "Chọn cá";
|
|
||||||
};
|
|
||||||
const [selectedFish, setSelectedFish] =
|
|
||||||
React.useState<Model.FishSpeciesResponse | null>(null);
|
|
||||||
return (
|
|
||||||
<View style={{ zIndex }}>
|
|
||||||
<TouchableOpacity style={styles.selectButton} onPress={onToggle}>
|
|
||||||
<Text style={styles.selectButtonText}>
|
|
||||||
{findFishNameById(selectedFishId)}
|
|
||||||
</Text>
|
|
||||||
<IconSymbol
|
|
||||||
name={isOpen ? "chevron.up" : "chevron.down"}
|
|
||||||
size={16}
|
|
||||||
color="#666"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{isOpen && (
|
|
||||||
<ScrollView style={dropdownStyle} nestedScrollEnabled={true}>
|
|
||||||
{options.map((option, optIndex) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={option.id || optIndex}
|
|
||||||
style={styles.optionItem}
|
|
||||||
onPress={() => onSelect(option)}
|
|
||||||
>
|
|
||||||
<Text style={styles.optionText}>
|
|
||||||
{findFishNameById(option.id)}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import styles from "../../style/NetDetailModal.styles";
|
|
||||||
|
|
||||||
interface NetDetail {
|
|
||||||
id: string;
|
|
||||||
stt: string;
|
|
||||||
trangThai: string;
|
|
||||||
thoiGianBatDau?: string;
|
|
||||||
thoiGianKetThuc?: string;
|
|
||||||
viTriHaThu?: string;
|
|
||||||
viTriThuLuoi?: string;
|
|
||||||
doSauHaThu?: string;
|
|
||||||
doSauThuLuoi?: string;
|
|
||||||
catchList?: any[];
|
|
||||||
ghiChu?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InfoSectionProps {
|
|
||||||
netData?: Model.FishingLog;
|
|
||||||
isCompleted: boolean;
|
|
||||||
stt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InfoSection: React.FC<InfoSectionProps> = ({
|
|
||||||
netData,
|
|
||||||
isCompleted,
|
|
||||||
stt,
|
|
||||||
}) => {
|
|
||||||
if (!netData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const infoItems = [
|
|
||||||
{ label: "Số thứ tự", value: `Mẻ ${stt}` },
|
|
||||||
{
|
|
||||||
label: "Trạng thái",
|
|
||||||
value: netData.status === 1 ? "Đã hoàn thành" : "Chưa hoàn thành",
|
|
||||||
isStatus: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Thời gian bắt đầu",
|
|
||||||
value: netData.start_at
|
|
||||||
? new Date(netData.start_at).toLocaleString()
|
|
||||||
: "Chưa cập nhật",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Thời gian kết thúc",
|
|
||||||
value: netData.start_at
|
|
||||||
? new Date(netData.end_at).toLocaleString()
|
|
||||||
: "Chưa cập nhật",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Vị trí hạ thu",
|
|
||||||
value: netData.viTriHaThu || "Chưa cập nhật",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Vị trí thu lưới",
|
|
||||||
value: netData.viTriThuLuoi || "Chưa cập nhật",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Độ sâu hạ thu",
|
|
||||||
value: netData.doSauHaThu || "Chưa cập nhật",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Độ sâu thu lưới",
|
|
||||||
value: netData.doSauThuLuoi || "Chưa cập nhật",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.infoCard}>
|
|
||||||
{infoItems.map((item, index) => (
|
|
||||||
<View key={index} style={styles.infoRow}>
|
|
||||||
<Text style={styles.infoLabel}>{item.label}</Text>
|
|
||||||
{item.isStatus ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.statusBadge,
|
|
||||||
item.value === "Đã hoàn thành"
|
|
||||||
? styles.statusBadgeCompleted
|
|
||||||
: styles.statusBadgeInProgress,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.statusBadgeText,
|
|
||||||
item.value === "Đã hoàn thành"
|
|
||||||
? styles.statusBadgeTextCompleted
|
|
||||||
: styles.statusBadgeTextInProgress,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.infoValue}>{item.value}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import styles from "../../style/NetDetailModal.styles";
|
|
||||||
|
|
||||||
interface NotesSectionProps {
|
|
||||||
ghiChu?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NotesSection: React.FC<NotesSectionProps> = ({ ghiChu }) => {
|
|
||||||
if (!ghiChu) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.infoCard}>
|
|
||||||
<View style={styles.infoRow}>
|
|
||||||
<Text style={styles.infoLabel}>Ghi chú</Text>
|
|
||||||
<Text style={styles.infoValue}>{ghiChu}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export { CatchSectionHeader } from "./CatchSectionHeader";
|
|
||||||
export { FishCardForm } from "./FishCardForm";
|
|
||||||
export { FishCardHeader } from "./FishCardHeader";
|
|
||||||
export { FishCardList } from "./FishCardList";
|
|
||||||
export { FishSelectDropdown } from "./FishSelectDropdown";
|
|
||||||
export { InfoSection } from "./InfoSection";
|
|
||||||
export { NotesSection } from "./NotesSection";
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
export default StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingTop: 16,
|
|
||||||
paddingBottom: 8,
|
|
||||||
backgroundColor: "#f8f9fa",
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#e9ecef",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: "#333",
|
|
||||||
},
|
|
||||||
closeButton: {
|
|
||||||
padding: 8,
|
|
||||||
},
|
|
||||||
closeButtonText: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: "#007bff",
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
fieldGroup: {
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#333",
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#ccc",
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: 8,
|
|
||||||
fontSize: 16,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
},
|
|
||||||
infoValue: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: "#555",
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
rowGroup: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
},
|
|
||||||
fishNameDropdown: {
|
|
||||||
// Custom styles if needed
|
|
||||||
},
|
|
||||||
optionsStatusFishList: {
|
|
||||||
// Custom styles if needed
|
|
||||||
},
|
|
||||||
optionsList: {
|
|
||||||
maxHeight: 150,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#ccc",
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
position: "absolute",
|
|
||||||
top: 40,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
},
|
|
||||||
selectButton: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#ccc",
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: 8,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
selectButtonText: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: "#333",
|
|
||||||
},
|
|
||||||
optionItem: {
|
|
||||||
padding: 10,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#eee",
|
|
||||||
},
|
|
||||||
optionText: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: "#333",
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#ddd",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
marginBottom: 12,
|
|
||||||
backgroundColor: "#f9f9f9",
|
|
||||||
},
|
|
||||||
removeButton: {
|
|
||||||
backgroundColor: "#dc3545",
|
|
||||||
padding: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
alignSelf: "flex-end",
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
removeButtonText: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: "#dc3545",
|
|
||||||
fontSize: 12,
|
|
||||||
marginTop: 4,
|
|
||||||
},
|
|
||||||
buttonGroup: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-around",
|
|
||||||
marginTop: 16,
|
|
||||||
},
|
|
||||||
editButton: {
|
|
||||||
backgroundColor: "#007bff",
|
|
||||||
padding: 10,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
editButtonText: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
addButton: {
|
|
||||||
backgroundColor: "#28a745",
|
|
||||||
padding: 10,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
addButtonText: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
saveButton: {
|
|
||||||
backgroundColor: "#007bff",
|
|
||||||
padding: 10,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
saveButtonText: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
cancelButton: {
|
|
||||||
backgroundColor: "#6c757d",
|
|
||||||
padding: 10,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
cancelButtonText: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
addFishButton: {
|
|
||||||
backgroundColor: "#17a2b8",
|
|
||||||
padding: 10,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
addFishButtonText: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
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 React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -10,7 +12,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import styles from "./style/TripCostDetailModal.styles";
|
import { createStyles } from "./style/TripCostDetailModal.styles";
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// 🧩 Interface
|
// 🧩 Interface
|
||||||
@@ -29,6 +31,9 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
data,
|
data,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editableData, setEditableData] = useState<Model.TripCost[]>(data);
|
const [editableData, setEditableData] = useState<Model.TripCost[]>(data);
|
||||||
|
|
||||||
@@ -94,7 +99,7 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>Chi tiết chi phí chuyến đi</Text>
|
<Text style={styles.title}>{t("trip.costDetailModal.title")}</Text>
|
||||||
<View style={styles.headerButtons}>
|
<View style={styles.headerButtons}>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
@@ -102,13 +107,17 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
|
|||||||
onPress={handleCancel}
|
onPress={handleCancel}
|
||||||
style={styles.cancelButton}
|
style={styles.cancelButton}
|
||||||
>
|
>
|
||||||
<Text style={styles.cancelButtonText}>Hủy</Text>
|
<Text style={styles.cancelButtonText}>
|
||||||
|
{t("trip.costDetailModal.cancel")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
style={styles.saveButton}
|
style={styles.saveButton}
|
||||||
>
|
>
|
||||||
<Text style={styles.saveButtonText}>Lưu</Text>
|
<Text style={styles.saveButtonText}>
|
||||||
|
{t("trip.costDetailModal.save")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -140,13 +149,15 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
|
|||||||
<View key={index} style={styles.itemCard}>
|
<View key={index} style={styles.itemCard}>
|
||||||
{/* Loại */}
|
{/* Loại */}
|
||||||
<View style={styles.fieldGroup}>
|
<View style={styles.fieldGroup}>
|
||||||
<Text style={styles.label}>Loại chi phí</Text>
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.costType")}
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, !isEditing && styles.inputDisabled]}
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
value={item.type}
|
value={item.type}
|
||||||
onChangeText={(value) => updateItem(index, "type", value)}
|
onChangeText={(value) => updateItem(index, "type", value)}
|
||||||
editable={isEditing}
|
editable={isEditing}
|
||||||
placeholder="Nhập loại chi phí"
|
placeholder={t("trip.costDetailModal.enterCostType")}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -155,7 +166,9 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
|
|||||||
<View
|
<View
|
||||||
style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}
|
style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}
|
||||||
>
|
>
|
||||||
<Text style={styles.label}>Số lượng</Text>
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.quantity")}
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, !isEditing && styles.inputDisabled]}
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
value={String(item.amount ?? "")}
|
value={String(item.amount ?? "")}
|
||||||
@@ -168,20 +181,24 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.fieldGroup, { flex: 1, marginLeft: 8 }]}>
|
<View style={[styles.fieldGroup, { flex: 1, marginLeft: 8 }]}>
|
||||||
<Text style={styles.label}>Đơn vị</Text>
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.unit")}
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, !isEditing && styles.inputDisabled]}
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
value={item.unit}
|
value={item.unit}
|
||||||
onChangeText={(value) => updateItem(index, "unit", value)}
|
onChangeText={(value) => updateItem(index, "unit", value)}
|
||||||
editable={isEditing}
|
editable={isEditing}
|
||||||
placeholder="kg, lít..."
|
placeholder={t("trip.costDetailModal.placeholder")}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Chi phí/đơn vị */}
|
{/* Chi phí/đơn vị */}
|
||||||
<View style={styles.fieldGroup}>
|
<View style={styles.fieldGroup}>
|
||||||
<Text style={styles.label}>Chi phí/đơn vị (VNĐ)</Text>
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.costPerUnit")}
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, !isEditing && styles.inputDisabled]}
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
value={String(item.cost_per_unit ?? "")}
|
value={String(item.cost_per_unit ?? "")}
|
||||||
@@ -196,10 +213,13 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
|
|||||||
|
|
||||||
{/* Tổng chi phí */}
|
{/* Tổng chi phí */}
|
||||||
<View style={styles.fieldGroup}>
|
<View style={styles.fieldGroup}>
|
||||||
<Text style={styles.label}>Tổng chi phí</Text>
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.totalCost")}
|
||||||
|
</Text>
|
||||||
<View style={styles.totalContainer}>
|
<View style={styles.totalContainer}>
|
||||||
<Text style={styles.totalText}>
|
<Text style={styles.totalText}>
|
||||||
{item.total_cost.toLocaleString()} VNĐ
|
{item.total_cost.toLocaleString()}{" "}
|
||||||
|
{t("trip.costDetailModal.vnd")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -208,9 +228,11 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
|
|||||||
|
|
||||||
{/* Footer Total */}
|
{/* Footer Total */}
|
||||||
<View style={styles.footerTotal}>
|
<View style={styles.footerTotal}>
|
||||||
<Text style={styles.footerLabel}>Tổng cộng</Text>
|
<Text style={styles.footerLabel}>
|
||||||
|
{t("trip.costDetailModal.total")}
|
||||||
|
</Text>
|
||||||
<Text style={styles.footerAmount}>
|
<Text style={styles.footerAmount}>
|
||||||
{tongCong.toLocaleString()} VNĐ
|
{tongCong.toLocaleString()} {t("trip.costDetailModal.vnd")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
119
components/tripInfo/modal/components/InfoSection.tsx
Normal file
119
components/tripInfo/modal/components/InfoSection.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
interface InfoSectionProps {
|
||||||
|
fishingLog?: Model.FishingLog;
|
||||||
|
stt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InfoSection: React.FC<InfoSectionProps> = ({
|
||||||
|
fishingLog,
|
||||||
|
stt,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||||
|
|
||||||
|
if (!fishingLog) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const infoItems = [
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.sttLabel"),
|
||||||
|
value: `${t("trip.infoSection.haulPrefix")} ${stt}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.statusLabel"),
|
||||||
|
value:
|
||||||
|
fishingLog.status === 1
|
||||||
|
? t("trip.infoSection.statusCompleted")
|
||||||
|
: t("trip.infoSection.statusPending"),
|
||||||
|
isStatus: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.startTimeLabel"),
|
||||||
|
value: fishingLog.start_at
|
||||||
|
? new Date(fishingLog.start_at).toLocaleString()
|
||||||
|
: t("trip.infoSection.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.endTimeLabel"),
|
||||||
|
value:
|
||||||
|
fishingLog.end_at.toString() !== "0001-01-01T00:00:00Z"
|
||||||
|
? new Date(fishingLog.end_at).toLocaleString()
|
||||||
|
: "-",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
{infoItems.map((item, index) => (
|
||||||
|
<View key={index} style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>{item.label}</Text>
|
||||||
|
{item.isStatus ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
item.value === t("trip.infoSection.statusCompleted")
|
||||||
|
? styles.statusBadgeCompleted
|
||||||
|
: styles.statusBadgeInProgress,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.statusBadgeText}>{item.value}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoValue}>{item.value}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
infoCard: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
backgroundColor: colors.surfaceSecondary,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
statusBadgeCompleted: {
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
},
|
||||||
|
statusBadgeInProgress: {
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
},
|
||||||
|
statusBadgeText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export const createStyles = (colors: typeof Colors.light) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
closeIconButton: {
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
fishCard: {
|
||||||
|
backgroundColor: colors.surfaceSecondary,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
fishCardHeaderContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
fishCardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
fishCardSubtitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
fieldGroup: {
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
inputDisabled: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.error,
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
removeButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
addButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
footerSection: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.separator,
|
||||||
|
},
|
||||||
|
saveButtonLarge: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
saveButtonLargeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
emptyStateText: {
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,69 +1,69 @@
|
|||||||
|
import { Colors } from "@/constants/theme";
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
export const createStyles = (colors: typeof Colors.light) =>
|
||||||
container: {
|
StyleSheet.create({
|
||||||
flex: 1,
|
container: {
|
||||||
backgroundColor: "#f5f5f5",
|
flex: 1,
|
||||||
},
|
backgroundColor: colors.backgroundSecondary,
|
||||||
header: {
|
},
|
||||||
flexDirection: "row",
|
header: {
|
||||||
justifyContent: "space-between",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
justifyContent: "space-between",
|
||||||
paddingHorizontal: 20,
|
alignItems: "center",
|
||||||
paddingTop: 30,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 16,
|
paddingTop: 30,
|
||||||
backgroundColor: "#fff",
|
paddingBottom: 16,
|
||||||
borderBottomWidth: 1,
|
backgroundColor: colors.surface,
|
||||||
borderBottomColor: "#eee",
|
borderBottomWidth: 1,
|
||||||
},
|
borderBottomColor: colors.separator,
|
||||||
title: {
|
},
|
||||||
fontSize: 22,
|
title: {
|
||||||
fontWeight: "700",
|
fontSize: 22,
|
||||||
color: "#000",
|
fontWeight: "700",
|
||||||
flex: 1,
|
color: colors.text,
|
||||||
},
|
flex: 1,
|
||||||
closeButton: {
|
},
|
||||||
padding: 4,
|
closeButton: {
|
||||||
},
|
padding: 4,
|
||||||
closeIconButton: {
|
},
|
||||||
backgroundColor: "#FF3B30",
|
closeIconButton: {
|
||||||
borderRadius: 10,
|
backgroundColor: colors.error,
|
||||||
padding: 10,
|
borderRadius: 10,
|
||||||
justifyContent: "center",
|
padding: 10,
|
||||||
alignItems: "center",
|
justifyContent: "center",
|
||||||
},
|
alignItems: "center",
|
||||||
content: {
|
},
|
||||||
flex: 1,
|
content: {
|
||||||
padding: 16,
|
flex: 1,
|
||||||
marginBottom: 15,
|
padding: 16,
|
||||||
},
|
marginBottom: 15,
|
||||||
infoCard: {
|
},
|
||||||
backgroundColor: "#fff",
|
infoCard: {
|
||||||
borderRadius: 12,
|
backgroundColor: colors.card,
|
||||||
padding: 16,
|
borderRadius: 12,
|
||||||
marginBottom: 35,
|
padding: 16,
|
||||||
shadowColor: "#000",
|
marginBottom: 35,
|
||||||
shadowOpacity: 0.05,
|
shadowColor: "#000",
|
||||||
shadowRadius: 4,
|
shadowOpacity: 0.05,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowRadius: 4,
|
||||||
elevation: 2,
|
shadowOffset: { width: 0, height: 2 },
|
||||||
},
|
elevation: 2,
|
||||||
infoRow: {
|
},
|
||||||
paddingVertical: 12,
|
infoRow: {
|
||||||
borderBottomWidth: 1,
|
paddingVertical: 12,
|
||||||
borderBottomColor: "#f0f0f0",
|
borderBottomWidth: 1,
|
||||||
},
|
borderBottomColor: colors.separator,
|
||||||
infoLabel: {
|
},
|
||||||
fontSize: 13,
|
infoLabel: {
|
||||||
fontWeight: "600",
|
fontSize: 13,
|
||||||
color: "#666",
|
fontWeight: "600",
|
||||||
marginBottom: 6,
|
color: colors.textSecondary,
|
||||||
},
|
marginBottom: 6,
|
||||||
infoValue: {
|
},
|
||||||
fontSize: 16,
|
infoValue: {
|
||||||
color: "#000",
|
fontSize: 16,
|
||||||
fontWeight: "500",
|
color: colors.text,
|
||||||
},
|
fontWeight: "500",
|
||||||
});
|
},
|
||||||
|
});
|
||||||
export default styles;
|
|
||||||
|
|||||||
@@ -1,292 +1,293 @@
|
|||||||
|
import { Colors } from "@/constants/theme";
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
export const createStyles = (colors: typeof Colors.light) =>
|
||||||
container: {
|
StyleSheet.create({
|
||||||
flex: 1,
|
container: {
|
||||||
backgroundColor: "#f5f5f5",
|
flex: 1,
|
||||||
},
|
backgroundColor: colors.backgroundSecondary,
|
||||||
header: {
|
},
|
||||||
flexDirection: "row",
|
header: {
|
||||||
justifyContent: "space-between",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
justifyContent: "space-between",
|
||||||
paddingHorizontal: 20,
|
alignItems: "center",
|
||||||
paddingTop: 30,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 16,
|
paddingTop: 30,
|
||||||
backgroundColor: "#fff",
|
paddingBottom: 16,
|
||||||
borderBottomWidth: 1,
|
backgroundColor: colors.surface,
|
||||||
borderBottomColor: "#eee",
|
borderBottomWidth: 1,
|
||||||
},
|
borderBottomColor: colors.separator,
|
||||||
title: {
|
},
|
||||||
fontSize: 22,
|
title: {
|
||||||
fontWeight: "700",
|
fontSize: 22,
|
||||||
color: "#000",
|
fontWeight: "700",
|
||||||
flex: 1,
|
color: colors.text,
|
||||||
},
|
flex: 1,
|
||||||
closeButton: {
|
},
|
||||||
padding: 4,
|
closeButton: {
|
||||||
},
|
padding: 4,
|
||||||
closeIconButton: {
|
},
|
||||||
backgroundColor: "#FF3B30",
|
closeIconButton: {
|
||||||
borderRadius: 10,
|
backgroundColor: colors.error,
|
||||||
padding: 10,
|
borderRadius: 10,
|
||||||
justifyContent: "center",
|
padding: 10,
|
||||||
alignItems: "center",
|
justifyContent: "center",
|
||||||
},
|
alignItems: "center",
|
||||||
content: {
|
},
|
||||||
flex: 1,
|
content: {
|
||||||
padding: 16,
|
flex: 1,
|
||||||
marginBottom: 15,
|
padding: 16,
|
||||||
},
|
marginBottom: 15,
|
||||||
infoCard: {
|
},
|
||||||
backgroundColor: "#fff",
|
infoCard: {
|
||||||
borderRadius: 12,
|
backgroundColor: colors.card,
|
||||||
padding: 16,
|
borderRadius: 12,
|
||||||
marginBottom: 35,
|
padding: 16,
|
||||||
shadowColor: "#000",
|
marginBottom: 35,
|
||||||
shadowOpacity: 0.05,
|
shadowColor: "#000",
|
||||||
shadowRadius: 4,
|
shadowOpacity: 0.05,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowRadius: 4,
|
||||||
elevation: 2,
|
shadowOffset: { width: 0, height: 2 },
|
||||||
},
|
elevation: 2,
|
||||||
infoRow: {
|
},
|
||||||
paddingVertical: 12,
|
infoRow: {
|
||||||
borderBottomWidth: 1,
|
paddingVertical: 12,
|
||||||
borderBottomColor: "#f0f0f0",
|
borderBottomWidth: 1,
|
||||||
},
|
borderBottomColor: colors.separator,
|
||||||
infoLabel: {
|
},
|
||||||
fontSize: 13,
|
infoLabel: {
|
||||||
fontWeight: "600",
|
fontSize: 13,
|
||||||
color: "#666",
|
fontWeight: "600",
|
||||||
marginBottom: 6,
|
color: colors.textSecondary,
|
||||||
},
|
marginBottom: 6,
|
||||||
infoValue: {
|
},
|
||||||
fontSize: 16,
|
infoValue: {
|
||||||
color: "#000",
|
fontSize: 16,
|
||||||
fontWeight: "500",
|
color: colors.text,
|
||||||
},
|
fontWeight: "500",
|
||||||
statusBadge: {
|
},
|
||||||
paddingHorizontal: 12,
|
statusBadge: {
|
||||||
paddingVertical: 6,
|
paddingHorizontal: 12,
|
||||||
borderRadius: 8,
|
paddingVertical: 6,
|
||||||
alignSelf: "flex-start",
|
borderRadius: 8,
|
||||||
},
|
alignSelf: "flex-start",
|
||||||
statusBadgeCompleted: {
|
},
|
||||||
backgroundColor: "#e8f5e9",
|
statusBadgeCompleted: {
|
||||||
},
|
backgroundColor: colors.success,
|
||||||
statusBadgeInProgress: {
|
},
|
||||||
backgroundColor: "#fff3e0",
|
statusBadgeInProgress: {
|
||||||
},
|
backgroundColor: colors.warning,
|
||||||
statusBadgeText: {
|
},
|
||||||
fontSize: 14,
|
statusBadgeText: {
|
||||||
fontWeight: "600",
|
fontSize: 14,
|
||||||
},
|
fontWeight: "600",
|
||||||
statusBadgeTextCompleted: {
|
color: "#fff",
|
||||||
color: "#2e7d32",
|
},
|
||||||
},
|
statusBadgeTextCompleted: {
|
||||||
statusBadgeTextInProgress: {
|
color: "#fff",
|
||||||
color: "#f57c00",
|
},
|
||||||
},
|
statusBadgeTextInProgress: {
|
||||||
headerButtons: {
|
color: "#fff",
|
||||||
flexDirection: "row",
|
},
|
||||||
alignItems: "center",
|
headerButtons: {
|
||||||
gap: 12,
|
flexDirection: "row",
|
||||||
},
|
alignItems: "center",
|
||||||
editButton: {
|
gap: 12,
|
||||||
padding: 4,
|
},
|
||||||
},
|
editButton: {
|
||||||
editIconButton: {
|
padding: 4,
|
||||||
backgroundColor: "#007AFF",
|
},
|
||||||
borderRadius: 10,
|
editIconButton: {
|
||||||
padding: 10,
|
backgroundColor: colors.primary,
|
||||||
justifyContent: "center",
|
borderRadius: 10,
|
||||||
alignItems: "center",
|
padding: 10,
|
||||||
},
|
justifyContent: "center",
|
||||||
cancelButton: {
|
alignItems: "center",
|
||||||
paddingHorizontal: 12,
|
},
|
||||||
paddingVertical: 6,
|
cancelButton: {
|
||||||
},
|
paddingHorizontal: 12,
|
||||||
cancelButtonText: {
|
paddingVertical: 6,
|
||||||
color: "#007AFF",
|
},
|
||||||
fontSize: 16,
|
cancelButtonText: {
|
||||||
fontWeight: "600",
|
color: colors.primary,
|
||||||
},
|
fontSize: 16,
|
||||||
saveButton: {
|
fontWeight: "600",
|
||||||
backgroundColor: "#007AFF",
|
},
|
||||||
paddingHorizontal: 16,
|
saveButton: {
|
||||||
paddingVertical: 6,
|
backgroundColor: colors.primary,
|
||||||
borderRadius: 6,
|
paddingHorizontal: 16,
|
||||||
},
|
paddingVertical: 6,
|
||||||
saveButtonText: {
|
borderRadius: 6,
|
||||||
color: "#fff",
|
},
|
||||||
fontSize: 16,
|
saveButtonText: {
|
||||||
fontWeight: "600",
|
color: "#fff",
|
||||||
},
|
fontSize: 16,
|
||||||
sectionHeader: {
|
fontWeight: "600",
|
||||||
flexDirection: "row",
|
},
|
||||||
justifyContent: "space-between",
|
sectionHeader: {
|
||||||
alignItems: "center",
|
flexDirection: "row",
|
||||||
marginTop: 16,
|
justifyContent: "space-between",
|
||||||
marginBottom: 12,
|
alignItems: "center",
|
||||||
paddingHorizontal: 4,
|
marginTop: 16,
|
||||||
},
|
marginBottom: 12,
|
||||||
sectionTitle: {
|
paddingHorizontal: 4,
|
||||||
fontSize: 18,
|
},
|
||||||
fontWeight: "700",
|
sectionTitle: {
|
||||||
color: "#000",
|
fontSize: 18,
|
||||||
},
|
fontWeight: "700",
|
||||||
totalCatchText: {
|
color: colors.text,
|
||||||
fontSize: 16,
|
},
|
||||||
fontWeight: "600",
|
totalCatchText: {
|
||||||
color: "#007AFF",
|
fontSize: 16,
|
||||||
},
|
fontWeight: "600",
|
||||||
fishCard: {
|
color: colors.primary,
|
||||||
position: "relative",
|
},
|
||||||
backgroundColor: "#fff",
|
fishCard: {
|
||||||
borderRadius: 12,
|
position: "relative",
|
||||||
padding: 16,
|
backgroundColor: colors.card,
|
||||||
marginBottom: 12,
|
borderRadius: 12,
|
||||||
shadowColor: "#000",
|
padding: 16,
|
||||||
shadowOpacity: 0.05,
|
marginBottom: 12,
|
||||||
shadowRadius: 4,
|
shadowColor: "#000",
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOpacity: 0.05,
|
||||||
elevation: 2,
|
shadowRadius: 4,
|
||||||
},
|
shadowOffset: { width: 0, height: 2 },
|
||||||
fieldGroup: {
|
elevation: 2,
|
||||||
marginBottom: 12,
|
},
|
||||||
marginTop: 0,
|
fieldGroup: {
|
||||||
},
|
marginBottom: 12,
|
||||||
rowGroup: {
|
marginTop: 0,
|
||||||
flexDirection: "row",
|
},
|
||||||
marginBottom: 12,
|
rowGroup: {
|
||||||
},
|
flexDirection: "row",
|
||||||
label: {
|
marginBottom: 12,
|
||||||
fontSize: 13,
|
},
|
||||||
fontWeight: "600",
|
label: {
|
||||||
color: "#666",
|
fontSize: 13,
|
||||||
marginBottom: 6,
|
fontWeight: "600",
|
||||||
},
|
color: colors.textSecondary,
|
||||||
input: {
|
marginBottom: 6,
|
||||||
borderWidth: 1,
|
},
|
||||||
borderColor: "#007AFF",
|
input: {
|
||||||
borderRadius: 8,
|
borderWidth: 1,
|
||||||
paddingHorizontal: 12,
|
borderColor: colors.primary,
|
||||||
paddingVertical: 10,
|
borderRadius: 8,
|
||||||
fontSize: 15,
|
paddingHorizontal: 12,
|
||||||
color: "#000",
|
paddingVertical: 10,
|
||||||
backgroundColor: "#fff",
|
fontSize: 15,
|
||||||
},
|
color: colors.text,
|
||||||
selectButton: {
|
backgroundColor: colors.surface,
|
||||||
borderWidth: 1,
|
},
|
||||||
borderColor: "#007AFF",
|
selectButton: {
|
||||||
borderRadius: 8,
|
borderWidth: 1,
|
||||||
paddingHorizontal: 12,
|
borderColor: colors.primary,
|
||||||
paddingVertical: 10,
|
borderRadius: 8,
|
||||||
flexDirection: "row",
|
paddingHorizontal: 12,
|
||||||
justifyContent: "space-between",
|
paddingVertical: 10,
|
||||||
alignItems: "center",
|
flexDirection: "row",
|
||||||
backgroundColor: "#fff",
|
justifyContent: "space-between",
|
||||||
},
|
alignItems: "center",
|
||||||
selectButtonText: {
|
backgroundColor: colors.surface,
|
||||||
fontSize: 15,
|
},
|
||||||
color: "#000",
|
selectButtonText: {
|
||||||
},
|
fontSize: 15,
|
||||||
optionsList: {
|
color: colors.text,
|
||||||
position: "absolute",
|
},
|
||||||
top: 46,
|
optionsList: {
|
||||||
left: 0,
|
position: "absolute",
|
||||||
right: 0,
|
top: 46,
|
||||||
borderWidth: 1,
|
left: 0,
|
||||||
borderColor: "#007AFF",
|
right: 0,
|
||||||
borderRadius: 8,
|
borderWidth: 1,
|
||||||
marginTop: 4,
|
borderColor: colors.primary,
|
||||||
backgroundColor: "#fff",
|
borderRadius: 8,
|
||||||
maxHeight: 100,
|
marginTop: 4,
|
||||||
zIndex: 1000,
|
backgroundColor: colors.surface,
|
||||||
elevation: 5,
|
maxHeight: 100,
|
||||||
shadowColor: "#000",
|
zIndex: 1000,
|
||||||
shadowOpacity: 0.15,
|
elevation: 5,
|
||||||
shadowRadius: 8,
|
shadowColor: "#000",
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOpacity: 0.15,
|
||||||
},
|
shadowRadius: 8,
|
||||||
optionItem: {
|
shadowOffset: { width: 0, height: 4 },
|
||||||
paddingHorizontal: 12,
|
},
|
||||||
paddingVertical: 12,
|
optionItem: {
|
||||||
borderBottomWidth: 1,
|
paddingHorizontal: 12,
|
||||||
borderBottomColor: "#f0f0f0",
|
paddingVertical: 12,
|
||||||
},
|
borderBottomWidth: 1,
|
||||||
optionText: {
|
borderBottomColor: colors.separator,
|
||||||
fontSize: 15,
|
},
|
||||||
color: "#000",
|
optionText: {
|
||||||
},
|
fontSize: 15,
|
||||||
optionsStatusFishList: {
|
color: colors.text,
|
||||||
borderWidth: 1,
|
},
|
||||||
borderColor: "#007AFF",
|
optionsStatusFishList: {
|
||||||
borderRadius: 8,
|
borderWidth: 1,
|
||||||
marginTop: 4,
|
borderColor: colors.primary,
|
||||||
backgroundColor: "#fff",
|
borderRadius: 8,
|
||||||
maxHeight: 120,
|
marginTop: 4,
|
||||||
zIndex: 1000,
|
backgroundColor: colors.surface,
|
||||||
elevation: 5,
|
maxHeight: 120,
|
||||||
shadowColor: "#000",
|
zIndex: 1000,
|
||||||
shadowOpacity: 0.15,
|
elevation: 5,
|
||||||
shadowRadius: 8,
|
shadowColor: "#000",
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOpacity: 0.15,
|
||||||
},
|
shadowRadius: 8,
|
||||||
fishNameDropdown: {
|
shadowOffset: { width: 0, height: 4 },
|
||||||
position: "absolute",
|
},
|
||||||
top: 46,
|
fishNameDropdown: {
|
||||||
left: 0,
|
position: "absolute",
|
||||||
right: 0,
|
top: 46,
|
||||||
borderWidth: 1,
|
left: 0,
|
||||||
borderColor: "#007AFF",
|
right: 0,
|
||||||
borderRadius: 8,
|
borderWidth: 1,
|
||||||
marginTop: 4,
|
borderColor: colors.primary,
|
||||||
backgroundColor: "#fff",
|
borderRadius: 8,
|
||||||
maxHeight: 180,
|
marginTop: 4,
|
||||||
zIndex: 1000,
|
backgroundColor: colors.surface,
|
||||||
elevation: 5,
|
maxHeight: 180,
|
||||||
shadowColor: "#000",
|
zIndex: 1000,
|
||||||
shadowOpacity: 0.15,
|
elevation: 5,
|
||||||
shadowRadius: 8,
|
shadowColor: "#000",
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOpacity: 0.15,
|
||||||
},
|
shadowRadius: 8,
|
||||||
fishCardHeaderContent: {
|
shadowOffset: { width: 0, height: 4 },
|
||||||
flexDirection: "row",
|
},
|
||||||
gap: 5,
|
fishCardHeaderContent: {
|
||||||
},
|
flexDirection: "row",
|
||||||
fishCardTitle: {
|
gap: 5,
|
||||||
fontSize: 16,
|
},
|
||||||
fontWeight: "600",
|
fishCardTitle: {
|
||||||
color: "#000",
|
fontSize: 16,
|
||||||
},
|
fontWeight: "600",
|
||||||
fishCardSubtitle: {
|
color: colors.text,
|
||||||
fontSize: 15,
|
},
|
||||||
color: "#ff6600",
|
fishCardSubtitle: {
|
||||||
marginTop: 0,
|
fontSize: 15,
|
||||||
},
|
color: colors.warning,
|
||||||
addFishButton: {
|
marginTop: 0,
|
||||||
backgroundColor: "#007AFF",
|
},
|
||||||
borderRadius: 12,
|
addFishButton: {
|
||||||
padding: 16,
|
backgroundColor: colors.primary,
|
||||||
marginBottom: 12,
|
borderRadius: 12,
|
||||||
justifyContent: "center",
|
padding: 16,
|
||||||
alignItems: "center",
|
marginBottom: 12,
|
||||||
shadowColor: "#000",
|
justifyContent: "center",
|
||||||
shadowOpacity: 0.05,
|
alignItems: "center",
|
||||||
shadowRadius: 4,
|
shadowColor: "#000",
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOpacity: 0.05,
|
||||||
elevation: 2,
|
shadowRadius: 4,
|
||||||
},
|
shadowOffset: { width: 0, height: 2 },
|
||||||
addFishButtonContent: {
|
elevation: 2,
|
||||||
flexDirection: "row",
|
},
|
||||||
alignItems: "center",
|
addFishButtonContent: {
|
||||||
gap: 8,
|
flexDirection: "row",
|
||||||
},
|
alignItems: "center",
|
||||||
addFishButtonText: {
|
gap: 8,
|
||||||
fontSize: 16,
|
},
|
||||||
fontWeight: "600",
|
addFishButtonText: {
|
||||||
color: "#fff",
|
fontSize: 16,
|
||||||
},
|
fontWeight: "600",
|
||||||
});
|
color: "#fff",
|
||||||
|
},
|
||||||
export default styles;
|
});
|
||||||
|
|||||||
@@ -1,153 +1,153 @@
|
|||||||
|
import { Colors } from "@/constants/theme";
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
export const createStyles = (colors: typeof Colors.light) =>
|
||||||
closeIconButton: {
|
StyleSheet.create({
|
||||||
backgroundColor: "#FF3B30",
|
closeIconButton: {
|
||||||
borderRadius: 10,
|
backgroundColor: colors.error,
|
||||||
padding: 10,
|
borderRadius: 10,
|
||||||
justifyContent: "center",
|
padding: 10,
|
||||||
alignItems: "center",
|
justifyContent: "center",
|
||||||
},
|
alignItems: "center",
|
||||||
container: {
|
},
|
||||||
flex: 1,
|
container: {
|
||||||
backgroundColor: "#f5f5f5",
|
flex: 1,
|
||||||
},
|
backgroundColor: colors.backgroundSecondary,
|
||||||
header: {
|
},
|
||||||
flexDirection: "row",
|
header: {
|
||||||
justifyContent: "space-between",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
justifyContent: "space-between",
|
||||||
paddingHorizontal: 20,
|
alignItems: "center",
|
||||||
paddingTop: 30,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 16,
|
paddingTop: 30,
|
||||||
backgroundColor: "#fff",
|
paddingBottom: 16,
|
||||||
borderBottomWidth: 1,
|
backgroundColor: colors.surface,
|
||||||
borderBottomColor: "#eee",
|
borderBottomWidth: 1,
|
||||||
},
|
borderBottomColor: colors.separator,
|
||||||
title: {
|
},
|
||||||
fontSize: 22,
|
title: {
|
||||||
fontWeight: "700",
|
fontSize: 22,
|
||||||
color: "#000",
|
fontWeight: "700",
|
||||||
flex: 1,
|
color: colors.text,
|
||||||
},
|
flex: 1,
|
||||||
headerButtons: {
|
},
|
||||||
flexDirection: "row",
|
headerButtons: {
|
||||||
alignItems: "center",
|
flexDirection: "row",
|
||||||
gap: 12,
|
alignItems: "center",
|
||||||
},
|
gap: 12,
|
||||||
editButton: {
|
},
|
||||||
padding: 4,
|
editButton: {
|
||||||
},
|
padding: 4,
|
||||||
editIconButton: {
|
},
|
||||||
backgroundColor: "#007AFF",
|
editIconButton: {
|
||||||
borderRadius: 10,
|
backgroundColor: colors.primary,
|
||||||
padding: 10,
|
borderRadius: 10,
|
||||||
justifyContent: "center",
|
padding: 10,
|
||||||
alignItems: "center",
|
justifyContent: "center",
|
||||||
},
|
alignItems: "center",
|
||||||
cancelButton: {
|
},
|
||||||
paddingHorizontal: 12,
|
cancelButton: {
|
||||||
paddingVertical: 6,
|
paddingHorizontal: 12,
|
||||||
},
|
paddingVertical: 6,
|
||||||
cancelButtonText: {
|
},
|
||||||
color: "#007AFF",
|
cancelButtonText: {
|
||||||
fontSize: 16,
|
color: colors.primary,
|
||||||
fontWeight: "600",
|
fontSize: 16,
|
||||||
},
|
fontWeight: "600",
|
||||||
saveButton: {
|
},
|
||||||
backgroundColor: "#007AFF",
|
saveButton: {
|
||||||
paddingHorizontal: 16,
|
backgroundColor: colors.primary,
|
||||||
paddingVertical: 6,
|
paddingHorizontal: 16,
|
||||||
borderRadius: 6,
|
paddingVertical: 6,
|
||||||
},
|
borderRadius: 6,
|
||||||
saveButtonText: {
|
},
|
||||||
color: "#fff",
|
saveButtonText: {
|
||||||
fontSize: 16,
|
color: "#fff",
|
||||||
fontWeight: "600",
|
fontSize: 16,
|
||||||
},
|
fontWeight: "600",
|
||||||
closeButton: {
|
},
|
||||||
padding: 4,
|
closeButton: {
|
||||||
},
|
padding: 4,
|
||||||
content: {
|
},
|
||||||
flex: 1,
|
content: {
|
||||||
padding: 16,
|
flex: 1,
|
||||||
},
|
padding: 16,
|
||||||
itemCard: {
|
},
|
||||||
backgroundColor: "#fff",
|
itemCard: {
|
||||||
borderRadius: 12,
|
backgroundColor: colors.surfaceSecondary,
|
||||||
padding: 16,
|
borderRadius: 12,
|
||||||
marginBottom: 12,
|
padding: 16,
|
||||||
shadowColor: "#000",
|
marginBottom: 12,
|
||||||
shadowOpacity: 0.05,
|
shadowColor: "#000",
|
||||||
shadowRadius: 4,
|
shadowOpacity: 0.05,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowRadius: 4,
|
||||||
elevation: 2,
|
shadowOffset: { width: 0, height: 2 },
|
||||||
},
|
elevation: 2,
|
||||||
fieldGroup: {
|
},
|
||||||
marginBottom: 12,
|
fieldGroup: {
|
||||||
},
|
marginBottom: 12,
|
||||||
rowGroup: {
|
},
|
||||||
flexDirection: "row",
|
rowGroup: {
|
||||||
marginBottom: 12,
|
flexDirection: "row",
|
||||||
},
|
marginBottom: 12,
|
||||||
label: {
|
},
|
||||||
fontSize: 13,
|
label: {
|
||||||
fontWeight: "600",
|
fontSize: 13,
|
||||||
color: "#666",
|
fontWeight: "600",
|
||||||
marginBottom: 6,
|
color: colors.textSecondary,
|
||||||
},
|
marginBottom: 6,
|
||||||
input: {
|
},
|
||||||
borderWidth: 1,
|
input: {
|
||||||
borderColor: "#007AFF",
|
borderWidth: 1,
|
||||||
borderRadius: 8,
|
borderColor: colors.primary,
|
||||||
paddingHorizontal: 12,
|
borderRadius: 8,
|
||||||
paddingVertical: 10,
|
paddingHorizontal: 12,
|
||||||
fontSize: 15,
|
paddingVertical: 10,
|
||||||
color: "#000",
|
fontSize: 15,
|
||||||
backgroundColor: "#fff",
|
color: colors.text,
|
||||||
},
|
backgroundColor: colors.surface,
|
||||||
inputDisabled: {
|
},
|
||||||
borderColor: "#ddd",
|
inputDisabled: {
|
||||||
backgroundColor: "#f9f9f9",
|
borderColor: colors.border,
|
||||||
color: "#666",
|
backgroundColor: colors.backgroundSecondary,
|
||||||
},
|
color: colors.textSecondary,
|
||||||
totalContainer: {
|
},
|
||||||
backgroundColor: "#fff5e6",
|
totalContainer: {
|
||||||
borderRadius: 8,
|
backgroundColor: colors.backgroundSecondary,
|
||||||
paddingHorizontal: 12,
|
borderRadius: 8,
|
||||||
paddingVertical: 10,
|
paddingHorizontal: 12,
|
||||||
borderWidth: 1,
|
paddingVertical: 10,
|
||||||
borderColor: "#ffd699",
|
borderWidth: 1,
|
||||||
},
|
borderColor: colors.border,
|
||||||
totalText: {
|
},
|
||||||
fontSize: 16,
|
totalText: {
|
||||||
fontWeight: "700",
|
fontSize: 16,
|
||||||
color: "#ff6600",
|
fontWeight: "700",
|
||||||
},
|
color: colors.warning,
|
||||||
footerTotal: {
|
},
|
||||||
backgroundColor: "#fff",
|
footerTotal: {
|
||||||
borderRadius: 12,
|
backgroundColor: colors.card,
|
||||||
padding: 20,
|
borderRadius: 12,
|
||||||
marginTop: 8,
|
padding: 20,
|
||||||
marginBottom: 50,
|
marginTop: 8,
|
||||||
flexDirection: "row",
|
marginBottom: 50,
|
||||||
justifyContent: "space-between",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
justifyContent: "space-between",
|
||||||
shadowColor: "#000",
|
alignItems: "center",
|
||||||
shadowOpacity: 0.1,
|
shadowColor: "#000",
|
||||||
shadowRadius: 4,
|
shadowOpacity: 0.1,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowRadius: 4,
|
||||||
elevation: 3,
|
shadowOffset: { width: 0, height: 2 },
|
||||||
},
|
elevation: 3,
|
||||||
footerLabel: {
|
},
|
||||||
fontSize: 18,
|
footerLabel: {
|
||||||
fontWeight: "700",
|
fontSize: 18,
|
||||||
color: "#007bff",
|
fontWeight: "700",
|
||||||
},
|
color: colors.primary,
|
||||||
footerAmount: {
|
},
|
||||||
fontSize: 20,
|
footerAmount: {
|
||||||
fontWeight: "700",
|
fontSize: 20,
|
||||||
color: "#ff6600",
|
fontWeight: "700",
|
||||||
},
|
color: colors.warning,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
export default styles;
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
export default StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
marginVertical: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#fff",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
headerRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
totalCollapsed: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderBottomWidth: 0.5,
|
|
||||||
borderBottomColor: "#eee",
|
|
||||||
},
|
|
||||||
tableHeader: {
|
|
||||||
backgroundColor: "#fafafa",
|
|
||||||
borderRadius: 6,
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
cell: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#111",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
cellWrapper: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
right: {
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
headerText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footerText: {
|
|
||||||
color: "#007bff",
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footerTotal: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontWeight: "800",
|
|
||||||
},
|
|
||||||
linkText: {
|
|
||||||
color: "#007AFF",
|
|
||||||
textDecorationLine: "underline",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
export default StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
marginVertical: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#fff",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
headerRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
totalCollapsed: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderBottomWidth: 0.5,
|
|
||||||
borderBottomColor: "#eee",
|
|
||||||
paddingLeft: 15,
|
|
||||||
},
|
|
||||||
cell: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#111",
|
|
||||||
},
|
|
||||||
left: {
|
|
||||||
textAlign: "left",
|
|
||||||
},
|
|
||||||
right: {
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
tableHeader: {
|
|
||||||
backgroundColor: "#fafafa",
|
|
||||||
borderRadius: 6,
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
headerText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footerText: {
|
|
||||||
color: "#007bff",
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footerTotal: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontWeight: "800",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
export default StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
marginVertical: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#eee",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 1,
|
|
||||||
},
|
|
||||||
headerRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
totalCollapsed: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderBottomWidth: 0.5,
|
|
||||||
borderBottomColor: "#eee",
|
|
||||||
},
|
|
||||||
tableHeader: {
|
|
||||||
backgroundColor: "#fafafa",
|
|
||||||
borderRadius: 6,
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
cell: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#111",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
sttCell: {
|
|
||||||
flex: 0.3,
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#111",
|
|
||||||
textAlign: "center",
|
|
||||||
paddingLeft: 10,
|
|
||||||
},
|
|
||||||
headerText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
statusContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
statusDot: {
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: "#2ecc71",
|
|
||||||
marginRight: 6,
|
|
||||||
},
|
|
||||||
statusText: {
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#4a90e2",
|
|
||||||
textDecorationLine: "underline",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { StyleSheet } from "react-native";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: "100%",
|
|
||||||
margin: 16,
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "700",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
borderBottomWidth: 0.5,
|
|
||||||
borderColor: "#ddd",
|
|
||||||
paddingVertical: 8,
|
|
||||||
paddingLeft: 15,
|
|
||||||
},
|
|
||||||
cell: {
|
|
||||||
flex: 1,
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: 15,
|
|
||||||
},
|
|
||||||
left: {
|
|
||||||
textAlign: "left",
|
|
||||||
},
|
|
||||||
right: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
backgroundColor: "#f8f8f8",
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
headerText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
marginTop: 6,
|
|
||||||
},
|
|
||||||
footerText: {
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#007bff",
|
|
||||||
},
|
|
||||||
total: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
viewDetailButton: {
|
|
||||||
marginTop: 12,
|
|
||||||
paddingVertical: 8,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
viewDetailText: {
|
|
||||||
color: "#007AFF",
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: "600",
|
|
||||||
textDecorationLine: "underline",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default styles;
|
|
||||||
175
components/tripInfo/style/createTableStyles.ts
Normal file
175
components/tripInfo/style/createTableStyles.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
|
||||||
|
export type ColorScheme = "light" | "dark";
|
||||||
|
|
||||||
|
export function createTableStyles(colorScheme: ColorScheme) {
|
||||||
|
const colors = Colors[colorScheme];
|
||||||
|
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginVertical: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
shadowColor: colors.text,
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
totalCollapsed: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 0.5,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
rowHorizontal: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 0.5,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
paddingLeft: 15,
|
||||||
|
},
|
||||||
|
tableHeader: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
headerCell: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
headerCellLeft: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
cellLeft: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
cellRight: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
cellWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
headerText: {
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
footerTotal: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
sttCell: {
|
||||||
|
flex: 0.3,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
paddingLeft: 10,
|
||||||
|
},
|
||||||
|
statusContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
statusDotPending: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.primary,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: colors.primary,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
viewDetailButton: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
viewDetailText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
footerRow: {
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TableStyles = ReturnType<typeof createTableStyles>;
|
||||||
@@ -5,11 +5,11 @@ import { ThemedText } from '@/components/themed-text';
|
|||||||
import { ThemedView } from '@/components/themed-view';
|
import { ThemedView } from '@/components/themed-view';
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||||
import { Colors } from '@/constants/theme';
|
import { Colors } from '@/constants/theme';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-theme-context';
|
||||||
|
|
||||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const theme = useColorScheme() ?? 'light';
|
const theme = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedView>
|
<ThemedView>
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import React from 'react';
|
|
||||||
import { createAlertDialog } from '@gluestack-ui/core/alert-dialog/creator';
|
|
||||||
import { tva } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import {
|
|
||||||
withStyleContext,
|
|
||||||
useStyleContext,
|
|
||||||
} from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
|
|
||||||
import { cssInterop } from 'nativewind';
|
|
||||||
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import {
|
|
||||||
Motion,
|
|
||||||
AnimatePresence,
|
|
||||||
createMotionAnimatedComponent,
|
|
||||||
MotionComponentProps,
|
|
||||||
} from '@legendapp/motion';
|
|
||||||
import { View, Pressable, ScrollView, ViewStyle } from 'react-native';
|
|
||||||
|
|
||||||
const SCOPE = 'ALERT_DIALOG';
|
|
||||||
|
|
||||||
const RootComponent = withStyleContext(View, SCOPE);
|
|
||||||
|
|
||||||
type IMotionViewProps = React.ComponentProps<typeof View> &
|
|
||||||
MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>;
|
|
||||||
|
|
||||||
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>;
|
|
||||||
|
|
||||||
type IAnimatedPressableProps = React.ComponentProps<typeof Pressable> &
|
|
||||||
MotionComponentProps<typeof Pressable, ViewStyle, unknown, unknown, unknown>;
|
|
||||||
|
|
||||||
const AnimatedPressable = createMotionAnimatedComponent(
|
|
||||||
Pressable
|
|
||||||
) as React.ComponentType<IAnimatedPressableProps>;
|
|
||||||
|
|
||||||
const UIAccessibleAlertDialog = createAlertDialog({
|
|
||||||
Root: RootComponent,
|
|
||||||
Body: ScrollView,
|
|
||||||
Content: MotionView,
|
|
||||||
CloseButton: Pressable,
|
|
||||||
Header: View,
|
|
||||||
Footer: View,
|
|
||||||
Backdrop: AnimatedPressable,
|
|
||||||
AnimatePresence: AnimatePresence,
|
|
||||||
});
|
|
||||||
|
|
||||||
cssInterop(MotionView, { className: 'style' });
|
|
||||||
cssInterop(AnimatedPressable, { className: 'style' });
|
|
||||||
|
|
||||||
const alertDialogStyle = tva({
|
|
||||||
base: 'group/modal w-full h-full justify-center items-center web:pointer-events-none',
|
|
||||||
parentVariants: {
|
|
||||||
size: {
|
|
||||||
xs: '',
|
|
||||||
sm: '',
|
|
||||||
md: '',
|
|
||||||
lg: '',
|
|
||||||
full: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const alertDialogContentStyle = tva({
|
|
||||||
base: 'bg-background-0 rounded-lg overflow-hidden border border-outline-100 p-6',
|
|
||||||
parentVariants: {
|
|
||||||
size: {
|
|
||||||
xs: 'w-[60%] max-w-[360px]',
|
|
||||||
sm: 'w-[70%] max-w-[420px]',
|
|
||||||
md: 'w-[80%] max-w-[510px]',
|
|
||||||
lg: 'w-[90%] max-w-[640px]',
|
|
||||||
full: 'w-full',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const alertDialogCloseButtonStyle = tva({
|
|
||||||
base: 'group/alert-dialog-close-button z-10 rounded-sm p-2 data-[focus-visible=true]:bg-background-100 web:cursor-pointer outline-0',
|
|
||||||
});
|
|
||||||
|
|
||||||
const alertDialogHeaderStyle = tva({
|
|
||||||
base: 'justify-between items-center flex-row',
|
|
||||||
});
|
|
||||||
|
|
||||||
const alertDialogFooterStyle = tva({
|
|
||||||
base: 'flex-row justify-end items-center gap-3',
|
|
||||||
});
|
|
||||||
|
|
||||||
const alertDialogBodyStyle = tva({ base: '' });
|
|
||||||
|
|
||||||
const alertDialogBackdropStyle = tva({
|
|
||||||
base: 'absolute left-0 top-0 right-0 bottom-0 bg-background-dark web:cursor-default',
|
|
||||||
});
|
|
||||||
|
|
||||||
type IAlertDialogProps = React.ComponentPropsWithoutRef<
|
|
||||||
typeof UIAccessibleAlertDialog
|
|
||||||
> &
|
|
||||||
VariantProps<typeof alertDialogStyle>;
|
|
||||||
|
|
||||||
type IAlertDialogContentProps = React.ComponentPropsWithoutRef<
|
|
||||||
typeof UIAccessibleAlertDialog.Content
|
|
||||||
> &
|
|
||||||
VariantProps<typeof alertDialogContentStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IAlertDialogCloseButtonProps = React.ComponentPropsWithoutRef<
|
|
||||||
typeof UIAccessibleAlertDialog.CloseButton
|
|
||||||
> &
|
|
||||||
VariantProps<typeof alertDialogCloseButtonStyle>;
|
|
||||||
|
|
||||||
type IAlertDialogHeaderProps = React.ComponentPropsWithoutRef<
|
|
||||||
typeof UIAccessibleAlertDialog.Header
|
|
||||||
> &
|
|
||||||
VariantProps<typeof alertDialogHeaderStyle>;
|
|
||||||
|
|
||||||
type IAlertDialogFooterProps = React.ComponentPropsWithoutRef<
|
|
||||||
typeof UIAccessibleAlertDialog.Footer
|
|
||||||
> &
|
|
||||||
VariantProps<typeof alertDialogFooterStyle>;
|
|
||||||
|
|
||||||
type IAlertDialogBodyProps = React.ComponentPropsWithoutRef<
|
|
||||||
typeof UIAccessibleAlertDialog.Body
|
|
||||||
> &
|
|
||||||
VariantProps<typeof alertDialogBodyStyle>;
|
|
||||||
|
|
||||||
type IAlertDialogBackdropProps = React.ComponentPropsWithoutRef<
|
|
||||||
typeof UIAccessibleAlertDialog.Backdrop
|
|
||||||
> &
|
|
||||||
VariantProps<typeof alertDialogBackdropStyle> & { className?: string };
|
|
||||||
|
|
||||||
const AlertDialog = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIAccessibleAlertDialog>,
|
|
||||||
IAlertDialogProps
|
|
||||||
>(function AlertDialog({ className, size = 'md', ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIAccessibleAlertDialog
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={alertDialogStyle({ class: className })}
|
|
||||||
context={{ size }}
|
|
||||||
pointerEvents="box-none"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const AlertDialogContent = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIAccessibleAlertDialog.Content>,
|
|
||||||
IAlertDialogContentProps
|
|
||||||
>(function AlertDialogContent({ className, size, ...props }, ref) {
|
|
||||||
const { size: parentSize } = useStyleContext(SCOPE);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UIAccessibleAlertDialog.Content
|
|
||||||
pointerEvents="auto"
|
|
||||||
ref={ref}
|
|
||||||
initial={{
|
|
||||||
scale: 0.9,
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
scale: 1,
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
scale: 0.9,
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 250,
|
|
||||||
opacity: {
|
|
||||||
type: 'timing',
|
|
||||||
duration: 250,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
className={alertDialogContentStyle({
|
|
||||||
parentVariants: {
|
|
||||||
size: parentSize,
|
|
||||||
},
|
|
||||||
size,
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const AlertDialogCloseButton = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIAccessibleAlertDialog.CloseButton>,
|
|
||||||
IAlertDialogCloseButtonProps
|
|
||||||
>(function AlertDialogCloseButton({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIAccessibleAlertDialog.CloseButton
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={alertDialogCloseButtonStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const AlertDialogHeader = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIAccessibleAlertDialog.Header>,
|
|
||||||
IAlertDialogHeaderProps
|
|
||||||
>(function AlertDialogHeader({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIAccessibleAlertDialog.Header
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={alertDialogHeaderStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const AlertDialogFooter = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIAccessibleAlertDialog.Footer>,
|
|
||||||
IAlertDialogFooterProps
|
|
||||||
>(function AlertDialogFooter({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIAccessibleAlertDialog.Footer
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={alertDialogFooterStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const AlertDialogBody = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIAccessibleAlertDialog.Body>,
|
|
||||||
IAlertDialogBodyProps
|
|
||||||
>(function AlertDialogBody({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIAccessibleAlertDialog.Body
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={alertDialogBodyStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const AlertDialogBackdrop = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIAccessibleAlertDialog.Backdrop>,
|
|
||||||
IAlertDialogBackdropProps
|
|
||||||
>(function AlertDialogBackdrop({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIAccessibleAlertDialog.Backdrop
|
|
||||||
ref={ref}
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 0.5,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 250,
|
|
||||||
opacity: {
|
|
||||||
type: 'timing',
|
|
||||||
duration: 250,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
className={alertDialogBackdropStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
AlertDialog.displayName = 'AlertDialog';
|
|
||||||
AlertDialogContent.displayName = 'AlertDialogContent';
|
|
||||||
AlertDialogCloseButton.displayName = 'AlertDialogCloseButton';
|
|
||||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
|
||||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
|
||||||
AlertDialogBody.displayName = 'AlertDialogBody';
|
|
||||||
AlertDialogBackdrop.displayName = 'AlertDialogBackdrop';
|
|
||||||
|
|
||||||
export {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogCloseButton,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogBody,
|
|
||||||
AlertDialogBackdrop,
|
|
||||||
};
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import React from 'react';
|
|
||||||
import { createButton } from '@gluestack-ui/core/button/creator';
|
|
||||||
import {
|
|
||||||
tva,
|
|
||||||
withStyleContext,
|
|
||||||
useStyleContext,
|
|
||||||
type VariantProps,
|
|
||||||
} from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import { cssInterop } from 'nativewind';
|
|
||||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native';
|
|
||||||
import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator';
|
|
||||||
|
|
||||||
const SCOPE = 'BUTTON';
|
|
||||||
|
|
||||||
const Root = withStyleContext(Pressable, SCOPE);
|
|
||||||
|
|
||||||
const UIButton = createButton({
|
|
||||||
Root: Root,
|
|
||||||
Text,
|
|
||||||
Group: View,
|
|
||||||
Spinner: ActivityIndicator,
|
|
||||||
Icon: UIIcon,
|
|
||||||
});
|
|
||||||
|
|
||||||
cssInterop(PrimitiveIcon, {
|
|
||||||
className: {
|
|
||||||
target: 'style',
|
|
||||||
nativeStyleToProp: {
|
|
||||||
height: true,
|
|
||||||
width: true,
|
|
||||||
fill: true,
|
|
||||||
color: 'classNameColor',
|
|
||||||
stroke: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttonStyle = tva({
|
|
||||||
base: 'group/button rounded bg-primary-500 flex-row items-center justify-center data-[focus-visible=true]:web:outline-none data-[focus-visible=true]:web:ring-2 data-[disabled=true]:opacity-40 gap-2',
|
|
||||||
variants: {
|
|
||||||
action: {
|
|
||||||
primary:
|
|
||||||
'bg-primary-500 data-[hover=true]:bg-primary-600 data-[active=true]:bg-primary-700 border-primary-300 data-[hover=true]:border-primary-400 data-[active=true]:border-primary-500 data-[focus-visible=true]:web:ring-indicator-info',
|
|
||||||
secondary:
|
|
||||||
'bg-secondary-500 border-secondary-300 data-[hover=true]:bg-secondary-600 data-[hover=true]:border-secondary-400 data-[active=true]:bg-secondary-700 data-[active=true]:border-secondary-700 data-[focus-visible=true]:web:ring-indicator-info',
|
|
||||||
positive:
|
|
||||||
'bg-success-500 border-success-300 data-[hover=true]:bg-success-600 data-[hover=true]:border-success-400 data-[active=true]:bg-success-700 data-[active=true]:border-success-500 data-[focus-visible=true]:web:ring-indicator-info',
|
|
||||||
negative:
|
|
||||||
'bg-error-500 border-error-300 data-[hover=true]:bg-error-600 data-[hover=true]:border-error-400 data-[active=true]:bg-error-700 data-[active=true]:border-error-500 data-[focus-visible=true]:web:ring-indicator-info',
|
|
||||||
default:
|
|
||||||
'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
|
|
||||||
},
|
|
||||||
variant: {
|
|
||||||
link: 'px-0',
|
|
||||||
outline:
|
|
||||||
'bg-transparent border data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
|
|
||||||
solid: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
size: {
|
|
||||||
xs: 'px-3.5 h-8',
|
|
||||||
sm: 'px-4 h-9',
|
|
||||||
md: 'px-5 h-10',
|
|
||||||
lg: 'px-6 h-11',
|
|
||||||
xl: 'px-7 h-12',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
compoundVariants: [
|
|
||||||
{
|
|
||||||
action: 'primary',
|
|
||||||
variant: 'link',
|
|
||||||
class:
|
|
||||||
'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'secondary',
|
|
||||||
variant: 'link',
|
|
||||||
class:
|
|
||||||
'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'positive',
|
|
||||||
variant: 'link',
|
|
||||||
class:
|
|
||||||
'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'negative',
|
|
||||||
variant: 'link',
|
|
||||||
class:
|
|
||||||
'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'primary',
|
|
||||||
variant: 'outline',
|
|
||||||
class:
|
|
||||||
'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'secondary',
|
|
||||||
variant: 'outline',
|
|
||||||
class:
|
|
||||||
'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'positive',
|
|
||||||
variant: 'outline',
|
|
||||||
class:
|
|
||||||
'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'negative',
|
|
||||||
variant: 'outline',
|
|
||||||
class:
|
|
||||||
'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttonTextStyle = tva({
|
|
||||||
base: 'text-typography-0 font-semibold web:select-none',
|
|
||||||
parentVariants: {
|
|
||||||
action: {
|
|
||||||
primary:
|
|
||||||
'text-primary-600 data-[hover=true]:text-primary-600 data-[active=true]:text-primary-700',
|
|
||||||
secondary:
|
|
||||||
'text-typography-500 data-[hover=true]:text-typography-600 data-[active=true]:text-typography-700',
|
|
||||||
positive:
|
|
||||||
'text-success-600 data-[hover=true]:text-success-600 data-[active=true]:text-success-700',
|
|
||||||
negative:
|
|
||||||
'text-error-600 data-[hover=true]:text-error-600 data-[active=true]:text-error-700',
|
|
||||||
},
|
|
||||||
variant: {
|
|
||||||
link: 'data-[hover=true]:underline data-[active=true]:underline',
|
|
||||||
outline: '',
|
|
||||||
solid:
|
|
||||||
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
xs: 'text-xs',
|
|
||||||
sm: 'text-sm',
|
|
||||||
md: 'text-base',
|
|
||||||
lg: 'text-lg',
|
|
||||||
xl: 'text-xl',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
parentCompoundVariants: [
|
|
||||||
{
|
|
||||||
variant: 'solid',
|
|
||||||
action: 'primary',
|
|
||||||
class:
|
|
||||||
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variant: 'solid',
|
|
||||||
action: 'secondary',
|
|
||||||
class:
|
|
||||||
'text-typography-800 data-[hover=true]:text-typography-800 data-[active=true]:text-typography-800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variant: 'solid',
|
|
||||||
action: 'positive',
|
|
||||||
class:
|
|
||||||
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variant: 'solid',
|
|
||||||
action: 'negative',
|
|
||||||
class:
|
|
||||||
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variant: 'outline',
|
|
||||||
action: 'primary',
|
|
||||||
class:
|
|
||||||
'text-primary-500 data-[hover=true]:text-primary-500 data-[active=true]:text-primary-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variant: 'outline',
|
|
||||||
action: 'secondary',
|
|
||||||
class:
|
|
||||||
'text-typography-500 data-[hover=true]:text-primary-600 data-[active=true]:text-typography-700',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variant: 'outline',
|
|
||||||
action: 'positive',
|
|
||||||
class:
|
|
||||||
'text-primary-500 data-[hover=true]:text-primary-500 data-[active=true]:text-primary-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variant: 'outline',
|
|
||||||
action: 'negative',
|
|
||||||
class:
|
|
||||||
'text-primary-500 data-[hover=true]:text-primary-500 data-[active=true]:text-primary-500',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttonIconStyle = tva({
|
|
||||||
base: 'fill-none',
|
|
||||||
parentVariants: {
|
|
||||||
variant: {
|
|
||||||
link: 'data-[hover=true]:underline data-[active=true]:underline',
|
|
||||||
outline: '',
|
|
||||||
solid:
|
|
||||||
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
xs: 'h-3.5 w-3.5',
|
|
||||||
sm: 'h-4 w-4',
|
|
||||||
md: 'h-[18px] w-[18px]',
|
|
||||||
lg: 'h-[18px] w-[18px]',
|
|
||||||
xl: 'h-5 w-5',
|
|
||||||
},
|
|
||||||
action: {
|
|
||||||
primary:
|
|
||||||
'text-primary-600 data-[hover=true]:text-primary-600 data-[active=true]:text-primary-700',
|
|
||||||
secondary:
|
|
||||||
'text-typography-500 data-[hover=true]:text-typography-600 data-[active=true]:text-typography-700',
|
|
||||||
positive:
|
|
||||||
'text-success-600 data-[hover=true]:text-success-600 data-[active=true]:text-success-700',
|
|
||||||
|
|
||||||
negative:
|
|
||||||
'text-error-600 data-[hover=true]:text-error-600 data-[active=true]:text-error-700',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
parentCompoundVariants: [
|
|
||||||
{
|
|
||||||
variant: 'solid',
|
|
||||||
action: 'primary',
|
|
||||||
class:
|
|
||||||
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variant: 'solid',
|
|
||||||
action: 'secondary',
|
|
||||||
class:
|
|
||||||
'text-typography-800 data-[hover=true]:text-typography-800 data-[active=true]:text-typography-800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variant: 'solid',
|
|
||||||
action: 'positive',
|
|
||||||
class:
|
|
||||||
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
variant: 'solid',
|
|
||||||
action: 'negative',
|
|
||||||
class:
|
|
||||||
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttonGroupStyle = tva({
|
|
||||||
base: '',
|
|
||||||
variants: {
|
|
||||||
space: {
|
|
||||||
'xs': 'gap-1',
|
|
||||||
'sm': 'gap-2',
|
|
||||||
'md': 'gap-3',
|
|
||||||
'lg': 'gap-4',
|
|
||||||
'xl': 'gap-5',
|
|
||||||
'2xl': 'gap-6',
|
|
||||||
'3xl': 'gap-7',
|
|
||||||
'4xl': 'gap-8',
|
|
||||||
},
|
|
||||||
isAttached: {
|
|
||||||
true: 'gap-0',
|
|
||||||
},
|
|
||||||
flexDirection: {
|
|
||||||
'row': 'flex-row',
|
|
||||||
'column': 'flex-col',
|
|
||||||
'row-reverse': 'flex-row-reverse',
|
|
||||||
'column-reverse': 'flex-col-reverse',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type IButtonProps = Omit<
|
|
||||||
React.ComponentPropsWithoutRef<typeof UIButton>,
|
|
||||||
'context'
|
|
||||||
> &
|
|
||||||
VariantProps<typeof buttonStyle> & { className?: string };
|
|
||||||
|
|
||||||
const Button = React.forwardRef<
|
|
||||||
React.ElementRef<typeof UIButton>,
|
|
||||||
IButtonProps
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{ className, variant = 'solid', size = 'md', action = 'primary', ...props },
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<UIButton
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={buttonStyle({ variant, size, action, class: className })}
|
|
||||||
context={{ variant, size, action }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
type IButtonTextProps = React.ComponentPropsWithoutRef<typeof UIButton.Text> &
|
|
||||||
VariantProps<typeof buttonTextStyle> & { className?: string };
|
|
||||||
|
|
||||||
const ButtonText = React.forwardRef<
|
|
||||||
React.ElementRef<typeof UIButton.Text>,
|
|
||||||
IButtonTextProps
|
|
||||||
>(({ className, variant, size, action, ...props }, ref) => {
|
|
||||||
const {
|
|
||||||
variant: parentVariant,
|
|
||||||
size: parentSize,
|
|
||||||
action: parentAction,
|
|
||||||
} = useStyleContext(SCOPE);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UIButton.Text
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={buttonTextStyle({
|
|
||||||
parentVariants: {
|
|
||||||
variant: parentVariant,
|
|
||||||
size: parentSize,
|
|
||||||
action: parentAction,
|
|
||||||
},
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
action,
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ButtonSpinner = UIButton.Spinner;
|
|
||||||
|
|
||||||
type IButtonIcon = React.ComponentPropsWithoutRef<typeof UIButton.Icon> &
|
|
||||||
VariantProps<typeof buttonIconStyle> & {
|
|
||||||
className?: string | undefined;
|
|
||||||
as?: React.ElementType;
|
|
||||||
height?: number;
|
|
||||||
width?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ButtonIcon = React.forwardRef<
|
|
||||||
React.ElementRef<typeof UIButton.Icon>,
|
|
||||||
IButtonIcon
|
|
||||||
>(({ className, size, ...props }, ref) => {
|
|
||||||
const {
|
|
||||||
variant: parentVariant,
|
|
||||||
size: parentSize,
|
|
||||||
action: parentAction,
|
|
||||||
} = useStyleContext(SCOPE);
|
|
||||||
|
|
||||||
if (typeof size === 'number') {
|
|
||||||
return (
|
|
||||||
<UIButton.Icon
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={buttonIconStyle({ class: className })}
|
|
||||||
size={size}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
(props.height !== undefined || props.width !== undefined) &&
|
|
||||||
size === undefined
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<UIButton.Icon
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={buttonIconStyle({ class: className })}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<UIButton.Icon
|
|
||||||
{...props}
|
|
||||||
className={buttonIconStyle({
|
|
||||||
parentVariants: {
|
|
||||||
size: parentSize,
|
|
||||||
variant: parentVariant,
|
|
||||||
action: parentAction,
|
|
||||||
},
|
|
||||||
size,
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
type IButtonGroupProps = React.ComponentPropsWithoutRef<typeof UIButton.Group> &
|
|
||||||
VariantProps<typeof buttonGroupStyle>;
|
|
||||||
|
|
||||||
const ButtonGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof UIButton.Group>,
|
|
||||||
IButtonGroupProps
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
className,
|
|
||||||
space = 'md',
|
|
||||||
isAttached = false,
|
|
||||||
flexDirection = 'column',
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<UIButton.Group
|
|
||||||
className={buttonGroupStyle({
|
|
||||||
class: className,
|
|
||||||
space,
|
|
||||||
isAttached,
|
|
||||||
flexDirection,
|
|
||||||
})}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Button.displayName = 'Button';
|
|
||||||
ButtonText.displayName = 'ButtonText';
|
|
||||||
ButtonSpinner.displayName = 'ButtonSpinner';
|
|
||||||
ButtonIcon.displayName = 'ButtonIcon';
|
|
||||||
ButtonGroup.displayName = 'ButtonGroup';
|
|
||||||
|
|
||||||
export { Button, ButtonText, ButtonSpinner, ButtonIcon, ButtonGroup };
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import { vars } from 'nativewind';
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
light: vars({
|
|
||||||
'--color-primary-0': '179 179 179',
|
|
||||||
'--color-primary-50': '153 153 153',
|
|
||||||
'--color-primary-100': '128 128 128',
|
|
||||||
'--color-primary-200': '115 115 115',
|
|
||||||
'--color-primary-300': '102 102 102',
|
|
||||||
'--color-primary-400': '82 82 82',
|
|
||||||
'--color-primary-500': '51 51 51',
|
|
||||||
'--color-primary-600': '41 41 41',
|
|
||||||
'--color-primary-700': '31 31 31',
|
|
||||||
'--color-primary-800': '13 13 13',
|
|
||||||
'--color-primary-900': '10 10 10',
|
|
||||||
'--color-primary-950': '8 8 8',
|
|
||||||
|
|
||||||
/* Secondary */
|
|
||||||
'--color-secondary-0': '253 253 253',
|
|
||||||
'--color-secondary-50': '251 251 251',
|
|
||||||
'--color-secondary-100': '246 246 246',
|
|
||||||
'--color-secondary-200': '242 242 242',
|
|
||||||
'--color-secondary-300': '237 237 237',
|
|
||||||
'--color-secondary-400': '230 230 231',
|
|
||||||
'--color-secondary-500': '217 217 219',
|
|
||||||
'--color-secondary-600': '198 199 199',
|
|
||||||
'--color-secondary-700': '189 189 189',
|
|
||||||
'--color-secondary-800': '177 177 177',
|
|
||||||
'--color-secondary-900': '165 164 164',
|
|
||||||
'--color-secondary-950': '157 157 157',
|
|
||||||
|
|
||||||
/* Tertiary */
|
|
||||||
'--color-tertiary-0': '255 250 245',
|
|
||||||
'--color-tertiary-50': '255 242 229',
|
|
||||||
'--color-tertiary-100': '255 233 213',
|
|
||||||
'--color-tertiary-200': '254 209 170',
|
|
||||||
'--color-tertiary-300': '253 180 116',
|
|
||||||
'--color-tertiary-400': '251 157 75',
|
|
||||||
'--color-tertiary-500': '231 129 40',
|
|
||||||
'--color-tertiary-600': '215 117 31',
|
|
||||||
'--color-tertiary-700': '180 98 26',
|
|
||||||
'--color-tertiary-800': '130 73 23',
|
|
||||||
'--color-tertiary-900': '108 61 19',
|
|
||||||
'--color-tertiary-950': '84 49 18',
|
|
||||||
|
|
||||||
/* Error */
|
|
||||||
'--color-error-0': '254 233 233',
|
|
||||||
'--color-error-50': '254 226 226',
|
|
||||||
'--color-error-100': '254 202 202',
|
|
||||||
'--color-error-200': '252 165 165',
|
|
||||||
'--color-error-300': '248 113 113',
|
|
||||||
'--color-error-400': '239 68 68',
|
|
||||||
'--color-error-500': '230 53 53',
|
|
||||||
'--color-error-600': '220 38 38',
|
|
||||||
'--color-error-700': '185 28 28',
|
|
||||||
'--color-error-800': '153 27 27',
|
|
||||||
'--color-error-900': '127 29 29',
|
|
||||||
'--color-error-950': '83 19 19',
|
|
||||||
|
|
||||||
/* Success */
|
|
||||||
'--color-success-0': '228 255 244',
|
|
||||||
'--color-success-50': '202 255 232',
|
|
||||||
'--color-success-100': '162 241 192',
|
|
||||||
'--color-success-200': '132 211 162',
|
|
||||||
'--color-success-300': '102 181 132',
|
|
||||||
'--color-success-400': '72 151 102',
|
|
||||||
'--color-success-500': '52 131 82',
|
|
||||||
'--color-success-600': '42 121 72',
|
|
||||||
'--color-success-700': '32 111 62',
|
|
||||||
'--color-success-800': '22 101 52',
|
|
||||||
'--color-success-900': '20 83 45',
|
|
||||||
'--color-success-950': '27 50 36',
|
|
||||||
|
|
||||||
/* Warning */
|
|
||||||
'--color-warning-0': '255 249 245',
|
|
||||||
'--color-warning-50': '255 244 236',
|
|
||||||
'--color-warning-100': '255 231 213',
|
|
||||||
'--color-warning-200': '254 205 170',
|
|
||||||
'--color-warning-300': '253 173 116',
|
|
||||||
'--color-warning-400': '251 149 75',
|
|
||||||
'--color-warning-500': '231 120 40',
|
|
||||||
'--color-warning-600': '215 108 31',
|
|
||||||
'--color-warning-700': '180 90 26',
|
|
||||||
'--color-warning-800': '130 68 23',
|
|
||||||
'--color-warning-900': '108 56 19',
|
|
||||||
'--color-warning-950': '84 45 18',
|
|
||||||
|
|
||||||
/* Info */
|
|
||||||
'--color-info-0': '236 248 254',
|
|
||||||
'--color-info-50': '199 235 252',
|
|
||||||
'--color-info-100': '162 221 250',
|
|
||||||
'--color-info-200': '124 207 248',
|
|
||||||
'--color-info-300': '87 194 246',
|
|
||||||
'--color-info-400': '50 180 244',
|
|
||||||
'--color-info-500': '13 166 242',
|
|
||||||
'--color-info-600': '11 141 205',
|
|
||||||
'--color-info-700': '9 115 168',
|
|
||||||
'--color-info-800': '7 90 131',
|
|
||||||
'--color-info-900': '5 64 93',
|
|
||||||
'--color-info-950': '3 38 56',
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
'--color-typography-0': '254 254 255',
|
|
||||||
'--color-typography-50': '245 245 245',
|
|
||||||
'--color-typography-100': '229 229 229',
|
|
||||||
'--color-typography-200': '219 219 220',
|
|
||||||
'--color-typography-300': '212 212 212',
|
|
||||||
'--color-typography-400': '163 163 163',
|
|
||||||
'--color-typography-500': '140 140 140',
|
|
||||||
'--color-typography-600': '115 115 115',
|
|
||||||
'--color-typography-700': '82 82 82',
|
|
||||||
'--color-typography-800': '64 64 64',
|
|
||||||
'--color-typography-900': '38 38 39',
|
|
||||||
'--color-typography-950': '23 23 23',
|
|
||||||
|
|
||||||
/* Outline */
|
|
||||||
'--color-outline-0': '253 254 254',
|
|
||||||
'--color-outline-50': '243 243 243',
|
|
||||||
'--color-outline-100': '230 230 230',
|
|
||||||
'--color-outline-200': '221 220 219',
|
|
||||||
'--color-outline-300': '211 211 211',
|
|
||||||
'--color-outline-400': '165 163 163',
|
|
||||||
'--color-outline-500': '140 141 141',
|
|
||||||
'--color-outline-600': '115 116 116',
|
|
||||||
'--color-outline-700': '83 82 82',
|
|
||||||
'--color-outline-800': '65 65 65',
|
|
||||||
'--color-outline-900': '39 38 36',
|
|
||||||
'--color-outline-950': '26 23 23',
|
|
||||||
|
|
||||||
/* Background */
|
|
||||||
'--color-background-0': '255 255 255',
|
|
||||||
'--color-background-50': '246 246 246',
|
|
||||||
'--color-background-100': '242 241 241',
|
|
||||||
'--color-background-200': '220 219 219',
|
|
||||||
'--color-background-300': '213 212 212',
|
|
||||||
'--color-background-400': '162 163 163',
|
|
||||||
'--color-background-500': '142 142 142',
|
|
||||||
'--color-background-600': '116 116 116',
|
|
||||||
'--color-background-700': '83 82 82',
|
|
||||||
'--color-background-800': '65 64 64',
|
|
||||||
'--color-background-900': '39 38 37',
|
|
||||||
'--color-background-950': '18 18 18',
|
|
||||||
|
|
||||||
/* Background Special */
|
|
||||||
'--color-background-error': '254 241 241',
|
|
||||||
'--color-background-warning': '255 243 234',
|
|
||||||
'--color-background-success': '237 252 242',
|
|
||||||
'--color-background-muted': '247 248 247',
|
|
||||||
'--color-background-info': '235 248 254',
|
|
||||||
|
|
||||||
/* Focus Ring Indicator */
|
|
||||||
'--color-indicator-primary': '55 55 55',
|
|
||||||
'--color-indicator-info': '83 153 236',
|
|
||||||
'--color-indicator-error': '185 28 28',
|
|
||||||
}),
|
|
||||||
dark: vars({
|
|
||||||
'--color-primary-0': '166 166 166',
|
|
||||||
'--color-primary-50': '175 175 175',
|
|
||||||
'--color-primary-100': '186 186 186',
|
|
||||||
'--color-primary-200': '197 197 197',
|
|
||||||
'--color-primary-300': '212 212 212',
|
|
||||||
'--color-primary-400': '221 221 221',
|
|
||||||
'--color-primary-500': '230 230 230',
|
|
||||||
'--color-primary-600': '240 240 240',
|
|
||||||
'--color-primary-700': '250 250 250',
|
|
||||||
'--color-primary-800': '253 253 253',
|
|
||||||
'--color-primary-900': '254 249 249',
|
|
||||||
'--color-primary-950': '253 252 252',
|
|
||||||
|
|
||||||
/* Secondary */
|
|
||||||
'--color-secondary-0': '20 20 20',
|
|
||||||
'--color-secondary-50': '23 23 23',
|
|
||||||
'--color-secondary-100': '31 31 31',
|
|
||||||
'--color-secondary-200': '39 39 39',
|
|
||||||
'--color-secondary-300': '44 44 44',
|
|
||||||
'--color-secondary-400': '56 57 57',
|
|
||||||
'--color-secondary-500': '63 64 64',
|
|
||||||
'--color-secondary-600': '86 86 86',
|
|
||||||
'--color-secondary-700': '110 110 110',
|
|
||||||
'--color-secondary-800': '135 135 135',
|
|
||||||
'--color-secondary-900': '150 150 150',
|
|
||||||
'--color-secondary-950': '164 164 164',
|
|
||||||
|
|
||||||
/* Tertiary */
|
|
||||||
'--color-tertiary-0': '84 49 18',
|
|
||||||
'--color-tertiary-50': '108 61 19',
|
|
||||||
'--color-tertiary-100': '130 73 23',
|
|
||||||
'--color-tertiary-200': '180 98 26',
|
|
||||||
'--color-tertiary-300': '215 117 31',
|
|
||||||
'--color-tertiary-400': '231 129 40',
|
|
||||||
'--color-tertiary-500': '251 157 75',
|
|
||||||
'--color-tertiary-600': '253 180 116',
|
|
||||||
'--color-tertiary-700': '254 209 170',
|
|
||||||
'--color-tertiary-800': '255 233 213',
|
|
||||||
'--color-tertiary-900': '255 242 229',
|
|
||||||
'--color-tertiary-950': '255 250 245',
|
|
||||||
|
|
||||||
/* Error */
|
|
||||||
'--color-error-0': '83 19 19',
|
|
||||||
'--color-error-50': '127 29 29',
|
|
||||||
'--color-error-100': '153 27 27',
|
|
||||||
'--color-error-200': '185 28 28',
|
|
||||||
'--color-error-300': '220 38 38',
|
|
||||||
'--color-error-400': '230 53 53',
|
|
||||||
'--color-error-500': '239 68 68',
|
|
||||||
'--color-error-600': '249 97 96',
|
|
||||||
'--color-error-700': '229 91 90',
|
|
||||||
'--color-error-800': '254 202 202',
|
|
||||||
'--color-error-900': '254 226 226',
|
|
||||||
'--color-error-950': '254 233 233',
|
|
||||||
|
|
||||||
/* Success */
|
|
||||||
'--color-success-0': '27 50 36',
|
|
||||||
'--color-success-50': '20 83 45',
|
|
||||||
'--color-success-100': '22 101 52',
|
|
||||||
'--color-success-200': '32 111 62',
|
|
||||||
'--color-success-300': '42 121 72',
|
|
||||||
'--color-success-400': '52 131 82',
|
|
||||||
'--color-success-500': '72 151 102',
|
|
||||||
'--color-success-600': '102 181 132',
|
|
||||||
'--color-success-700': '132 211 162',
|
|
||||||
'--color-success-800': '162 241 192',
|
|
||||||
'--color-success-900': '202 255 232',
|
|
||||||
'--color-success-950': '228 255 244',
|
|
||||||
|
|
||||||
/* Warning */
|
|
||||||
'--color-warning-0': '84 45 18',
|
|
||||||
'--color-warning-50': '108 56 19',
|
|
||||||
'--color-warning-100': '130 68 23',
|
|
||||||
'--color-warning-200': '180 90 26',
|
|
||||||
'--color-warning-300': '215 108 31',
|
|
||||||
'--color-warning-400': '231 120 40',
|
|
||||||
'--color-warning-500': '251 149 75',
|
|
||||||
'--color-warning-600': '253 173 116',
|
|
||||||
'--color-warning-700': '254 205 170',
|
|
||||||
'--color-warning-800': '255 231 213',
|
|
||||||
'--color-warning-900': '255 244 237',
|
|
||||||
'--color-warning-950': '255 249 245',
|
|
||||||
|
|
||||||
/* Info */
|
|
||||||
'--color-info-0': '3 38 56',
|
|
||||||
'--color-info-50': '5 64 93',
|
|
||||||
'--color-info-100': '7 90 131',
|
|
||||||
'--color-info-200': '9 115 168',
|
|
||||||
'--color-info-300': '11 141 205',
|
|
||||||
'--color-info-400': '13 166 242',
|
|
||||||
'--color-info-500': '50 180 244',
|
|
||||||
'--color-info-600': '87 194 246',
|
|
||||||
'--color-info-700': '124 207 248',
|
|
||||||
'--color-info-800': '162 221 250',
|
|
||||||
'--color-info-900': '199 235 252',
|
|
||||||
'--color-info-950': '236 248 254',
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
'--color-typography-0': '23 23 23',
|
|
||||||
'--color-typography-50': '38 38 39',
|
|
||||||
'--color-typography-100': '64 64 64',
|
|
||||||
'--color-typography-200': '82 82 82',
|
|
||||||
'--color-typography-300': '115 115 115',
|
|
||||||
'--color-typography-400': '140 140 140',
|
|
||||||
'--color-typography-500': '163 163 163',
|
|
||||||
'--color-typography-600': '212 212 212',
|
|
||||||
'--color-typography-700': '219 219 220',
|
|
||||||
'--color-typography-800': '229 229 229',
|
|
||||||
'--color-typography-900': '245 245 245',
|
|
||||||
'--color-typography-950': '254 254 255',
|
|
||||||
|
|
||||||
/* Outline */
|
|
||||||
'--color-outline-0': '26 23 23',
|
|
||||||
'--color-outline-50': '39 38 36',
|
|
||||||
'--color-outline-100': '65 65 65',
|
|
||||||
'--color-outline-200': '83 82 82',
|
|
||||||
'--color-outline-300': '115 116 116',
|
|
||||||
'--color-outline-400': '140 141 141',
|
|
||||||
'--color-outline-500': '165 163 163',
|
|
||||||
'--color-outline-600': '211 211 211',
|
|
||||||
'--color-outline-700': '221 220 219',
|
|
||||||
'--color-outline-800': '230 230 230',
|
|
||||||
'--color-outline-900': '243 243 243',
|
|
||||||
'--color-outline-950': '253 254 254',
|
|
||||||
|
|
||||||
/* Background */
|
|
||||||
'--color-background-0': '18 18 18',
|
|
||||||
'--color-background-50': '39 38 37',
|
|
||||||
'--color-background-100': '65 64 64',
|
|
||||||
'--color-background-200': '83 82 82',
|
|
||||||
'--color-background-300': '116 116 116',
|
|
||||||
'--color-background-400': '142 142 142',
|
|
||||||
'--color-background-500': '162 163 163',
|
|
||||||
'--color-background-600': '213 212 212',
|
|
||||||
'--color-background-700': '229 228 228',
|
|
||||||
'--color-background-800': '242 241 241',
|
|
||||||
'--color-background-900': '246 246 246',
|
|
||||||
'--color-background-950': '255 255 255',
|
|
||||||
|
|
||||||
/* Background Special */
|
|
||||||
'--color-background-error': '66 43 43',
|
|
||||||
'--color-background-warning': '65 47 35',
|
|
||||||
'--color-background-success': '28 43 33',
|
|
||||||
'--color-background-muted': '51 51 51',
|
|
||||||
'--color-background-info': '26 40 46',
|
|
||||||
|
|
||||||
/* Focus Ring Indicator */
|
|
||||||
'--color-indicator-primary': '247 247 247',
|
|
||||||
'--color-indicator-info': '161 199 245',
|
|
||||||
'--color-indicator-error': '232 70 69',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
// This is a Next.js 15 compatible version of the GluestackUIProvider
|
|
||||||
'use client';
|
|
||||||
import React, { useEffect, useLayoutEffect } from 'react';
|
|
||||||
import { config } from './config';
|
|
||||||
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
|
|
||||||
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
|
|
||||||
import { setFlushStyles } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import { script } from './script';
|
|
||||||
|
|
||||||
const variableStyleTagId = 'nativewind-style';
|
|
||||||
const createStyle = (styleTagId: string) => {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = styleTagId;
|
|
||||||
style.appendChild(document.createTextNode(''));
|
|
||||||
return style;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSafeLayoutEffect =
|
|
||||||
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
||||||
|
|
||||||
export function GluestackUIProvider({
|
|
||||||
mode = 'light',
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
mode?: 'light' | 'dark' | 'system';
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
let cssVariablesWithMode = ``;
|
|
||||||
Object.keys(config).forEach((configKey) => {
|
|
||||||
cssVariablesWithMode +=
|
|
||||||
configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`;
|
|
||||||
const cssVariables = Object.keys(
|
|
||||||
config[configKey as keyof typeof config]
|
|
||||||
).reduce((acc: string, curr: string) => {
|
|
||||||
acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `;
|
|
||||||
return acc;
|
|
||||||
}, '');
|
|
||||||
cssVariablesWithMode += `${cssVariables} \n}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFlushStyles(cssVariablesWithMode);
|
|
||||||
|
|
||||||
const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => {
|
|
||||||
script(e.matches ? 'dark' : 'light');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useSafeLayoutEffect(() => {
|
|
||||||
if (mode !== 'system') {
|
|
||||||
const documentElement = document.documentElement;
|
|
||||||
if (documentElement) {
|
|
||||||
documentElement.classList.add(mode);
|
|
||||||
documentElement.classList.remove(mode === 'light' ? 'dark' : 'light');
|
|
||||||
documentElement.style.colorScheme = mode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
useSafeLayoutEffect(() => {
|
|
||||||
if (mode !== 'system') return;
|
|
||||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
|
|
||||||
media.addListener(handleMediaQuery);
|
|
||||||
|
|
||||||
return () => media.removeListener(handleMediaQuery);
|
|
||||||
}, [handleMediaQuery]);
|
|
||||||
|
|
||||||
useSafeLayoutEffect(() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const documentElement = document.documentElement;
|
|
||||||
if (documentElement) {
|
|
||||||
const head = documentElement.querySelector('head');
|
|
||||||
let style = head?.querySelector(`[id='${variableStyleTagId}']`);
|
|
||||||
if (!style) {
|
|
||||||
style = createStyle(variableStyleTagId);
|
|
||||||
style.innerHTML = cssVariablesWithMode;
|
|
||||||
if (head) head.appendChild(style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OverlayProvider>
|
|
||||||
<ToastProvider>{props.children}</ToastProvider>
|
|
||||||
</OverlayProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { config } from './config';
|
|
||||||
import { View, ViewProps } from 'react-native';
|
|
||||||
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
|
|
||||||
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
|
|
||||||
import { useColorScheme } from 'nativewind';
|
|
||||||
|
|
||||||
export type ModeType = 'light' | 'dark' | 'system';
|
|
||||||
|
|
||||||
export function GluestackUIProvider({
|
|
||||||
mode = 'light',
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
mode?: ModeType;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
style?: ViewProps['style'];
|
|
||||||
}) {
|
|
||||||
const { colorScheme, setColorScheme } = useColorScheme();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setColorScheme(mode);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
config[colorScheme!],
|
|
||||||
{ flex: 1, height: '100%', width: '100%' },
|
|
||||||
props.style,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<OverlayProvider>
|
|
||||||
<ToastProvider>{props.children}</ToastProvider>
|
|
||||||
</OverlayProvider>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import React, { useEffect, useLayoutEffect } from 'react';
|
|
||||||
import { config } from './config';
|
|
||||||
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
|
|
||||||
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
|
|
||||||
import { setFlushStyles } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import { script } from './script';
|
|
||||||
|
|
||||||
export type ModeType = 'light' | 'dark' | 'system';
|
|
||||||
|
|
||||||
const variableStyleTagId = 'nativewind-style';
|
|
||||||
const createStyle = (styleTagId: string) => {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = styleTagId;
|
|
||||||
style.appendChild(document.createTextNode(''));
|
|
||||||
return style;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSafeLayoutEffect =
|
|
||||||
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
||||||
|
|
||||||
export function GluestackUIProvider({
|
|
||||||
mode = 'light',
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
mode?: ModeType;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
let cssVariablesWithMode = ``;
|
|
||||||
Object.keys(config).forEach((configKey) => {
|
|
||||||
cssVariablesWithMode +=
|
|
||||||
configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`;
|
|
||||||
const cssVariables = Object.keys(
|
|
||||||
config[configKey as keyof typeof config]
|
|
||||||
).reduce((acc: string, curr: string) => {
|
|
||||||
acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `;
|
|
||||||
return acc;
|
|
||||||
}, '');
|
|
||||||
cssVariablesWithMode += `${cssVariables} \n}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFlushStyles(cssVariablesWithMode);
|
|
||||||
|
|
||||||
const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => {
|
|
||||||
script(e.matches ? 'dark' : 'light');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useSafeLayoutEffect(() => {
|
|
||||||
if (mode !== 'system') {
|
|
||||||
const documentElement = document.documentElement;
|
|
||||||
if (documentElement) {
|
|
||||||
documentElement.classList.add(mode);
|
|
||||||
documentElement.classList.remove(mode === 'light' ? 'dark' : 'light');
|
|
||||||
documentElement.style.colorScheme = mode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
useSafeLayoutEffect(() => {
|
|
||||||
if (mode !== 'system') return;
|
|
||||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
|
|
||||||
media.addListener(handleMediaQuery);
|
|
||||||
|
|
||||||
return () => media.removeListener(handleMediaQuery);
|
|
||||||
}, [handleMediaQuery]);
|
|
||||||
|
|
||||||
useSafeLayoutEffect(() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const documentElement = document.documentElement;
|
|
||||||
if (documentElement) {
|
|
||||||
const head = documentElement.querySelector('head');
|
|
||||||
let style = head?.querySelector(`[id='${variableStyleTagId}']`);
|
|
||||||
if (!style) {
|
|
||||||
style = createStyle(variableStyleTagId);
|
|
||||||
style.innerHTML = cssVariablesWithMode;
|
|
||||||
if (head) head.appendChild(style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<script
|
|
||||||
suppressHydrationWarning
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `(${script.toString()})('${mode}')`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<OverlayProvider>
|
|
||||||
<ToastProvider>{props.children}</ToastProvider>
|
|
||||||
</OverlayProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export const script = (mode: string) => {
|
|
||||||
const documentElement = document.documentElement;
|
|
||||||
|
|
||||||
function getSystemColorMode() {
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
? 'dark'
|
|
||||||
: 'light';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const isSystem = mode === 'system';
|
|
||||||
const theme = isSystem ? getSystemColorMode() : mode;
|
|
||||||
documentElement.classList.remove(theme === 'light' ? 'dark' : 'light');
|
|
||||||
documentElement.classList.add(theme);
|
|
||||||
documentElement.style.colorScheme = theme;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import React from 'react';
|
|
||||||
import { createModal } from '@gluestack-ui/core/modal/creator';
|
|
||||||
import { Pressable, View, ScrollView, ViewStyle } from 'react-native';
|
|
||||||
import {
|
|
||||||
Motion,
|
|
||||||
AnimatePresence,
|
|
||||||
createMotionAnimatedComponent,
|
|
||||||
MotionComponentProps,
|
|
||||||
} from '@legendapp/motion';
|
|
||||||
import { tva } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import {
|
|
||||||
withStyleContext,
|
|
||||||
useStyleContext,
|
|
||||||
} from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import { cssInterop } from 'nativewind';
|
|
||||||
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
|
|
||||||
type IAnimatedPressableProps = React.ComponentProps<typeof Pressable> &
|
|
||||||
MotionComponentProps<typeof Pressable, ViewStyle, unknown, unknown, unknown>;
|
|
||||||
|
|
||||||
const AnimatedPressable = createMotionAnimatedComponent(
|
|
||||||
Pressable
|
|
||||||
) as React.ComponentType<IAnimatedPressableProps>;
|
|
||||||
const SCOPE = 'MODAL';
|
|
||||||
|
|
||||||
type IMotionViewProps = React.ComponentProps<typeof View> &
|
|
||||||
MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>;
|
|
||||||
|
|
||||||
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>;
|
|
||||||
|
|
||||||
const UIModal = createModal({
|
|
||||||
Root: withStyleContext(View, SCOPE),
|
|
||||||
Backdrop: AnimatedPressable,
|
|
||||||
Content: MotionView,
|
|
||||||
Body: ScrollView,
|
|
||||||
CloseButton: Pressable,
|
|
||||||
Footer: View,
|
|
||||||
Header: View,
|
|
||||||
AnimatePresence: AnimatePresence,
|
|
||||||
});
|
|
||||||
|
|
||||||
cssInterop(AnimatedPressable, { className: 'style' });
|
|
||||||
cssInterop(MotionView, { className: 'style' });
|
|
||||||
|
|
||||||
const modalStyle = tva({
|
|
||||||
base: 'group/modal w-full h-full justify-center items-center web:pointer-events-none',
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
xs: '',
|
|
||||||
sm: '',
|
|
||||||
md: '',
|
|
||||||
lg: '',
|
|
||||||
full: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const modalBackdropStyle = tva({
|
|
||||||
base: 'absolute left-0 top-0 right-0 bottom-0 bg-background-dark web:cursor-default',
|
|
||||||
});
|
|
||||||
|
|
||||||
const modalContentStyle = tva({
|
|
||||||
base: 'bg-background-0 rounded-md overflow-hidden border border-outline-100 shadow-hard-2 p-6',
|
|
||||||
parentVariants: {
|
|
||||||
size: {
|
|
||||||
xs: 'w-[60%] max-w-[360px]',
|
|
||||||
sm: 'w-[70%] max-w-[420px]',
|
|
||||||
md: 'w-[80%] max-w-[510px]',
|
|
||||||
lg: 'w-[90%] max-w-[640px]',
|
|
||||||
full: 'w-full',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const modalBodyStyle = tva({
|
|
||||||
base: 'mt-2 mb-6',
|
|
||||||
});
|
|
||||||
|
|
||||||
const modalCloseButtonStyle = tva({
|
|
||||||
base: 'group/modal-close-button z-10 rounded data-[focus-visible=true]:web:bg-background-100 web:outline-0 cursor-pointer',
|
|
||||||
});
|
|
||||||
|
|
||||||
const modalHeaderStyle = tva({
|
|
||||||
base: 'justify-between items-center flex-row',
|
|
||||||
});
|
|
||||||
|
|
||||||
const modalFooterStyle = tva({
|
|
||||||
base: 'flex-row justify-end items-center gap-2',
|
|
||||||
});
|
|
||||||
|
|
||||||
type IModalProps = React.ComponentProps<typeof UIModal> &
|
|
||||||
VariantProps<typeof modalStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IModalBackdropProps = React.ComponentProps<typeof UIModal.Backdrop> &
|
|
||||||
VariantProps<typeof modalBackdropStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IModalContentProps = React.ComponentProps<typeof UIModal.Content> &
|
|
||||||
VariantProps<typeof modalContentStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IModalHeaderProps = React.ComponentProps<typeof UIModal.Header> &
|
|
||||||
VariantProps<typeof modalHeaderStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IModalBodyProps = React.ComponentProps<typeof UIModal.Body> &
|
|
||||||
VariantProps<typeof modalBodyStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IModalFooterProps = React.ComponentProps<typeof UIModal.Footer> &
|
|
||||||
VariantProps<typeof modalFooterStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IModalCloseButtonProps = React.ComponentProps<typeof UIModal.CloseButton> &
|
|
||||||
VariantProps<typeof modalCloseButtonStyle> & { className?: string };
|
|
||||||
|
|
||||||
const Modal = React.forwardRef<React.ComponentRef<typeof UIModal>, IModalProps>(
|
|
||||||
({ className, size = 'md', ...props }, ref) => (
|
|
||||||
<UIModal
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
pointerEvents="box-none"
|
|
||||||
className={modalStyle({ size, class: className })}
|
|
||||||
context={{ size }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const ModalBackdrop = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIModal.Backdrop>,
|
|
||||||
IModalBackdropProps
|
|
||||||
>(function ModalBackdrop({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIModal.Backdrop
|
|
||||||
ref={ref}
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 0.5,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 250,
|
|
||||||
opacity: {
|
|
||||||
type: 'timing',
|
|
||||||
duration: 250,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
className={modalBackdropStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ModalContent = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIModal.Content>,
|
|
||||||
IModalContentProps
|
|
||||||
>(function ModalContent({ className, size, ...props }, ref) {
|
|
||||||
const { size: parentSize } = useStyleContext(SCOPE);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UIModal.Content
|
|
||||||
ref={ref}
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
scale: 0.9,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 250,
|
|
||||||
opacity: {
|
|
||||||
type: 'timing',
|
|
||||||
duration: 250,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
className={modalContentStyle({
|
|
||||||
parentVariants: {
|
|
||||||
size: parentSize,
|
|
||||||
},
|
|
||||||
size,
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
pointerEvents="auto"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ModalHeader = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIModal.Header>,
|
|
||||||
IModalHeaderProps
|
|
||||||
>(function ModalHeader({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIModal.Header
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={modalHeaderStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ModalBody = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIModal.Body>,
|
|
||||||
IModalBodyProps
|
|
||||||
>(function ModalBody({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIModal.Body
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={modalBodyStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ModalFooter = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIModal.Footer>,
|
|
||||||
IModalFooterProps
|
|
||||||
>(function ModalFooter({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIModal.Footer
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={modalFooterStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ModalCloseButton = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIModal.CloseButton>,
|
|
||||||
IModalCloseButtonProps
|
|
||||||
>(function ModalCloseButton({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIModal.CloseButton
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={modalCloseButtonStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Modal.displayName = 'Modal';
|
|
||||||
ModalBackdrop.displayName = 'ModalBackdrop';
|
|
||||||
ModalContent.displayName = 'ModalContent';
|
|
||||||
ModalHeader.displayName = 'ModalHeader';
|
|
||||||
ModalBody.displayName = 'ModalBody';
|
|
||||||
ModalFooter.displayName = 'ModalFooter';
|
|
||||||
ModalCloseButton.displayName = 'ModalCloseButton';
|
|
||||||
|
|
||||||
export {
|
|
||||||
Modal,
|
|
||||||
ModalBackdrop,
|
|
||||||
ModalContent,
|
|
||||||
ModalCloseButton,
|
|
||||||
ModalHeader,
|
|
||||||
ModalBody,
|
|
||||||
ModalFooter,
|
|
||||||
};
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import React from 'react';
|
|
||||||
import { View, Pressable, ScrollView, ViewStyle } from 'react-native';
|
|
||||||
import {
|
|
||||||
Motion,
|
|
||||||
createMotionAnimatedComponent,
|
|
||||||
AnimatePresence,
|
|
||||||
MotionComponentProps,
|
|
||||||
} from '@legendapp/motion';
|
|
||||||
import { createPopover } from '@gluestack-ui/core/popover/creator';
|
|
||||||
import { tva } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import {
|
|
||||||
withStyleContext,
|
|
||||||
useStyleContext,
|
|
||||||
} from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import { cssInterop } from 'nativewind';
|
|
||||||
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
|
|
||||||
type IAnimatedPressableProps = React.ComponentProps<typeof Pressable> &
|
|
||||||
MotionComponentProps<typeof Pressable, ViewStyle, unknown, unknown, unknown>;
|
|
||||||
|
|
||||||
const AnimatedPressable = createMotionAnimatedComponent(
|
|
||||||
Pressable
|
|
||||||
) as React.ComponentType<IAnimatedPressableProps>;
|
|
||||||
|
|
||||||
const SCOPE = 'POPOVER';
|
|
||||||
|
|
||||||
type IMotionViewProps = React.ComponentProps<typeof View> &
|
|
||||||
MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>;
|
|
||||||
|
|
||||||
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>;
|
|
||||||
|
|
||||||
const UIPopover = createPopover({
|
|
||||||
Root: withStyleContext(View, SCOPE),
|
|
||||||
Arrow: MotionView,
|
|
||||||
Backdrop: AnimatedPressable,
|
|
||||||
Body: ScrollView,
|
|
||||||
CloseButton: Pressable,
|
|
||||||
Content: MotionView,
|
|
||||||
Footer: View,
|
|
||||||
Header: View,
|
|
||||||
AnimatePresence: AnimatePresence,
|
|
||||||
});
|
|
||||||
|
|
||||||
cssInterop(MotionView, { className: 'style' });
|
|
||||||
cssInterop(AnimatedPressable, { className: 'style' });
|
|
||||||
|
|
||||||
const popoverStyle = tva({
|
|
||||||
base: 'group/popover w-full h-full justify-center items-center web:pointer-events-none',
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
xs: '',
|
|
||||||
sm: '',
|
|
||||||
md: '',
|
|
||||||
lg: '',
|
|
||||||
full: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const popoverArrowStyle = tva({
|
|
||||||
base: 'bg-background-0 z-[1] border absolute overflow-hidden h-3.5 w-3.5 border-outline-100',
|
|
||||||
variants: {
|
|
||||||
placement: {
|
|
||||||
'top left':
|
|
||||||
'data-[flip=false]:border-t-0 data-[flip=false]:border-l-0 data-[flip=true]:border-b-0 data-[flip=true]:border-r-0',
|
|
||||||
'top':
|
|
||||||
'data-[flip=false]:border-t-0 data-[flip=false]:border-l-0 data-[flip=true]:border-b-0 data-[flip=true]:border-r-0',
|
|
||||||
'top right':
|
|
||||||
'data-[flip=false]:border-t-0 data-[flip=false]:border-l-0 data-[flip=true]:border-b-0 data-[flip=true]:border-r-0',
|
|
||||||
'bottom':
|
|
||||||
'data-[flip=false]:border-b-0 data-[flip=false]:border-r-0 data-[flip=true]:border-t-0 data-[flip=true]:border-l-0',
|
|
||||||
'bottom left':
|
|
||||||
'data-[flip=false]:border-b-0 data-[flip=false]:border-r-0 data-[flip=true]:border-t-0 data-[flip=true]:border-l-0',
|
|
||||||
'bottom right':
|
|
||||||
'data-[flip=false]:border-b-0 data-[flip=false]:border-r-0 data-[flip=true]:border-t-0 data-[flip=true]:border-l-0',
|
|
||||||
'left':
|
|
||||||
'data-[flip=false]:border-l-0 data-[flip=false]:border-b-0 data-[flip=true]:border-r-0 data-[flip=true]:border-t-0',
|
|
||||||
'left top':
|
|
||||||
'data-[flip=false]:border-l-0 data-[flip=false]:border-b-0 data-[flip=true]:border-r-0 data-[flip=true]:border-t-0',
|
|
||||||
'left bottom':
|
|
||||||
'data-[flip=false]:border-l-0 data-[flip=false]:border-b-0 data-[flip=true]:border-r-0 data-[flip=true]:border-t-0',
|
|
||||||
'right':
|
|
||||||
'data-[flip=false]:border-r-0 data-[flip=false]:border-t-0 data-[flip=true]:border-l-0 data-[flip=true]:border-b-0',
|
|
||||||
'right top':
|
|
||||||
'data-[flip=false]:border-r-0 data-[flip=false]:border-t-0 data-[flip=true]:border-l-0 data-[flip=true]:border-b-0',
|
|
||||||
'right bottom':
|
|
||||||
'data-[flip=false]:border-r-0 data-[flip=false]:border-t-0 data-[flip=true]:border-l-0 data-[flip=true]:border-b-0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const popoverBackdropStyle = tva({
|
|
||||||
base: 'absolute left-0 top-0 right-0 bottom-0 web:cursor-default',
|
|
||||||
});
|
|
||||||
|
|
||||||
const popoverCloseButtonStyle = tva({
|
|
||||||
base: 'group/popover-close-button z-[1] rounded-sm data-[focus-visible=true]:web:bg-background-100 web:outline-0 web:cursor-pointer',
|
|
||||||
});
|
|
||||||
|
|
||||||
const popoverContentStyle = tva({
|
|
||||||
base: 'bg-background-0 rounded-lg overflow-hidden border border-outline-100 w-full',
|
|
||||||
parentVariants: {
|
|
||||||
size: {
|
|
||||||
xs: 'max-w-[360px] p-3.5',
|
|
||||||
sm: 'max-w-[420px] p-4',
|
|
||||||
md: 'max-w-[510px] p-[18px]',
|
|
||||||
lg: 'max-w-[640px] p-5',
|
|
||||||
full: 'p-6',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const popoverHeaderStyle = tva({
|
|
||||||
base: 'flex-row justify-between items-center',
|
|
||||||
});
|
|
||||||
|
|
||||||
const popoverBodyStyle = tva({
|
|
||||||
base: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const popoverFooterStyle = tva({
|
|
||||||
base: 'flex-row justify-between items-center',
|
|
||||||
});
|
|
||||||
|
|
||||||
type IPopoverProps = React.ComponentProps<typeof UIPopover> &
|
|
||||||
VariantProps<typeof popoverStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IPopoverArrowProps = React.ComponentProps<typeof UIPopover.Arrow> &
|
|
||||||
VariantProps<typeof popoverArrowStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IPopoverContentProps = React.ComponentProps<typeof UIPopover.Content> &
|
|
||||||
VariantProps<typeof popoverContentStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IPopoverHeaderProps = React.ComponentProps<typeof UIPopover.Header> &
|
|
||||||
VariantProps<typeof popoverHeaderStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IPopoverFooterProps = React.ComponentProps<typeof UIPopover.Footer> &
|
|
||||||
VariantProps<typeof popoverFooterStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IPopoverBodyProps = React.ComponentProps<typeof UIPopover.Body> &
|
|
||||||
VariantProps<typeof popoverBodyStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IPopoverBackdropProps = React.ComponentProps<typeof UIPopover.Backdrop> &
|
|
||||||
VariantProps<typeof popoverBackdropStyle> & { className?: string };
|
|
||||||
|
|
||||||
type IPopoverCloseButtonProps = React.ComponentProps<
|
|
||||||
typeof UIPopover.CloseButton
|
|
||||||
> &
|
|
||||||
VariantProps<typeof popoverCloseButtonStyle> & { className?: string };
|
|
||||||
|
|
||||||
const Popover = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIPopover>,
|
|
||||||
IPopoverProps
|
|
||||||
>(function Popover(
|
|
||||||
{ className, size = 'md', placement = 'bottom', ...props },
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<UIPopover
|
|
||||||
ref={ref}
|
|
||||||
placement={placement}
|
|
||||||
{...props}
|
|
||||||
className={popoverStyle({ size, class: className })}
|
|
||||||
context={{ size, placement }}
|
|
||||||
pointerEvents="box-none"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const PopoverContent = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIPopover.Content>,
|
|
||||||
IPopoverContentProps
|
|
||||||
>(function PopoverContent({ className, size, ...props }, ref) {
|
|
||||||
const { size: parentSize } = useStyleContext(SCOPE);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UIPopover.Content
|
|
||||||
ref={ref}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 250,
|
|
||||||
mass: 0.9,
|
|
||||||
opacity: {
|
|
||||||
type: 'timing',
|
|
||||||
duration: 50,
|
|
||||||
delay: 50,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
className={popoverContentStyle({
|
|
||||||
parentVariants: {
|
|
||||||
size: parentSize,
|
|
||||||
},
|
|
||||||
size,
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
pointerEvents="auto"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const PopoverArrow = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIPopover.Arrow>,
|
|
||||||
IPopoverArrowProps
|
|
||||||
>(function PopoverArrow({ className, ...props }, ref) {
|
|
||||||
const { placement } = useStyleContext(SCOPE);
|
|
||||||
return (
|
|
||||||
<UIPopover.Arrow
|
|
||||||
ref={ref}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 250,
|
|
||||||
mass: 0.9,
|
|
||||||
opacity: {
|
|
||||||
type: 'timing',
|
|
||||||
duration: 50,
|
|
||||||
delay: 50,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
className={popoverArrowStyle({
|
|
||||||
class: className,
|
|
||||||
placement,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const PopoverBackdrop = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIPopover.Backdrop>,
|
|
||||||
IPopoverBackdropProps
|
|
||||||
>(function PopoverBackdrop({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIPopover.Backdrop
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 0.1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 450,
|
|
||||||
mass: 0.9,
|
|
||||||
opacity: {
|
|
||||||
type: 'timing',
|
|
||||||
duration: 50,
|
|
||||||
delay: 50,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
className={popoverBackdropStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const PopoverBody = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIPopover.Body>,
|
|
||||||
IPopoverBodyProps
|
|
||||||
>(function PopoverBody({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIPopover.Body
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={popoverBodyStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const PopoverCloseButton = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIPopover.CloseButton>,
|
|
||||||
IPopoverCloseButtonProps
|
|
||||||
>(function PopoverCloseButton({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIPopover.CloseButton
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={popoverCloseButtonStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const PopoverFooter = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIPopover.Footer>,
|
|
||||||
IPopoverFooterProps
|
|
||||||
>(function PopoverFooter({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIPopover.Footer
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={popoverFooterStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const PopoverHeader = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UIPopover.Header>,
|
|
||||||
IPopoverHeaderProps
|
|
||||||
>(function PopoverHeader({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UIPopover.Header
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={popoverHeaderStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Popover.displayName = 'Popover';
|
|
||||||
PopoverArrow.displayName = 'PopoverArrow';
|
|
||||||
PopoverBackdrop.displayName = 'PopoverBackdrop';
|
|
||||||
PopoverContent.displayName = 'PopoverContent';
|
|
||||||
PopoverHeader.displayName = 'PopoverHeader';
|
|
||||||
PopoverFooter.displayName = 'PopoverFooter';
|
|
||||||
PopoverBody.displayName = 'PopoverBody';
|
|
||||||
PopoverCloseButton.displayName = 'PopoverCloseButton';
|
|
||||||
|
|
||||||
export {
|
|
||||||
Popover,
|
|
||||||
PopoverBackdrop,
|
|
||||||
PopoverArrow,
|
|
||||||
PopoverCloseButton,
|
|
||||||
PopoverFooter,
|
|
||||||
PopoverHeader,
|
|
||||||
PopoverBody,
|
|
||||||
PopoverContent,
|
|
||||||
};
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import React from 'react';
|
|
||||||
import { createTooltip } from '@gluestack-ui/core/tooltip/creator';
|
|
||||||
import { View, Text, ViewStyle } from 'react-native';
|
|
||||||
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import { tva } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import { withStyleContext } from '@gluestack-ui/utils/nativewind-utils';
|
|
||||||
import {
|
|
||||||
Motion,
|
|
||||||
AnimatePresence,
|
|
||||||
MotionComponentProps,
|
|
||||||
} from '@legendapp/motion';
|
|
||||||
import { cssInterop } from 'nativewind';
|
|
||||||
|
|
||||||
type IMotionViewProps = React.ComponentProps<typeof View> &
|
|
||||||
MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>;
|
|
||||||
|
|
||||||
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>;
|
|
||||||
|
|
||||||
export const UITooltip = createTooltip({
|
|
||||||
Root: withStyleContext(View),
|
|
||||||
Content: MotionView,
|
|
||||||
Text: Text,
|
|
||||||
AnimatePresence: AnimatePresence,
|
|
||||||
});
|
|
||||||
|
|
||||||
cssInterop(MotionView, { className: 'style' });
|
|
||||||
|
|
||||||
const tooltipStyle = tva({
|
|
||||||
base: 'w-full h-full web:pointer-events-none',
|
|
||||||
});
|
|
||||||
|
|
||||||
const tooltipContentStyle = tva({
|
|
||||||
base: 'py-1 px-3 rounded-sm bg-background-900 web:pointer-events-auto',
|
|
||||||
});
|
|
||||||
|
|
||||||
const tooltipTextStyle = tva({
|
|
||||||
base: 'font-normal tracking-normal web:select-none text-xs text-typography-50',
|
|
||||||
|
|
||||||
variants: {
|
|
||||||
isTruncated: {
|
|
||||||
true: 'line-clamp-1 truncate',
|
|
||||||
},
|
|
||||||
bold: {
|
|
||||||
true: 'font-bold',
|
|
||||||
},
|
|
||||||
underline: {
|
|
||||||
true: 'underline',
|
|
||||||
},
|
|
||||||
strikeThrough: {
|
|
||||||
true: 'line-through',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
'2xs': 'text-2xs',
|
|
||||||
'xs': 'text-xs',
|
|
||||||
'sm': 'text-sm',
|
|
||||||
'md': 'text-base',
|
|
||||||
'lg': 'text-lg',
|
|
||||||
'xl': 'text-xl',
|
|
||||||
'2xl': 'text-2xl',
|
|
||||||
'3xl': 'text-3xl',
|
|
||||||
'4xl': 'text-4xl',
|
|
||||||
'5xl': 'text-5xl',
|
|
||||||
'6xl': 'text-6xl',
|
|
||||||
},
|
|
||||||
sub: {
|
|
||||||
true: 'text-xs',
|
|
||||||
},
|
|
||||||
italic: {
|
|
||||||
true: 'italic',
|
|
||||||
},
|
|
||||||
highlight: {
|
|
||||||
true: 'bg-yellow-500',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type ITooltipProps = React.ComponentProps<typeof UITooltip> &
|
|
||||||
VariantProps<typeof tooltipStyle> & { className?: string };
|
|
||||||
type ITooltipContentProps = React.ComponentProps<typeof UITooltip.Content> &
|
|
||||||
VariantProps<typeof tooltipContentStyle> & { className?: string };
|
|
||||||
type ITooltipTextProps = React.ComponentProps<typeof UITooltip.Text> &
|
|
||||||
VariantProps<typeof tooltipTextStyle> & { className?: string };
|
|
||||||
|
|
||||||
const Tooltip = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UITooltip>,
|
|
||||||
ITooltipProps
|
|
||||||
>(function Tooltip({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UITooltip
|
|
||||||
ref={ref}
|
|
||||||
className={tooltipStyle({ class: className })}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UITooltip.Content>,
|
|
||||||
ITooltipContentProps & { className?: string }
|
|
||||||
>(function TooltipContent({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UITooltip.Content
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={tooltipContentStyle({
|
|
||||||
class: className,
|
|
||||||
})}
|
|
||||||
pointerEvents="auto"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const TooltipText = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof UITooltip.Text>,
|
|
||||||
ITooltipTextProps & { className?: string }
|
|
||||||
>(function TooltipText({ size, className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<UITooltip.Text
|
|
||||||
ref={ref}
|
|
||||||
className={tooltipTextStyle({ size, class: className })}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Tooltip.displayName = 'Tooltip';
|
|
||||||
TooltipContent.displayName = 'TooltipContent';
|
|
||||||
TooltipText.displayName = 'TooltipText';
|
|
||||||
|
|
||||||
export { Tooltip, TooltipContent, TooltipText };
|
|
||||||
578
components/ui/modal.tsx
Normal file
578
components/ui/modal.tsx
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { ReactNode, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
Pressable,
|
||||||
|
Modal as RNModal,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface ModalProps {
|
||||||
|
/** Whether the modal dialog is visible or not */
|
||||||
|
open?: boolean;
|
||||||
|
/** The modal dialog's title */
|
||||||
|
title?: ReactNode;
|
||||||
|
/** Whether a close (x) button is visible on top right or not */
|
||||||
|
closable?: boolean;
|
||||||
|
/** Custom close icon */
|
||||||
|
closeIcon?: ReactNode;
|
||||||
|
/** Whether to close the modal dialog when the mask (area outside the modal) is clicked */
|
||||||
|
maskClosable?: boolean;
|
||||||
|
/** Centered Modal */
|
||||||
|
centered?: boolean;
|
||||||
|
/** Width of the modal dialog */
|
||||||
|
width?: number | string;
|
||||||
|
/** Whether to apply loading visual effect for OK button or not */
|
||||||
|
confirmLoading?: boolean;
|
||||||
|
/** Text of the OK button */
|
||||||
|
okText?: string;
|
||||||
|
/** Text of the Cancel button */
|
||||||
|
cancelText?: string;
|
||||||
|
/** Button type of the OK button */
|
||||||
|
okType?: "primary" | "default" | "dashed" | "text" | "link";
|
||||||
|
/** Footer content, set as footer={null} when you don't need default buttons */
|
||||||
|
footer?: ReactNode | null;
|
||||||
|
/** Whether show mask or not */
|
||||||
|
mask?: boolean;
|
||||||
|
/** The z-index of the Modal */
|
||||||
|
zIndex?: number;
|
||||||
|
/** Specify a function that will be called when a user clicks the OK button */
|
||||||
|
onOk?: (e?: any) => void | Promise<void>;
|
||||||
|
/** Specify a function that will be called when a user clicks mask, close button on top right or Cancel button */
|
||||||
|
onCancel?: (e?: any) => void;
|
||||||
|
/** Callback when the animation ends when Modal is turned on and off */
|
||||||
|
afterOpenChange?: (open: boolean) => void;
|
||||||
|
/** Specify a function that will be called when modal is closed completely */
|
||||||
|
afterClose?: () => void;
|
||||||
|
/** Custom className */
|
||||||
|
className?: string;
|
||||||
|
/** Modal body content */
|
||||||
|
children?: ReactNode;
|
||||||
|
/** Whether to unmount child components on close */
|
||||||
|
destroyOnClose?: boolean;
|
||||||
|
/** The ok button props */
|
||||||
|
okButtonProps?: any;
|
||||||
|
/** The cancel button props */
|
||||||
|
cancelButtonProps?: any;
|
||||||
|
/** Whether support press esc to close */
|
||||||
|
keyboard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmModalProps extends Omit<ModalProps, "open"> {
|
||||||
|
/** Type of the confirm modal */
|
||||||
|
type?: "info" | "success" | "error" | "warning" | "confirm";
|
||||||
|
/** Content */
|
||||||
|
content?: ReactNode;
|
||||||
|
/** Custom icon */
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal Component
|
||||||
|
const Modal: React.FC<ModalProps> & {
|
||||||
|
info: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
success: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
error: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
warning: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
confirm: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
useModal: () => [
|
||||||
|
{
|
||||||
|
info: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
success: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
error: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
warning: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
confirm: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
},
|
||||||
|
ReactNode
|
||||||
|
];
|
||||||
|
} = ({
|
||||||
|
open = false,
|
||||||
|
title,
|
||||||
|
closable = true,
|
||||||
|
closeIcon,
|
||||||
|
maskClosable = true,
|
||||||
|
centered = false,
|
||||||
|
width = 520,
|
||||||
|
confirmLoading = false,
|
||||||
|
okText = "OK",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
okType = "primary",
|
||||||
|
footer,
|
||||||
|
mask = true,
|
||||||
|
zIndex = 1000,
|
||||||
|
onOk,
|
||||||
|
onCancel,
|
||||||
|
afterOpenChange,
|
||||||
|
afterClose,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
destroyOnClose = false,
|
||||||
|
okButtonProps,
|
||||||
|
cancelButtonProps,
|
||||||
|
keyboard = true,
|
||||||
|
}) => {
|
||||||
|
const [visible, setVisible] = useState(open);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisible(open);
|
||||||
|
if (afterOpenChange) {
|
||||||
|
afterOpenChange(open);
|
||||||
|
}
|
||||||
|
}, [open, afterOpenChange]);
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
if (onOk) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onOk();
|
||||||
|
// Không tự động đóng modal - để parent component quyết định
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Modal onOk error:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
// Nếu không có onCancel, tự động đóng modal
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaskPress = () => {
|
||||||
|
if (maskClosable) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestClose = () => {
|
||||||
|
if (keyboard) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible && afterClose) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
afterClose();
|
||||||
|
}, 300); // Wait for animation to complete
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visible, afterClose]);
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
if (footer === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footer !== undefined) {
|
||||||
|
return <View style={styles.footer}>{footer}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.cancelButton]}
|
||||||
|
onPress={handleCancel}
|
||||||
|
disabled={loading || confirmLoading}
|
||||||
|
{...cancelButtonProps}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>{cancelText}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
styles.okButton,
|
||||||
|
okType === "primary" && styles.primaryButton,
|
||||||
|
(loading || confirmLoading) && styles.disabledButton,
|
||||||
|
]}
|
||||||
|
onPress={handleOk}
|
||||||
|
disabled={loading || confirmLoading}
|
||||||
|
{...okButtonProps}
|
||||||
|
>
|
||||||
|
{loading || confirmLoading ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.okButtonText}>{okText}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalWidth =
|
||||||
|
typeof width === "number" ? width : Dimensions.get("window").width * 0.9;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RNModal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleRequestClose}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.overlay,
|
||||||
|
centered && styles.centered,
|
||||||
|
{ zIndex },
|
||||||
|
!mask && styles.noMask,
|
||||||
|
]}
|
||||||
|
onPress={handleMaskPress}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.modal, { width: modalWidth, maxWidth: "90%" }]}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{(title || closable) && (
|
||||||
|
<View style={styles.header}>
|
||||||
|
{title && (
|
||||||
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{closable && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={handleCancel}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
{closeIcon || (
|
||||||
|
<Ionicons name="close" size={24} color="#666" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<View style={styles.body}>
|
||||||
|
{(!destroyOnClose || visible) && children}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{renderFooter()}
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</RNModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Confirm Modal Component
|
||||||
|
const ConfirmModal: React.FC<
|
||||||
|
ConfirmModalProps & { visible: boolean; onClose: () => void }
|
||||||
|
> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
type = "confirm",
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
icon,
|
||||||
|
okText = "OK",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
onOk,
|
||||||
|
onCancel,
|
||||||
|
...restProps
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
if (icon !== undefined) return icon;
|
||||||
|
|
||||||
|
const iconProps = { size: 24, style: { marginRight: 12 } };
|
||||||
|
switch (type) {
|
||||||
|
case "info":
|
||||||
|
return (
|
||||||
|
<Ionicons name="information-circle" color="#1890ff" {...iconProps} />
|
||||||
|
);
|
||||||
|
case "success":
|
||||||
|
return (
|
||||||
|
<Ionicons name="checkmark-circle" color="#52c41a" {...iconProps} />
|
||||||
|
);
|
||||||
|
case "error":
|
||||||
|
return <Ionicons name="close-circle" color="#ff4d4f" {...iconProps} />;
|
||||||
|
case "warning":
|
||||||
|
return <Ionicons name="warning" color="#faad14" {...iconProps} />;
|
||||||
|
default:
|
||||||
|
return <Ionicons name="help-circle" color="#1890ff" {...iconProps} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
if (onOk) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onOk();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Confirm modal onOk error:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={visible}
|
||||||
|
title={title}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={type === "confirm" ? handleCancel : undefined}
|
||||||
|
okText={okText}
|
||||||
|
cancelText={cancelText}
|
||||||
|
confirmLoading={loading}
|
||||||
|
footer={
|
||||||
|
type === "confirm" ? undefined : (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.okButton, styles.primaryButton]}
|
||||||
|
onPress={handleOk}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.okButtonText}>{okText}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<View style={styles.confirmContent}>
|
||||||
|
{getIcon()}
|
||||||
|
<Text style={styles.confirmText}>{content}</Text>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal Instance
|
||||||
|
export interface ModalInstance {
|
||||||
|
destroy: () => void;
|
||||||
|
update: (config: ConfirmModalProps) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container for imperatively created modals - Not used in React Native
|
||||||
|
// Static methods will return instance but won't render imperatively
|
||||||
|
// Use Modal.useModal() hook for proper context support
|
||||||
|
|
||||||
|
const createConfirmModal = (config: ConfirmModalProps): ModalInstance => {
|
||||||
|
console.warn(
|
||||||
|
"Modal static methods are not fully supported in React Native. Please use Modal.useModal() hook for better context support."
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy: () => {
|
||||||
|
console.warn(
|
||||||
|
"Modal.destroy() called but static modals are not supported in React Native"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
update: (newConfig: ConfirmModalProps) => {
|
||||||
|
console.warn(
|
||||||
|
"Modal.update() called but static modals are not supported in React Native"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static methods
|
||||||
|
Modal.info = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "info" });
|
||||||
|
Modal.success = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "success" });
|
||||||
|
Modal.error = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "error" });
|
||||||
|
Modal.warning = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "warning" });
|
||||||
|
Modal.confirm = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "confirm" });
|
||||||
|
|
||||||
|
// useModal hook
|
||||||
|
Modal.useModal = () => {
|
||||||
|
const [modals, setModals] = useState<ReactNode[]>([]);
|
||||||
|
|
||||||
|
const createModal = (
|
||||||
|
config: ConfirmModalProps,
|
||||||
|
type: ConfirmModalProps["type"]
|
||||||
|
) => {
|
||||||
|
const id = `modal-${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
setModals((prev) => prev.filter((modal: any) => modal.key !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (newConfig: ConfirmModalProps) => {
|
||||||
|
setModals((prev) =>
|
||||||
|
prev.map((modal: any) =>
|
||||||
|
modal.key === id ? (
|
||||||
|
<ConfirmModal
|
||||||
|
key={id}
|
||||||
|
visible={true}
|
||||||
|
onClose={destroy}
|
||||||
|
{...config}
|
||||||
|
{...newConfig}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
modal
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalElement = (
|
||||||
|
<ConfirmModal
|
||||||
|
key={id}
|
||||||
|
visible={true}
|
||||||
|
onClose={destroy}
|
||||||
|
{...config}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
setModals((prev) => [...prev, modalElement]);
|
||||||
|
|
||||||
|
return { destroy, update };
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalMethods = {
|
||||||
|
info: (props: ConfirmModalProps) => createModal(props, "info"),
|
||||||
|
success: (props: ConfirmModalProps) => createModal(props, "success"),
|
||||||
|
error: (props: ConfirmModalProps) => createModal(props, "error"),
|
||||||
|
warning: (props: ConfirmModalProps) => createModal(props, "warning"),
|
||||||
|
confirm: (props: ConfirmModalProps) => createModal(props, "confirm"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextHolder = <>{modals}</>;
|
||||||
|
|
||||||
|
return [modalMethods, contextHolder];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.45)",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
paddingTop: 100,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: 0,
|
||||||
|
},
|
||||||
|
noMask: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 8,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
maxHeight: "90%",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#000",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 6,
|
||||||
|
minWidth: 70,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: 36,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#d9d9d9",
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: "#000",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
okButton: {
|
||||||
|
backgroundColor: "#1890ff",
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
backgroundColor: "#1890ff",
|
||||||
|
},
|
||||||
|
okButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
confirmContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
confirmText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#000",
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
262
components/ui/slice-switch.tsx
Normal file
262
components/ui/slice-switch.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
OpaqueColorValue,
|
||||||
|
Pressable,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
const SIZE_PRESETS = {
|
||||||
|
sm: { width: 64, height: 32 },
|
||||||
|
md: { width: 80, height: 40 },
|
||||||
|
lg: { width: 96, height: 48 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SwitchSize = keyof typeof SIZE_PRESETS;
|
||||||
|
|
||||||
|
const DEFAULT_TOGGLE_DURATION = 400;
|
||||||
|
|
||||||
|
// Default both backgrounds to a grey tone when not provided
|
||||||
|
const DEFAULT_INACTIVE_BG = "#D3DAD9";
|
||||||
|
const DEFAULT_ACTIVE_BG = "#D3DAD9";
|
||||||
|
const PRESSED_SCALE = 0.96;
|
||||||
|
const PRESS_FEEDBACK_DURATION = 120;
|
||||||
|
|
||||||
|
type IoniconName = ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
|
type SliceSwitchProps = {
|
||||||
|
size?: SwitchSize;
|
||||||
|
leftIcon?: IoniconName;
|
||||||
|
leftIconColor?: string | OpaqueColorValue | undefined;
|
||||||
|
rightIconColor?: string | OpaqueColorValue | undefined;
|
||||||
|
rightIcon?: IoniconName;
|
||||||
|
duration?: number;
|
||||||
|
activeBackgroundColor?: string;
|
||||||
|
inactiveBackgroundColor?: string;
|
||||||
|
inactiveOverlayColor?: string;
|
||||||
|
activeOverlayColor?: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
onChange?: (value: boolean) => void;
|
||||||
|
value?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SliceSwitch = ({
|
||||||
|
size = "md",
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
duration,
|
||||||
|
activeBackgroundColor = DEFAULT_ACTIVE_BG,
|
||||||
|
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
|
||||||
|
leftIconColor = "#fff",
|
||||||
|
rightIconColor = "#fff",
|
||||||
|
inactiveOverlayColor = "#000",
|
||||||
|
activeOverlayColor = "#000",
|
||||||
|
style,
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
}: SliceSwitchProps) => {
|
||||||
|
const { width: containerWidth, height: containerHeight } =
|
||||||
|
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
|
||||||
|
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
|
||||||
|
const [isOn, setIsOn] = useState(value ?? false);
|
||||||
|
const [bgOn, setBgOn] = useState(value ?? false);
|
||||||
|
const progress = useRef(new Animated.Value(value ? 1 : 0)).current;
|
||||||
|
const pressScale = useRef(new Animated.Value(1)).current;
|
||||||
|
const overlayTranslateX = useRef(
|
||||||
|
new Animated.Value(value ? containerWidth / 2 : 0)
|
||||||
|
).current;
|
||||||
|
const listenerIdRef = useRef<string | number | null>(null);
|
||||||
|
|
||||||
|
// Sync with external value prop if provided
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== undefined && value !== isOn) {
|
||||||
|
animateToValue(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const animateToValue = (next: boolean) => {
|
||||||
|
const targetValue = next ? 1 : 0;
|
||||||
|
const overlayTarget = next ? containerWidth / 2 : 0;
|
||||||
|
|
||||||
|
progress.stopAnimation();
|
||||||
|
overlayTranslateX.stopAnimation();
|
||||||
|
|
||||||
|
if (animationDuration <= 0) {
|
||||||
|
progress.setValue(targetValue);
|
||||||
|
overlayTranslateX.setValue(overlayTarget);
|
||||||
|
setIsOn(next);
|
||||||
|
setBgOn(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOn(next);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(progress, {
|
||||||
|
toValue: targetValue,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(overlayTranslateX, {
|
||||||
|
toValue: overlayTarget,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
setBgOn(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove any previous listener
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap image & background exactly at 50% progress
|
||||||
|
let swapped = false;
|
||||||
|
listenerIdRef.current = progress.addListener(({ value }) => {
|
||||||
|
if (swapped) return;
|
||||||
|
if (next && value >= 0.5) {
|
||||||
|
swapped = true;
|
||||||
|
setBgOn(next);
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!next && value <= 0.5) {
|
||||||
|
swapped = true;
|
||||||
|
setBgOn(next);
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const next = !isOn;
|
||||||
|
if (value === undefined) {
|
||||||
|
animateToValue(next);
|
||||||
|
}
|
||||||
|
onChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
pressScale.stopAnimation();
|
||||||
|
Animated.timing(pressScale, {
|
||||||
|
toValue: PRESSED_SCALE,
|
||||||
|
duration: PRESS_FEEDBACK_DURATION,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressOut = () => {
|
||||||
|
pressScale.stopAnimation();
|
||||||
|
Animated.timing(pressScale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: PRESS_FEEDBACK_DURATION,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggle}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
|
accessibilityRole="switch"
|
||||||
|
accessibilityState={{ checked: isOn }}
|
||||||
|
style={[styles.pressable, style]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.shadowWrapper,
|
||||||
|
{
|
||||||
|
transform: [{ scale: pressScale }],
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
backgroundColor: bgOn
|
||||||
|
? activeBackgroundColor
|
||||||
|
: inactiveBackgroundColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: containerWidth / 2,
|
||||||
|
height: containerHeight * 0.95,
|
||||||
|
top: containerHeight * 0.01,
|
||||||
|
left: 0,
|
||||||
|
borderRadius: containerHeight * 0.95 / 2,
|
||||||
|
zIndex: 10,
|
||||||
|
backgroundColor: bgOn ? activeOverlayColor : inactiveOverlayColor,
|
||||||
|
transform: [{ translateX: overlayTranslateX }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className="h-full w-1/2 items-center justify-center ">
|
||||||
|
<Ionicons
|
||||||
|
name={leftIcon ?? "sunny"}
|
||||||
|
size={20}
|
||||||
|
color={leftIconColor ?? "#fff"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="h-full w-1/2 items-center justify-center ">
|
||||||
|
<Ionicons
|
||||||
|
name={rightIcon ?? "moon"}
|
||||||
|
size={20}
|
||||||
|
color={rightIconColor ?? "#fff"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pressable: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
shadowWrapper: {
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 6,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
knob: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SliceSwitch;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TOKEN } from "@/constants";
|
import { DOMAIN, TOKEN } from "@/constants";
|
||||||
import { removeStorageItem } from "@/utils/storage";
|
import { removeStorageItem } from "@/utils/storage";
|
||||||
import { Router } from "expo-router";
|
import { Router } from "expo-router";
|
||||||
|
|
||||||
@@ -17,7 +17,12 @@ export const setRouterInstance = (router: Router) => {
|
|||||||
export const handle401 = () => {
|
export const handle401 = () => {
|
||||||
if (routerInstance) {
|
if (routerInstance) {
|
||||||
removeStorageItem(TOKEN);
|
removeStorageItem(TOKEN);
|
||||||
(routerInstance as any).replace("/auth/login");
|
removeStorageItem(DOMAIN);
|
||||||
|
// Cancel all pending requests to prevent further API calls
|
||||||
|
if (typeof window !== "undefined" && (window as any).axiosAbortController) {
|
||||||
|
(window as any).axiosAbortController.abort();
|
||||||
|
}
|
||||||
|
routerInstance.navigate("/auth/login");
|
||||||
} else {
|
} else {
|
||||||
console.warn("Router instance not set, cannot redirect to login");
|
console.warn("Router instance not set, cannot redirect to login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { TOKEN } from "@/constants";
|
import { DOMAIN, TOKEN } from "@/constants";
|
||||||
|
import { showErrorToast } from "@/services/toast_service";
|
||||||
import { getStorageItem } from "@/utils/storage";
|
import { getStorageItem } from "@/utils/storage";
|
||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import { handle401 } from "./auth";
|
import { handle401 } from "./auth";
|
||||||
import { showToastError } from "./toast";
|
|
||||||
|
|
||||||
const codeMessage = {
|
const codeMessage = {
|
||||||
200: "The server successfully returned the requested data。",
|
200: "The server successfully returned the requested data。",
|
||||||
@@ -38,9 +38,15 @@ api.interceptors.request.use(
|
|||||||
async (config) => {
|
async (config) => {
|
||||||
// Thêm auth token nếu có
|
// Thêm auth token nếu có
|
||||||
const token = await getStorageItem(TOKEN);
|
const token = await getStorageItem(TOKEN);
|
||||||
|
const domain = await getStorageItem(DOMAIN);
|
||||||
|
if (domain) {
|
||||||
|
config.baseURL = `http://${domain}`;
|
||||||
|
}
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `${token}`;
|
config.headers.Authorization = `${token}`;
|
||||||
}
|
}
|
||||||
|
// console.log("Domain Request: ", config.baseURL);
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -57,7 +63,9 @@ api.interceptors.response.use(
|
|||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
const networkErrorMsg =
|
const networkErrorMsg =
|
||||||
error.message || "Network error - please check connection";
|
error.message || "Network error - please check connection";
|
||||||
showToastError("Lỗi kết nối", networkErrorMsg);
|
showErrorToast("Lỗi kết nối");
|
||||||
|
console.error("Response Network Error: ", networkErrorMsg);
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +78,7 @@ api.interceptors.response.use(
|
|||||||
statusText ||
|
statusText ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
|
|
||||||
showToastError(`Lỗi ${status}`, errMsg);
|
showErrorToast(`Lỗi ${status}: ${errMsg}`);
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
handle401();
|
handle401();
|
||||||
}
|
}
|
||||||
|
|||||||
2
config/localization.ts
Normal file
2
config/localization.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { useI18n } from "@/hooks/use-i18n";
|
||||||
|
export { default as i18n } from "./localization/i18n";
|
||||||
27
config/localization/i18n.ts
Normal file
27
config/localization/i18n.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import en from "@/locales/en.json";
|
||||||
|
import vi from "@/locales/vi.json";
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
|
import { I18n } from "i18n-js";
|
||||||
|
|
||||||
|
// Set the key-value pairs for the different languages you want to support
|
||||||
|
const translations = {
|
||||||
|
en,
|
||||||
|
vi,
|
||||||
|
};
|
||||||
|
|
||||||
|
const i18n = new I18n(translations);
|
||||||
|
|
||||||
|
// Set the locale once at the beginning of your app
|
||||||
|
// This will be set from storage in the useI18n hook, default to device language or 'en'
|
||||||
|
i18n.locale = getLocales()[0].languageCode ?? "vi";
|
||||||
|
|
||||||
|
// Enable fallback mechanism - if a key is missing in the current language, it will use the key from English
|
||||||
|
i18n.enableFallback = true;
|
||||||
|
|
||||||
|
// Set default locale to English if no locale is available
|
||||||
|
i18n.defaultLocale = "vi";
|
||||||
|
|
||||||
|
// Storage key for locale preference
|
||||||
|
export const LOCALE_STORAGE_KEY = "app_locale_preference";
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const TOKEN = "token";
|
export const TOKEN = "token";
|
||||||
export const BASE_URL = "https://sgw-device.gms.vn";
|
export const DOMAIN = "domain";
|
||||||
export const MAP_TRACKPOINTS_ID = "ship-trackpoints";
|
export const MAP_TRACKPOINTS_ID = "ship-trackpoints";
|
||||||
export const MAP_POLYLINE_BAN = "ban-polyline";
|
export const MAP_POLYLINE_BAN = "ban-polyline";
|
||||||
export const MAP_POLYGON_BAN = "ban-polygon";
|
export const MAP_POLYGON_BAN = "ban-polygon";
|
||||||
|
|||||||
@@ -3,51 +3,82 @@
|
|||||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
const tintColorLight = '#0a7ea4';
|
const tintColorLight = "#0a7ea4";
|
||||||
const tintColorDark = '#fff';
|
const tintColorDark = "#fff";
|
||||||
|
|
||||||
export const Colors = {
|
export const Colors = {
|
||||||
light: {
|
light: {
|
||||||
text: '#11181C',
|
text: "#11181C",
|
||||||
background: '#fff',
|
textSecondary: "#687076",
|
||||||
|
background: "#fff",
|
||||||
|
backgroundSecondary: "#f5f5f5",
|
||||||
|
surface: "#ffffff",
|
||||||
|
surfaceSecondary: "#f8f9fa",
|
||||||
tint: tintColorLight,
|
tint: tintColorLight,
|
||||||
icon: '#687076',
|
primary: "#007AFF",
|
||||||
tabIconDefault: '#687076',
|
secondary: "#5AC8FA",
|
||||||
|
success: "#34C759",
|
||||||
|
warning: "#ff6600",
|
||||||
|
error: "#FF3B30",
|
||||||
|
icon: "#687076",
|
||||||
|
iconSecondary: "#8E8E93",
|
||||||
|
border: "#C6C6C8",
|
||||||
|
separator: "#E5E5E7",
|
||||||
|
tabIconDefault: "#687076",
|
||||||
tabIconSelected: tintColorLight,
|
tabIconSelected: tintColorLight,
|
||||||
|
card: "#ffffff",
|
||||||
|
notification: "#FF3B30",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
text: '#ECEDEE',
|
text: "#ECEDEE",
|
||||||
background: '#151718',
|
textSecondary: "#8E8E93",
|
||||||
|
background: "#000000",
|
||||||
|
backgroundSecondary: "#1C1C1E",
|
||||||
|
surface: "#1C1C1E",
|
||||||
|
surfaceSecondary: "#2C2C2E",
|
||||||
tint: tintColorDark,
|
tint: tintColorDark,
|
||||||
icon: '#9BA1A6',
|
primary: "#0A84FF",
|
||||||
tabIconDefault: '#9BA1A6',
|
secondary: "#64D2FF",
|
||||||
|
success: "#30D158",
|
||||||
|
warning: "#ff6600",
|
||||||
|
error: "#FF453A",
|
||||||
|
icon: "#8E8E93",
|
||||||
|
iconSecondary: "#636366",
|
||||||
|
border: "#38383A",
|
||||||
|
separator: "#38383A",
|
||||||
|
tabIconDefault: "#8E8E93",
|
||||||
tabIconSelected: tintColorDark,
|
tabIconSelected: tintColorDark,
|
||||||
|
card: "#1C1C1E",
|
||||||
|
notification: "#FF453A",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ColorName = keyof typeof Colors.light;
|
||||||
|
|
||||||
export const Fonts = Platform.select({
|
export const Fonts = Platform.select({
|
||||||
ios: {
|
ios: {
|
||||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||||
sans: 'system-ui',
|
sans: "system-ui",
|
||||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||||
serif: 'ui-serif',
|
serif: "ui-serif",
|
||||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||||
rounded: 'ui-rounded',
|
rounded: "ui-rounded",
|
||||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||||
mono: 'ui-monospace',
|
mono: "ui-monospace",
|
||||||
},
|
},
|
||||||
default: {
|
default: {
|
||||||
sans: 'normal',
|
sans: "normal",
|
||||||
serif: 'serif',
|
serif: "serif",
|
||||||
rounded: 'normal',
|
rounded: "normal",
|
||||||
mono: 'monospace',
|
mono: "monospace",
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||||
serif: "Georgia, 'Times New Roman', serif",
|
serif: "Georgia, 'Times New Roman', serif",
|
||||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
rounded:
|
||||||
|
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api } from "@/config";
|
|||||||
import {
|
import {
|
||||||
API_GET_TRIP,
|
API_GET_TRIP,
|
||||||
API_HAUL_HANDLE,
|
API_HAUL_HANDLE,
|
||||||
|
API_UPDATE_FISHING_LOGS,
|
||||||
API_UPDATE_TRIP_STATUS,
|
API_UPDATE_TRIP_STATUS,
|
||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
|
|
||||||
@@ -16,3 +17,7 @@ export async function queryUpdateTripState(body: Model.TripUpdateStateRequest) {
|
|||||||
export async function queryStartNewHaul(body: Model.NewFishingLogRequest) {
|
export async function queryStartNewHaul(body: Model.NewFishingLogRequest) {
|
||||||
return api.put(API_HAUL_HANDLE, body);
|
return api.put(API_HAUL_HANDLE, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function queryUpdateFishingLogs(body: Model.FishingLog) {
|
||||||
|
return api.put(API_UPDATE_FISHING_LOGS, body);
|
||||||
|
}
|
||||||
163
hooks/use-app-theme.ts
Normal file
163
hooks/use-app-theme.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Custom hook for easy theme access throughout the app
|
||||||
|
* Provides styled components and theme utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { StyleSheet, TextStyle, ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
export function useAppTheme() {
|
||||||
|
const { colors, colorScheme, themeMode, setThemeMode, getColor } =
|
||||||
|
useThemeContext();
|
||||||
|
|
||||||
|
// Common styled components
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
// Container styles
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
surface: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: colors.text,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
// Button styles
|
||||||
|
primaryButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
secondaryButton: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
// Text styles
|
||||||
|
primaryButtonText: {
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
} as TextStyle,
|
||||||
|
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
} as TextStyle,
|
||||||
|
|
||||||
|
// Input styles
|
||||||
|
textInput: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
} as ViewStyle & TextStyle,
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
separator: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: colors.separator,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
// Status styles
|
||||||
|
successContainer: {
|
||||||
|
backgroundColor: `${colors.success}20`,
|
||||||
|
borderColor: colors.success,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
warningContainer: {
|
||||||
|
backgroundColor: `${colors.warning}20`,
|
||||||
|
borderColor: colors.warning,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
} as ViewStyle,
|
||||||
|
|
||||||
|
errorContainer: {
|
||||||
|
backgroundColor: `${colors.error}20`,
|
||||||
|
borderColor: colors.error,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
} as ViewStyle,
|
||||||
|
}),
|
||||||
|
[colors]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Theme utilities
|
||||||
|
const utils = useMemo(
|
||||||
|
() => ({
|
||||||
|
// Get opacity color
|
||||||
|
getOpacityColor: (
|
||||||
|
colorName: keyof typeof colors,
|
||||||
|
opacity: number = 0.1
|
||||||
|
) => {
|
||||||
|
const color = colors[colorName];
|
||||||
|
const hex = color.replace("#", "");
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if current theme is dark
|
||||||
|
isDark: colorScheme === "dark",
|
||||||
|
|
||||||
|
// Check if current theme is light
|
||||||
|
isLight: colorScheme === "light",
|
||||||
|
|
||||||
|
// Toggle between light and dark (ignoring system)
|
||||||
|
toggleTheme: () => {
|
||||||
|
const newMode = colorScheme === "dark" ? "light" : "dark";
|
||||||
|
setThemeMode(newMode);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[colors, colorScheme, setThemeMode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
colors,
|
||||||
|
styles,
|
||||||
|
utils,
|
||||||
|
colorScheme,
|
||||||
|
themeMode,
|
||||||
|
setThemeMode,
|
||||||
|
getColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppTheme = ReturnType<typeof useAppTheme>;
|
||||||
119
hooks/use-i18n.ts
Normal file
119
hooks/use-i18n.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import i18n, { LOCALE_STORAGE_KEY } from "@/config/localization/i18n";
|
||||||
|
import { getStorageItem, setStorageItem } from "@/utils/storage";
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
|
import {
|
||||||
|
PropsWithChildren,
|
||||||
|
createContext,
|
||||||
|
createElement,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
type SupportedLocale = "en" | "vi";
|
||||||
|
|
||||||
|
type I18nContextValue = {
|
||||||
|
t: typeof i18n.t;
|
||||||
|
locale: SupportedLocale;
|
||||||
|
setLocale: (locale: SupportedLocale) => Promise<void>;
|
||||||
|
isLoaded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
const SUPPORTED_LOCALES: SupportedLocale[] = ["en", "vi"];
|
||||||
|
|
||||||
|
const resolveSupportedLocale = (
|
||||||
|
locale: string | null | undefined
|
||||||
|
): SupportedLocale => {
|
||||||
|
if (!locale) {
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = locale.split("-")[0]?.toLowerCase() as SupportedLocale;
|
||||||
|
if (normalized && SUPPORTED_LOCALES.includes(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "en";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const I18nProvider = ({ children }: PropsWithChildren<unknown>) => {
|
||||||
|
const [locale, setLocaleState] = useState<SupportedLocale>(
|
||||||
|
resolveSupportedLocale(i18n.locale)
|
||||||
|
);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLocale = async () => {
|
||||||
|
try {
|
||||||
|
const savedLocale = await getStorageItem(LOCALE_STORAGE_KEY);
|
||||||
|
const deviceLocale = getLocales()[0]?.languageCode;
|
||||||
|
const localeToUse = resolveSupportedLocale(savedLocale ?? deviceLocale);
|
||||||
|
|
||||||
|
if (localeToUse !== i18n.locale) {
|
||||||
|
i18n.locale = localeToUse;
|
||||||
|
}
|
||||||
|
setLocaleState(localeToUse);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading locale preference:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoaded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadLocale();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateLocale = useCallback((nextLocale: SupportedLocale) => {
|
||||||
|
if (i18n.locale !== nextLocale) {
|
||||||
|
i18n.locale = nextLocale;
|
||||||
|
}
|
||||||
|
setLocaleState(nextLocale);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLocale = useCallback(
|
||||||
|
async (nextLocale: SupportedLocale) => {
|
||||||
|
if (!SUPPORTED_LOCALES.includes(nextLocale)) {
|
||||||
|
console.warn(`Unsupported locale: ${nextLocale}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLocale(nextLocale);
|
||||||
|
await setStorageItem(LOCALE_STORAGE_KEY, nextLocale);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error setting locale:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateLocale]
|
||||||
|
);
|
||||||
|
|
||||||
|
const translate = useCallback(
|
||||||
|
(...args: Parameters<typeof i18n.t>) => i18n.t(...args),
|
||||||
|
[locale]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<I18nContextValue>(
|
||||||
|
() => ({
|
||||||
|
t: translate,
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
isLoaded,
|
||||||
|
}),
|
||||||
|
[locale, setLocale, translate, isLoaded]
|
||||||
|
);
|
||||||
|
|
||||||
|
return createElement(I18nContext.Provider, { value }, children);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useI18n = () => {
|
||||||
|
const context = useContext(I18nContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useI18n must be used within an I18nProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
* https://docs.expo.dev/guides/color-schemes/
|
* https://docs.expo.dev/guides/color-schemes/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Colors } from '@/constants/theme';
|
import { Colors } from "@/constants/theme";
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
export function useThemeColor(
|
export function useThemeColor(
|
||||||
props: { light?: string; dark?: string },
|
props: { light?: string; dark?: string },
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||||
) {
|
) {
|
||||||
const theme = useColorScheme() ?? 'light';
|
const theme = useColorScheme();
|
||||||
const colorFromProps = props[theme];
|
const colorFromProps = props[theme];
|
||||||
|
|
||||||
if (colorFromProps) {
|
if (colorFromProps) {
|
||||||
|
|||||||
193
hooks/use-theme-context.tsx
Normal file
193
hooks/use-theme-context.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Theme Context Hook for managing app-wide theme state.
|
||||||
|
* Supports Light, Dark, and System (automatic) modes across Expo platforms.
|
||||||
|
*
|
||||||
|
* IMPORTANT: Requires expo-system-ui plugin in app.json for Android status bar support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ColorName, Colors } from "@/constants/theme";
|
||||||
|
import { getStorageItem, setStorageItem } from "@/utils/storage";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Appearance,
|
||||||
|
AppState,
|
||||||
|
AppStateStatus,
|
||||||
|
useColorScheme as useRNColorScheme,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
|
export type ColorScheme = "light" | "dark";
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
colorScheme: ColorScheme;
|
||||||
|
colors: typeof Colors.light;
|
||||||
|
setThemeMode: (mode: ThemeMode) => Promise<void>;
|
||||||
|
getColor: (colorName: ColorName) => string;
|
||||||
|
isHydrated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = "theme_mode";
|
||||||
|
|
||||||
|
const getSystemScheme = (): ColorScheme => {
|
||||||
|
const scheme = Appearance.getColorScheme();
|
||||||
|
// console.log("[Theme] Appearance.getColorScheme():", scheme);
|
||||||
|
return scheme === "dark" ? "dark" : "light";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isThemeMode = (value: unknown): value is ThemeMode => {
|
||||||
|
return value === "light" || value === "dark" || value === "system";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [systemScheme, setSystemScheme] =
|
||||||
|
useState<ColorScheme>(getSystemScheme);
|
||||||
|
const [themeMode, setThemeModeState] = useState<ThemeMode>("system");
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
|
||||||
|
const syncSystemScheme = useCallback(() => {
|
||||||
|
const next = getSystemScheme();
|
||||||
|
// console.log("[Theme] syncSystemScheme computed:", next);
|
||||||
|
setSystemScheme((current) => (current === next ? current : next));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rnScheme = useRNColorScheme();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rnScheme) return;
|
||||||
|
const next = rnScheme === "dark" ? "dark" : "light";
|
||||||
|
// console.log("[Theme] useColorScheme hook emitted:", rnScheme);
|
||||||
|
setSystemScheme((current) => (current === next ? current : next));
|
||||||
|
}, [rnScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
|
||||||
|
const next = colorScheme === "dark" ? "dark" : "light";
|
||||||
|
// console.log("[Theme] Appearance listener fired with:", colorScheme);
|
||||||
|
setSystemScheme((current) => (current === next ? current : next));
|
||||||
|
});
|
||||||
|
|
||||||
|
syncSystemScheme();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [syncSystemScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log("[Theme] System scheme detected:", systemScheme);
|
||||||
|
}, [systemScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAppStateChange = (nextState: AppStateStatus) => {
|
||||||
|
if (nextState === "active") {
|
||||||
|
// console.log("[Theme] AppState active → scheduling system scheme sync");
|
||||||
|
setTimeout(() => {
|
||||||
|
// console.log("[Theme] AppState sync callback running");
|
||||||
|
syncSystemScheme();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
handleAppStateChange
|
||||||
|
);
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [syncSystemScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const hydrateThemeMode = async () => {
|
||||||
|
try {
|
||||||
|
const savedThemeMode = await getStorageItem(THEME_STORAGE_KEY);
|
||||||
|
if (isMounted && isThemeMode(savedThemeMode)) {
|
||||||
|
setThemeModeState(savedThemeMode);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Theme] Failed to load theme mode:", error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsHydrated(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
hydrateThemeMode();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colorScheme: ColorScheme =
|
||||||
|
themeMode === "system" ? systemScheme : themeMode;
|
||||||
|
|
||||||
|
const colors = useMemo(() => Colors[colorScheme], [colorScheme]);
|
||||||
|
|
||||||
|
const setThemeMode = useCallback(async (mode: ThemeMode) => {
|
||||||
|
setThemeModeState(mode);
|
||||||
|
try {
|
||||||
|
await setStorageItem(THEME_STORAGE_KEY, mode);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Theme] Failed to save theme mode:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log("[Theme] window defined:", typeof window !== "undefined");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getColor = useCallback(
|
||||||
|
(colorName: ColorName) => colors[colorName] ?? colors.text,
|
||||||
|
[colors]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log("[Theme] Mode:", themeMode);
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log("[Theme] Derived colorScheme:", colorScheme);
|
||||||
|
}, [colorScheme]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
themeMode,
|
||||||
|
colorScheme,
|
||||||
|
colors,
|
||||||
|
setThemeMode,
|
||||||
|
getColor,
|
||||||
|
isHydrated,
|
||||||
|
}),
|
||||||
|
[themeMode, colorScheme, colors, setThemeMode, getColor, isHydrated]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextType {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeContext = useTheme;
|
||||||
|
|
||||||
|
export function useColorScheme(): ColorScheme {
|
||||||
|
return useTheme().colorScheme;
|
||||||
|
}
|
||||||
202
locales/en.json
Normal file
202
locales/en.json
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"app_name": "Sea Gateway",
|
||||||
|
"footer_text": "Product of Mobifone v1.0",
|
||||||
|
"ok": "OK",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"add": "Add",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"warning": "Warning",
|
||||||
|
"language": "Language",
|
||||||
|
"language_vi": "Vietnamese",
|
||||||
|
"language_en": "English",
|
||||||
|
"theme": "Theme",
|
||||||
|
"theme_light": "Light",
|
||||||
|
"theme_dark": "Dark",
|
||||||
|
"theme_system": "System"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"home": "Monitor",
|
||||||
|
"diary": "Diary",
|
||||||
|
"sensor": "Sensor",
|
||||||
|
"trip": "Trip",
|
||||||
|
"setting": "Settings"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"welcome": "Welcome",
|
||||||
|
"noData": "No data available",
|
||||||
|
"gpsInfo": "GPS Information",
|
||||||
|
"tripActive": "Active Trip",
|
||||||
|
"latitude": "Latitude",
|
||||||
|
"longitude": "Longitude",
|
||||||
|
"speed": "Speed",
|
||||||
|
"heading": "Heading",
|
||||||
|
"offline": "Offline",
|
||||||
|
"online": "Online",
|
||||||
|
"sos": {
|
||||||
|
"title": "Emergency Alert",
|
||||||
|
"active": "In Emergency State",
|
||||||
|
"inactive": "Emergency",
|
||||||
|
"description": "Emergency Notification",
|
||||||
|
"content": "Content:",
|
||||||
|
"selectReason": "Select reason",
|
||||||
|
"statusInput": "Enter status",
|
||||||
|
"enterStatus": "Describe emergency status",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"statusRequired": "Please enter status",
|
||||||
|
"sendError": "Unable to send SOS signal"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trip": {
|
||||||
|
"infoTrip": "Trip Information",
|
||||||
|
"createNewTrip": "Create New Trip",
|
||||||
|
"endTrip": "End Trip",
|
||||||
|
"cancelTrip": "Cancel Trip",
|
||||||
|
"tripStatus": "Trip Status",
|
||||||
|
"tripDuration": "Trip Duration",
|
||||||
|
"distance": "Distance",
|
||||||
|
"speed": "Speed",
|
||||||
|
"startTime": "Start Time",
|
||||||
|
"endTime": "End Time",
|
||||||
|
"startTrip": "Start Trip",
|
||||||
|
"endHaul": "End Haul",
|
||||||
|
"startHaul": "Start Haul",
|
||||||
|
"endHaulConfirm": "Are you sure you want to end this haul?",
|
||||||
|
"endHaulTitle": "End Haul",
|
||||||
|
"startHaulConfirm": "Do you want to start a new haul?",
|
||||||
|
"startHaulTitle": "Start Haul",
|
||||||
|
"cancelButton": "Cancel",
|
||||||
|
"endButton": "End",
|
||||||
|
"startButton": "Start",
|
||||||
|
"successTitle": "Success",
|
||||||
|
"endHaulSuccess": "Haul ended successfully!",
|
||||||
|
"startHaulSuccess": "New haul started successfully!",
|
||||||
|
"startTripSuccess": "Trip started successfully!",
|
||||||
|
"alreadyStarted": "Trip has already been started or completed.",
|
||||||
|
"finishCurrentHaul": "Please finish the current haul before starting a new one",
|
||||||
|
"createHaulFailed": "Failed to create new haul!",
|
||||||
|
"weatherDescription": "Clear",
|
||||||
|
"costTable": {
|
||||||
|
"title": "Trip Cost",
|
||||||
|
"typeHeader": "Type",
|
||||||
|
"totalCostHeader": "Total Cost",
|
||||||
|
"totalLabel": "Total",
|
||||||
|
"viewDetail": "View Details"
|
||||||
|
},
|
||||||
|
"fishingTools": {
|
||||||
|
"title": "Fishing Tools List",
|
||||||
|
"nameHeader": "Name",
|
||||||
|
"quantityHeader": "Quantity",
|
||||||
|
"totalLabel": "Total"
|
||||||
|
},
|
||||||
|
"crewList": {
|
||||||
|
"title": "Crew List",
|
||||||
|
"nameHeader": "Name",
|
||||||
|
"roleHeader": "Role",
|
||||||
|
"totalLabel": "Total"
|
||||||
|
},
|
||||||
|
"netList": {
|
||||||
|
"title": "Haul List",
|
||||||
|
"sttHeader": "No.",
|
||||||
|
"statusHeader": "Status",
|
||||||
|
"completed": "Completed",
|
||||||
|
"pending": "Pending",
|
||||||
|
"haulPrefix": "Haul"
|
||||||
|
},
|
||||||
|
"createHaulModal": {
|
||||||
|
"title": "Catch Information",
|
||||||
|
"addSuccess": "Catch added successfully",
|
||||||
|
"addError": "Failed to add catch",
|
||||||
|
"updateSuccess": "Catch updated successfully",
|
||||||
|
"updateError": "Failed to update catch",
|
||||||
|
"fishName": "Fish Name",
|
||||||
|
"selectFish": "Select fish species",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"unit": "Unit",
|
||||||
|
"size": "Size",
|
||||||
|
"optional": "Optional",
|
||||||
|
"addFish": "Add Fish",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"edit": "Edit",
|
||||||
|
"done": "Done",
|
||||||
|
"fishListNotReady": "Fish species list not ready",
|
||||||
|
"gpsError": "Unable to get current GPS data",
|
||||||
|
"validationError": "Please add at least 1 fish species"
|
||||||
|
},
|
||||||
|
"crewDetailModal": {
|
||||||
|
"title": "Crew Information",
|
||||||
|
"personalId": "Personal ID",
|
||||||
|
"fullName": "Full Name",
|
||||||
|
"role": "Role",
|
||||||
|
"birthDate": "Date of Birth",
|
||||||
|
"phone": "Phone",
|
||||||
|
"address": "Address",
|
||||||
|
"joinedDate": "Joined Date",
|
||||||
|
"note": "Note",
|
||||||
|
"status": "Status",
|
||||||
|
"working": "Working",
|
||||||
|
"resigned": "Resigned",
|
||||||
|
"notUpdated": "Not updated"
|
||||||
|
},
|
||||||
|
"costDetailModal": {
|
||||||
|
"title": "Trip Cost Details",
|
||||||
|
"costType": "Cost Type",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"unit": "Unit",
|
||||||
|
"costPerUnit": "Cost Per Unit (VND)",
|
||||||
|
"totalCost": "Total Cost",
|
||||||
|
"total": "Total",
|
||||||
|
"edit": "Edit",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"enterCostType": "Enter cost type",
|
||||||
|
"placeholder": "e.g. kg, liters",
|
||||||
|
"vnd": "VND"
|
||||||
|
},
|
||||||
|
"buttonEndTrip": {
|
||||||
|
"title": "End",
|
||||||
|
"endTrip": "End Trip"
|
||||||
|
},
|
||||||
|
"buttonCancelTrip": {
|
||||||
|
"title": "Cancel Trip"
|
||||||
|
},
|
||||||
|
"infoSection": {
|
||||||
|
"sttLabel": "No.",
|
||||||
|
"haulPrefix": "Haul",
|
||||||
|
"statusLabel": "Status",
|
||||||
|
"statusCompleted": "Completed",
|
||||||
|
"statusPending": "Pending",
|
||||||
|
"startTimeLabel": "Start Time",
|
||||||
|
"endTimeLabel": "End Time",
|
||||||
|
"notUpdated": "Not updated"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"alarm": {
|
||||||
|
"title": "Alarm",
|
||||||
|
"noAlarm": "No alarm",
|
||||||
|
"warning": "Warning",
|
||||||
|
"danger": "Danger",
|
||||||
|
"critical": "Critical"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": "Login",
|
||||||
|
"logout": "Logout",
|
||||||
|
"username": "Username",
|
||||||
|
"username_placeholder": "Enter username",
|
||||||
|
"password": "Password",
|
||||||
|
"password_placeholder": "Enter password",
|
||||||
|
"loginError": "Login failed. Please try again.",
|
||||||
|
"sessionExpired": "Your session has expired. Please login again."
|
||||||
|
}
|
||||||
|
}
|
||||||
203
locales/vi.json
Normal file
203
locales/vi.json
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"app_name": "Hệ thống giám sát tàu cá",
|
||||||
|
"footer_text": "Sản phẩm của Mobifone v1.0",
|
||||||
|
"ok": "OK",
|
||||||
|
"cancel": "Hủy",
|
||||||
|
"save": "Lưu",
|
||||||
|
"delete": "Xóa",
|
||||||
|
"edit": "Chỉnh sửa",
|
||||||
|
"add": "Thêm",
|
||||||
|
"close": "Đóng",
|
||||||
|
"back": "Quay lại",
|
||||||
|
"next": "Tiếp theo",
|
||||||
|
"previous": "Quay lại",
|
||||||
|
"loading": "Đang tải...",
|
||||||
|
"error": "Lỗi",
|
||||||
|
"success": "Thành công",
|
||||||
|
"warning": "Cảnh báo",
|
||||||
|
"language": "Ngôn ngữ",
|
||||||
|
"language_vi": "Tiếng Việt",
|
||||||
|
"language_en": "Tiếng Anh",
|
||||||
|
"theme": "Giao diện",
|
||||||
|
"theme_light": "Sáng",
|
||||||
|
"theme_dark": "Tối",
|
||||||
|
"theme_system": "Hệ thống"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"home": "Giám sát",
|
||||||
|
"diary": "Nhật ký",
|
||||||
|
"sensor": "Cảm biến",
|
||||||
|
"trip": "Chuyến đi",
|
||||||
|
"setting": "Cài đặt"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"welcome": "Chào mừng",
|
||||||
|
"noData": "Không có dữ liệu",
|
||||||
|
"gpsInfo": "Thông tin GPS",
|
||||||
|
"tripActive": "Chuyến hoạt động",
|
||||||
|
"latitude": "Vĩ độ",
|
||||||
|
"longitude": "Kinh độ",
|
||||||
|
"speed": "Tốc độ",
|
||||||
|
"heading": "Hướng",
|
||||||
|
"offline": "Ngoại tuyến",
|
||||||
|
"online": "Trực tuyến",
|
||||||
|
"sos": {
|
||||||
|
"title": "Thông báo khẩn cấp",
|
||||||
|
"active": "Đang trong trạng thái khẩn cấp",
|
||||||
|
"inactive": "Khẩn cấp",
|
||||||
|
"description": "Thông báo khẩn cấp",
|
||||||
|
"content": "Nội dung:",
|
||||||
|
"selectReason": "Chọn lý do",
|
||||||
|
"statusInput": "Nhập trạng thái",
|
||||||
|
"enterStatus": "Mô tả trạng thái khẩn cấp",
|
||||||
|
"confirm": "Xác nhận",
|
||||||
|
"cancel": "Hủy",
|
||||||
|
"statusRequired": "Vui lòng nhập trạng thái",
|
||||||
|
"sendError": "Không thể gửi tín hiệu SOS"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trip": {
|
||||||
|
"infoTrip": "Thông Tin Chuyến Đi",
|
||||||
|
"createNewTrip": "Tạo chuyến mới",
|
||||||
|
"endTrip": "Kết thúc chuyến",
|
||||||
|
"cancelTrip": "Hủy chuyến",
|
||||||
|
"tripStatus": "Trạng thái chuyến",
|
||||||
|
"tripDuration": "Thời lượng chuyến",
|
||||||
|
"distance": "Khoảng cách",
|
||||||
|
"speed": "Tốc độ",
|
||||||
|
"startTime": "Thời gian bắt đầu",
|
||||||
|
"endTime": "Thời gian kết thúc",
|
||||||
|
"startTrip": "Bắt đầu chuyến đi",
|
||||||
|
"endHaul": "Kết thúc mẻ lưới",
|
||||||
|
"startHaul": "Bắt đầu mẻ lưới",
|
||||||
|
"endHaulConfirm": "Bạn có chắc chắn muốn kết thúc mẻ lưới này?",
|
||||||
|
"endHaulTitle": "Kết thúc mẻ lưới",
|
||||||
|
"startHaulConfirm": "Bạn có muốn bắt đầu mẻ lưới mới?",
|
||||||
|
"startHaulTitle": "Bắt đầu mẻ lưới",
|
||||||
|
"cancelButton": "Hủy",
|
||||||
|
"endButton": "Kết thúc",
|
||||||
|
"startButton": "Bắt đầu",
|
||||||
|
"successTitle": "Thành công",
|
||||||
|
"endHaulSuccess": "Đã kết thúc mẻ lưới!",
|
||||||
|
"startHaulSuccess": "Đã bắt đầu mẻ lưới mới!",
|
||||||
|
"startTripSuccess": "Bắt đầu chuyến đi thành công!",
|
||||||
|
"alreadyStarted": "Chuyến đi đã được bắt đầu hoặc hoàn thành.",
|
||||||
|
"finishCurrentHaul": "Vui lòng kết thúc mẻ lưới hiện tại trước khi bắt đầu mẻ mới",
|
||||||
|
"createHaulFailed": "Tạo mẻ lưới mới thất bại!",
|
||||||
|
"weatherDescription": "Nắng đẹp",
|
||||||
|
"costTable": {
|
||||||
|
"title": "Chi phí chuyến đi",
|
||||||
|
"typeHeader": "Loại",
|
||||||
|
"totalCostHeader": "Tổng chi phí",
|
||||||
|
"totalLabel": "Tổng cộng",
|
||||||
|
"viewDetail": "Xem chi tiết"
|
||||||
|
},
|
||||||
|
"fishingTools": {
|
||||||
|
"title": "Danh sách ngư cụ",
|
||||||
|
"nameHeader": "Tên",
|
||||||
|
"quantityHeader": "Số lượng",
|
||||||
|
"totalLabel": "Tổng cộng"
|
||||||
|
},
|
||||||
|
"crewList": {
|
||||||
|
"title": "Danh sách thuyền viên",
|
||||||
|
"nameHeader": "Tên",
|
||||||
|
"roleHeader": "Chức vụ",
|
||||||
|
"totalLabel": "Tổng cộng"
|
||||||
|
},
|
||||||
|
"netList": {
|
||||||
|
"title": "Danh sách mẻ lưới",
|
||||||
|
"sttHeader": "STT",
|
||||||
|
"statusHeader": "Trạng thái",
|
||||||
|
"completed": "Đã hoàn thành",
|
||||||
|
"pending": "Chưa hoàn thành",
|
||||||
|
"haulPrefix": "Mẻ"
|
||||||
|
},
|
||||||
|
"createHaulModal": {
|
||||||
|
"title": "Thông tin mẻ cá",
|
||||||
|
"addSuccess": "Thêm mẻ cá thành công",
|
||||||
|
"addError": "Thêm mẻ cá thất bại",
|
||||||
|
"updateSuccess": "Cập nhật mẻ cá thành công",
|
||||||
|
"updateError": "Cập nhật mẻ cá thất bại",
|
||||||
|
"fishName": "Tên cá",
|
||||||
|
"selectFish": "Chọn loài cá",
|
||||||
|
"quantity": "Số lượng",
|
||||||
|
"unit": "Đơn vị",
|
||||||
|
"size": "Kích thước",
|
||||||
|
"optional": "Không bắt buộc",
|
||||||
|
"addFish": "Thêm cá",
|
||||||
|
"save": "Lưu",
|
||||||
|
"cancel": "Hủy",
|
||||||
|
"edit": "Chỉnh sửa",
|
||||||
|
"done": "Xong",
|
||||||
|
"fishListNotReady": "Danh sách loài cá chưa sẵn sàng",
|
||||||
|
"gpsError": "Không thể lấy dữ liệu GPS hiện tại",
|
||||||
|
"validationError": "Vui lòng thêm ít nhất 1 loài cá"
|
||||||
|
},
|
||||||
|
"crewDetailModal": {
|
||||||
|
"title": "Thông tin thuyền viên",
|
||||||
|
"personalId": "Mã định danh",
|
||||||
|
"fullName": "Họ và tên",
|
||||||
|
"role": "Chức vụ",
|
||||||
|
"birthDate": "Ngày sinh",
|
||||||
|
"phone": "Số điện thoại",
|
||||||
|
"address": "Địa chỉ",
|
||||||
|
"joinedDate": "Ngày vào làm",
|
||||||
|
"note": "Ghi chú",
|
||||||
|
"status": "Tình trạng",
|
||||||
|
"working": "Đang làm việc",
|
||||||
|
"resigned": "Đã nghỉ",
|
||||||
|
"notUpdated": "Chưa cập nhật"
|
||||||
|
},
|
||||||
|
"costDetailModal": {
|
||||||
|
"title": "Chi tiết chi phí chuyến đi",
|
||||||
|
"costType": "Loại chi phí",
|
||||||
|
"quantity": "Số lượng",
|
||||||
|
"unit": "Đơn vị",
|
||||||
|
"costPerUnit": "Chi phí/đơn vị (VNĐ)",
|
||||||
|
"totalCost": "Tổng chi phí",
|
||||||
|
"total": "Tổng cộng",
|
||||||
|
"edit": "Chỉnh sửa",
|
||||||
|
"save": "Lưu",
|
||||||
|
"cancel": "Hủy",
|
||||||
|
"enterCostType": "Nhập loại chi phí",
|
||||||
|
"placeholder": "ví dụ: kg, lít",
|
||||||
|
"vnd": "VNĐ"
|
||||||
|
},
|
||||||
|
"buttonEndTrip": {
|
||||||
|
"title": "Kết thúc",
|
||||||
|
"endTrip": "Kết thúc chuyến"
|
||||||
|
},
|
||||||
|
"buttonCancelTrip": {
|
||||||
|
"title": "Hủy chuyến đi"
|
||||||
|
},
|
||||||
|
"infoSection": {
|
||||||
|
"sttLabel": "Số thứ tự",
|
||||||
|
"haulPrefix": "Mẻ",
|
||||||
|
"statusLabel": "Trạng thái",
|
||||||
|
"statusCompleted": "Đã hoàn thành",
|
||||||
|
"statusPending": "Chưa hoàn thành",
|
||||||
|
"startTimeLabel": "Thời gian bắt đầu",
|
||||||
|
"endTimeLabel": "Thời gian kết thúc",
|
||||||
|
"notUpdated": "Chưa cập nhật"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"alarm": {
|
||||||
|
"title": "Cảnh báo",
|
||||||
|
"noAlarm": "Không có cảnh báo",
|
||||||
|
"warning": "Cảnh báo",
|
||||||
|
"danger": "Nguy hiểm",
|
||||||
|
"critical": "Rất nguy hiểm"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": "Đăng nhập",
|
||||||
|
"logout": "Đăng xuất",
|
||||||
|
"username": "Tài khoản",
|
||||||
|
"username_placeholder": "Nhập tài khoản",
|
||||||
|
|
||||||
|
"password": "Mật khẩu",
|
||||||
|
"password_placeholder": "Nhập mật khẩu",
|
||||||
|
"loginError": "Đăng nhập thất bại. Vui lòng thử lại.",
|
||||||
|
"sessionExpired": "Phiên của bạn đã hết hạn. Vui lòng đăng nhập lại."
|
||||||
|
}
|
||||||
|
}
|
||||||
134
package-lock.json
generated
134
package-lock.json
generated
@@ -10,8 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/html-elements": "^0.10.1",
|
"@expo/html-elements": "^0.10.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gluestack-ui/core": "^3.0.12",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@gluestack-ui/utils": "^3.0.11",
|
"@islacel/react-native-custom-switch": "^1.0.10",
|
||||||
"@legendapp/motion": "^2.5.3",
|
"@legendapp/motion": "^2.5.3",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
@@ -28,16 +28,19 @@
|
|||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
|
"expo-localization": "~17.0.7",
|
||||||
"expo-router": "~6.0.13",
|
"expo-router": "~6.0.13",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
"expo-symbols": "~1.0.7",
|
"expo-symbols": "~1.0.7",
|
||||||
"expo-system-ui": "~6.0.8",
|
"expo-system-ui": "~6.0.8",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
|
"i18n-js": "^4.5.1",
|
||||||
"nativewind": "^4.2.1",
|
"nativewind": "^4.2.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-aria": "^3.44.0",
|
"react-aria": "^3.44.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-hook-form": "^7.66.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||||
@@ -2352,41 +2355,16 @@
|
|||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@gluestack-ui/core": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "3.0.12",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@gluestack-ui/core/-/core-3.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||||
"integrity": "sha512-TyNjDUJrZF/FTqcSEPBR87wZQ3yvbWuTjn0tG5AFYzYfMCw0IpfTigmzoajN9KHensN0xNwHoAkXKaHlhy11yQ==",
|
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@gluestack-ui/utils": ">=2.0.0",
|
|
||||||
"react": ">=16.8.0",
|
|
||||||
"react-native": ">=0.64.0",
|
|
||||||
"react-native-safe-area-context": ">=4.0.0",
|
|
||||||
"react-native-svg": ">=12.0.0",
|
|
||||||
"react-native-web": ">=0.19.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@gluestack-ui/utils": {
|
|
||||||
"version": "3.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@gluestack-ui/utils/-/utils-3.0.11.tgz",
|
|
||||||
"integrity": "sha512-4stxK98v07NFAGvSI4Dxje/xbnftaY45VcZglZUxlAr8FFVLNFcjXUTSnVWqog0DBp2oJ7Nk/AYUpT2KkpI+7A==",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dom-helpers": "^6.0.1",
|
"@standard-schema/utils": "^0.3.0"
|
||||||
"react-aria": "^3.41.1",
|
|
||||||
"react-stately": "^3.39.0",
|
|
||||||
"tailwind-variants": "0.1.20"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=16.8.0",
|
"react-hook-form": "^7.55.0"
|
||||||
"react-native": ">=0.64.0",
|
|
||||||
"react-native-web": ">=0.19.0",
|
|
||||||
"tailwindcss": ">=3.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
@@ -2516,6 +2494,12 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@islacel/react-native-custom-switch": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@islacel/react-native-custom-switch/-/react-native-custom-switch-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-BnNXcnpbPK8C0FBdTL5BqVH+Y6iLYKO9bN7ElpuJD2P6u2zcyDm8VYrNLke/+ZDawFd+XOentu5Zx64fx6K25w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@istanbuljs/load-nyc-config": {
|
"node_modules/@istanbuljs/load-nyc-config": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
||||||
@@ -5101,6 +5085,12 @@
|
|||||||
"@sinonjs/commons": "^3.0.0"
|
"@sinonjs/commons": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.17",
|
"version": "0.5.17",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||||
@@ -6586,6 +6576,15 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bignumber.js": {
|
||||||
|
"version": "9.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||||
|
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -7341,6 +7340,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
@@ -7587,16 +7587,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dom-helpers": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-IKySryuFwseGkrCA/pIqlwUPOD50w1Lj/B2Yief3vBOP18k5y4t+hTqKh55gULDVeJMRitcozve+g/wVFf4sFQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.27.1",
|
|
||||||
"csstype": "^3.1.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
@@ -8572,6 +8562,19 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-localization": {
|
||||||
|
"version": "17.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.7.tgz",
|
||||||
|
"integrity": "sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"rtl-detect": "^1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-modules-autolinking": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.19",
|
"version": "3.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.19.tgz",
|
||||||
@@ -9957,6 +9960,17 @@
|
|||||||
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/i18n-js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18n-js/-/i18n-js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-n7jojFj1WC0tztgr0I8jqTXuIlY1xNzXnC3mjKX/YjJhimdM+jXM8vOmn9d3xQFNC6qDHJ4ovhdrGXrRXLIGkA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bignumber.js": "*",
|
||||||
|
"lodash": "*",
|
||||||
|
"make-plural": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -11286,6 +11300,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.debounce": {
|
"node_modules/lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
@@ -11409,6 +11429,12 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/make-plural": {
|
||||||
|
"version": "7.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.4.0.tgz",
|
||||||
|
"integrity": "sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg==",
|
||||||
|
"license": "Unicode-DFS-2016"
|
||||||
|
},
|
||||||
"node_modules/makeerror": {
|
"node_modules/makeerror": {
|
||||||
"version": "1.0.12",
|
"version": "1.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
|
||||||
@@ -13244,6 +13270,22 @@
|
|||||||
"react": ">=17.0.0"
|
"react": ">=17.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||||
|
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
|
||||||
@@ -14299,6 +14341,12 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rtl-detect": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -5,16 +5,16 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/html-elements": "^0.10.1",
|
"@expo/html-elements": "^0.10.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gluestack-ui/core": "^3.0.12",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@gluestack-ui/utils": "^3.0.11",
|
"@islacel/react-native-custom-switch": "^1.0.10",
|
||||||
"@legendapp/motion": "^2.5.3",
|
"@legendapp/motion": "^2.5.3",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
@@ -31,16 +31,19 @@
|
|||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
|
"expo-localization": "~17.0.7",
|
||||||
"expo-router": "~6.0.13",
|
"expo-router": "~6.0.13",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
"expo-symbols": "~1.0.7",
|
"expo-symbols": "~1.0.7",
|
||||||
"expo-system-ui": "~6.0.8",
|
"expo-system-ui": "~6.0.8",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
|
"i18n-js": "^4.5.1",
|
||||||
"nativewind": "^4.2.1",
|
"nativewind": "^4.2.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-aria": "^3.44.0",
|
"react-aria": "^3.44.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-hook-form": "^7.66.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||||
|
|||||||
@@ -162,3 +162,11 @@ export function stopEvents() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startEvents() {
|
||||||
|
getGpsEventBus();
|
||||||
|
getAlarmEventBus();
|
||||||
|
getEntitiesEventBus();
|
||||||
|
getTrackPointsEventBus();
|
||||||
|
getBanzonesEventBus();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user