Compare commits

..

10 Commits

Author SHA1 Message Date
554289ee1e fix themes modal, Add English to the trip information tab 2025-11-21 18:46:51 +07:00
6975358a7f update darkMode for modal 2025-11-21 08:50:07 +07:00
51327c7d01 clear file not use 2025-11-20 16:54:37 +07:00
1d5b29e4a7 update doc themes 2025-11-19 19:18:39 +07:00
7cb35efd30 fix bug themes 2025-11-19 17:11:10 +07:00
Tran Anh Tuan
d8874fbe60 uncomment 2025-11-19 15:05:59 +07:00
00fd53bbd4 fix config theme system 2025-11-19 14:52:12 +07:00
Tran Anh Tuan
742d8f6bcc remove gluestackk-ui 2025-11-19 14:23:17 +07:00
f3cf10e5e6 fix theme system 2025-11-17 19:55:53 +07:00
862c4e42a4 update theme dark, light mode 2025-11-17 17:01:42 +07:00
66 changed files with 3802 additions and 4762 deletions

View File

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

409
MODAL_USAGE.md Normal file
View File

@@ -0,0 +1,409 @@
# Modal Component
Modal component tương tự như Modal của Ant Design, được tạo cho React Native/Expo.
## Cài đặt
Component này sử dụng `@expo/vector-icons` cho các icon. Đảm bảo bạn đã cài đặt:
```bash
npx expo install @expo/vector-icons
```
## Import
```tsx
import Modal from "@/components/ui/modal";
```
## Các tính năng chính
### 1. Basic Modal
```tsx
import React, { useState } from "react";
import { View, Text, Button } from "react-native";
import Modal from "@/components/ui/modal";
export default function BasicExample() {
const [open, setOpen] = useState(false);
return (
<View>
<Button title="Open Modal" onPress={() => setOpen(true)} />
<Modal
open={open}
title="Basic Modal"
onOk={() => {
console.log("OK clicked");
setOpen(false);
}}
onCancel={() => setOpen(false)}
>
<Text>Some contents...</Text>
<Text>Some contents...</Text>
<Text>Some contents...</Text>
</Modal>
</View>
);
}
```
### 2. Async Close (với confirmLoading)
```tsx
import React, { useState } from "react";
import { View, Text, Button } from "react-native";
import Modal from "@/components/ui/modal";
export default function AsyncExample() {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleOk = async () => {
setLoading(true);
// Giả lập API call
await new Promise((resolve) => setTimeout(resolve, 2000));
setLoading(false);
setOpen(false);
};
return (
<View>
<Button title="Open Modal" onPress={() => setOpen(true)} />
<Modal
open={open}
title="Async Modal"
confirmLoading={loading}
onOk={handleOk}
onCancel={() => setOpen(false)}
>
<Text>Modal will be closed after 2 seconds when you click OK.</Text>
</Modal>
</View>
);
}
```
### 3. Customized Footer
```tsx
import React, { useState } from "react";
import { View, Text, Button, TouchableOpacity, StyleSheet } from "react-native";
import Modal from "@/components/ui/modal";
export default function CustomFooterExample() {
const [open, setOpen] = useState(false);
return (
<View>
<Button title="Open Modal" onPress={() => setOpen(true)} />
<Modal
open={open}
title="Custom Footer Modal"
onCancel={() => setOpen(false)}
footer={
<View style={styles.customFooter}>
<TouchableOpacity
style={styles.customButton}
onPress={() => setOpen(false)}
>
<Text style={styles.buttonText}>Return</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.customButton, styles.submitButton]}
onPress={() => {
console.log("Submit");
setOpen(false);
}}
>
<Text style={[styles.buttonText, styles.submitText]}>Submit</Text>
</TouchableOpacity>
</View>
}
>
<Text>Custom footer buttons</Text>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
customFooter: {
flexDirection: "row",
gap: 8,
},
customButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 6,
borderWidth: 1,
borderColor: "#d9d9d9",
},
submitButton: {
backgroundColor: "#1890ff",
borderColor: "#1890ff",
},
buttonText: {
fontSize: 14,
color: "#000",
},
submitText: {
color: "#fff",
},
});
```
### 4. No Footer
```tsx
<Modal
open={open}
title="Modal Without Footer"
footer={null}
onCancel={() => setOpen(false)}
>
<Text>Modal content without footer buttons</Text>
</Modal>
```
### 5. Centered Modal
```tsx
<Modal
open={open}
title="Centered Modal"
centered
onOk={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<Text>This modal is centered on the screen</Text>
</Modal>
```
### 6. Custom Width
```tsx
<Modal
open={open}
title="Custom Width Modal"
width={700}
onOk={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<Text>This modal has custom width</Text>
</Modal>
```
### 7. Confirm Modal với useModal Hook
**Đây là cách khuyến nghị sử dụng trong React Native để có context đầy đủ:**
```tsx
import React from "react";
import { View, Button } from "react-native";
import Modal from "@/components/ui/modal";
export default function HookExample() {
const [modal, contextHolder] = Modal.useModal();
const showConfirm = () => {
modal.confirm({
title: "Do you want to delete these items?",
content:
"When clicked the OK button, this dialog will be closed after 1 second",
onOk: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("OK");
},
onCancel: () => {
console.log("Cancel");
},
});
};
const showInfo = () => {
modal.info({
title: "This is a notification message",
content: "Some additional information...",
});
};
const showSuccess = () => {
modal.success({
title: "Success",
content: "Operation completed successfully!",
});
};
const showError = () => {
modal.error({
title: "Error",
content: "Something went wrong!",
});
};
const showWarning = () => {
modal.warning({
title: "Warning",
content: "This is a warning message!",
});
};
return (
<View style={{ padding: 20, gap: 10 }}>
{/* contextHolder phải được đặt trong component */}
{contextHolder}
<Button title="Confirm" onPress={showConfirm} />
<Button title="Info" onPress={showInfo} />
<Button title="Success" onPress={showSuccess} />
<Button title="Error" onPress={showError} />
<Button title="Warning" onPress={showWarning} />
</View>
);
}
```
### 8. Update Modal Instance
```tsx
import React from "react";
import { View, Button } from "react-native";
import Modal from "@/components/ui/modal";
export default function UpdateExample() {
const [modal, contextHolder] = Modal.useModal();
const showModal = () => {
const instance = modal.success({
title: "Loading...",
content: "Please wait...",
});
// Update after 2 seconds
setTimeout(() => {
instance.update({
title: "Success!",
content: "Operation completed successfully!",
});
}, 2000);
// Close after 4 seconds
setTimeout(() => {
instance.destroy();
}, 4000);
};
return (
<View>
{contextHolder}
<Button title="Show Updating Modal" onPress={showModal} />
</View>
);
}
```
## API
### Modal Props
| Prop | Type | Default | Description |
| --------------- | ----------------------------- | --------- | ------------------------------------------------------------ |
| open | boolean | false | Whether the modal dialog is visible or not |
| title | ReactNode | - | The modal dialog's title |
| closable | boolean | true | Whether a close (x) button is visible on top right or not |
| closeIcon | ReactNode | - | Custom close icon |
| maskClosable | boolean | true | Whether to close the modal dialog when the mask is clicked |
| centered | boolean | false | Centered Modal |
| width | number \| string | 520 | Width of the modal dialog |
| confirmLoading | boolean | false | Whether to apply loading visual effect for OK button |
| okText | string | 'OK' | Text of the OK button |
| cancelText | string | 'Cancel' | Text of the Cancel button |
| okType | 'primary' \| 'default' | 'primary' | Button type of the OK button |
| footer | ReactNode \| null | - | Footer content, set as footer={null} to hide default buttons |
| mask | boolean | true | Whether show mask or not |
| zIndex | number | 1000 | The z-index of the Modal |
| onOk | (e?) => void \| Promise<void> | - | Callback when clicking OK button |
| onCancel | (e?) => void | - | Callback when clicking cancel button or close icon |
| afterOpenChange | (open: boolean) => void | - | Callback when animation ends |
| afterClose | () => void | - | Callback when modal is closed completely |
| destroyOnClose | boolean | false | Whether to unmount child components on close |
| keyboard | boolean | true | Whether support press back button to close (Android) |
### Modal.useModal()
Khi bạn cần sử dụng Context, bạn có thể dùng `Modal.useModal()` để tạo `contextHolder` và chèn vào children. Modal được tạo bởi hooks sẽ có tất cả context nơi `contextHolder` được đặt.
**Returns:** `[modalMethods, contextHolder]`
- `modalMethods`: Object chứa các methods
- `info(config)`: Show info modal
- `success(config)`: Show success modal
- `error(config)`: Show error modal
- `warning(config)`: Show warning modal
- `confirm(config)`: Show confirm modal
- `contextHolder`: React element cần được render trong component tree
### Modal Methods Config
| Prop | Type | Default | Description |
| ---------- | -------------------------------------------------------- | --------- | ----------------------------- |
| type | 'info' \| 'success' \| 'error' \| 'warning' \| 'confirm' | 'confirm' | Type of the modal |
| title | ReactNode | - | Title |
| content | ReactNode | - | Content |
| icon | ReactNode | - | Custom icon |
| okText | string | 'OK' | Text of the OK button |
| cancelText | string | 'Cancel' | Text of the Cancel button |
| onOk | (e?) => void \| Promise<void> | - | Callback when clicking OK |
| onCancel | (e?) => void | - | Callback when clicking Cancel |
### Modal Instance
Modal instance được trả về bởi `Modal.useModal()`:
```tsx
interface ModalInstance {
destroy: () => void;
update: (config: ConfirmModalProps) => void;
}
```
## Lưu ý
1. **React Native Limitations**: Các static methods như `Modal.info()`, `Modal.confirm()` gọi trực tiếp (không qua hook) không được hỗ trợ đầy đủ trong React Native do không thể render imperatively. Hãy sử dụng `Modal.useModal()` hook thay thế.
2. **Context Support**: Khi cần sử dụng Context (như Redux, Theme Context), bắt buộc phải dùng `Modal.useModal()` hook và đặt `contextHolder` trong component tree.
3. **Animation**: Modal sử dụng React Native's built-in Modal với `animationType="fade"`.
4. **Icons**: Component sử dụng `@expo/vector-icons` (Ionicons). Đảm bảo đã cài đặt package này.
5. **Keyboard**: Prop `keyboard` trong React Native chỉ hoạt động với nút back của Android (không có ESC key như web).
## So sánh với Ant Design Modal
| Feature | Ant Design (Web) | This Component (RN) |
| --------------------------- | ---------------- | --------------------------- |
| Basic Modal | ✅ | ✅ |
| Centered | ✅ | ✅ |
| Custom Footer | ✅ | ✅ |
| Confirm Dialog | ✅ | ✅ (via useModal) |
| Info/Success/Error/Warning | ✅ | ✅ (via useModal) |
| Async close | ✅ | ✅ |
| Custom width | ✅ | ✅ |
| Mask closable | ✅ | ✅ |
| Keyboard close | ✅ (ESC) | ✅ (Back button on Android) |
| Static methods without hook | ✅ | ⚠️ (Limited support) |
| useModal hook | ✅ | ✅ (Recommended) |
| Draggable | ✅ | ❌ (Not applicable) |
| destroyAll() | ✅ | ❌ |
## License
MIT

View File

@@ -48,3 +48,35 @@ Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. - [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
View File

@@ -0,0 +1,502 @@
# Theme System Documentation
## Tổng quan
Hệ thống theme hỗ trợ **Light Mode**, **Dark Mode****System Mode** (tự động theo hệ thống). Theme preference được lưu trong AsyncStorage và tự động khôi phục khi khởi động lại ứng dụng.
## Kiến trúc Theme System
### 1. Theme Provider (`hooks/use-theme-context.tsx`)
Theme Provider là core của hệ thống theme, quản lý state và đồng bộ với system theme.
**Các tính năng chính:**
- Quản lý `themeMode`: `'light' | 'dark' | 'system'`
- Tự động detect system theme thông qua nhiều nguồn:
- `Appearance.getColorScheme()` - iOS/Android system theme
- `useColorScheme()` hook từ React Native
- `Appearance.addChangeListener()` - listen system theme changes
- `AppState` listener - sync lại khi app active
- Lưu và restore theme preference từ AsyncStorage
- Export ra `colorScheme` cuối cùng: `'light' | 'dark'`
**ThemeContextType:**
```typescript
interface ThemeContextType {
themeMode: ThemeMode; // User's choice: 'light' | 'dark' | 'system'
colorScheme: ColorScheme; // Final theme: 'light' | 'dark'
colors: typeof Colors.light; // Theme colors object
setThemeMode: (mode: ThemeMode) => Promise<void>;
getColor: (colorName: ColorName) => string;
isHydrated: boolean; // AsyncStorage đã load xong
}
```
**Cách hoạt động:**
```typescript
// Xác định colorScheme cuối cùng
const colorScheme: ColorScheme =
themeMode === "system" ? systemScheme : themeMode;
```
### 2. Colors Configuration (`constants/theme.ts`)
Định nghĩa tất cả colors cho light và dark theme:
```typescript
export const Colors = {
light: {
text: "#11181C",
textSecondary: "#687076",
background: "#fff",
backgroundSecondary: "#f5f5f5",
surface: "#ffffff",
surfaceSecondary: "#f8f9fa",
tint: "#0a7ea4",
primary: "#007AFF",
secondary: "#5AC8FA",
success: "#34C759",
warning: "#ff6600",
error: "#FF3B30",
icon: "#687076",
border: "#C6C6C8",
separator: "#E5E5E7",
card: "#ffffff",
// ... more colors
},
dark: {
text: "#ECEDEE",
textSecondary: "#8E8E93",
background: "#000000",
backgroundSecondary: "#1C1C1E",
surface: "#1C1C1E",
surfaceSecondary: "#2C2C2E",
tint: "#fff",
primary: "#0A84FF",
secondary: "#64D2FF",
success: "#30D158",
warning: "#ff6600",
error: "#FF453A",
icon: "#8E8E93",
border: "#38383A",
separator: "#38383A",
card: "#1C1C1E",
// ... more colors
},
};
export type ColorName = keyof typeof Colors.light;
```
### 3. Setup trong App (`app/_layout.tsx`)
Theme Provider phải wrap toàn bộ app:
```tsx
export default function RootLayout() {
return (
<I18nProvider>
<AppThemeProvider>
<AppContent />
</AppThemeProvider>
</I18nProvider>
);
}
function AppContent() {
const { colorScheme } = useThemeContext();
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack>{/* ... routes */}</Stack>
<StatusBar style="auto" />
</ThemeProvider>
);
}
```
## Cách sử dụng Theme
### 1. useThemeContext (Core Hook)
Hook chính để access theme state:
```tsx
import { useThemeContext } from "@/hooks/use-theme-context";
function MyComponent() {
const {
themeMode, // 'light' | 'dark' | 'system'
colorScheme, // 'light' | 'dark'
colors, // Colors object
setThemeMode, // Change theme
getColor, // Get color by name
isHydrated, // AsyncStorage loaded
} = useThemeContext();
return (
<View style={{ backgroundColor: colors.background }}>
<Text style={{ color: colors.text }}>
Mode: {themeMode}, Scheme: {colorScheme}
</Text>
</View>
);
}
```
### 2. useColorScheme Hook
Alias để lấy colorScheme nhanh:
```tsx
import { useColorScheme } from "@/hooks/use-theme-context";
function MyComponent() {
const colorScheme = useColorScheme(); // 'light' | 'dark'
return <Text>Current theme: {colorScheme}</Text>;
}
```
**⚠️ Lưu ý:** `useColorScheme` từ `use-theme-context.tsx`, KHÔNG phải từ `react-native`.
### 3. useThemeColor Hook
Override colors cho specific themes:
```tsx
import { useThemeColor } from "@/hooks/use-theme-color";
function MyComponent() {
// Với override
const backgroundColor = useThemeColor(
{ light: "#ffffff", dark: "#1C1C1E" },
"surface"
);
// Không override, dùng color từ theme
const textColor = useThemeColor({}, "text");
return (
<View style={{ backgroundColor }}>
<Text style={{ color: textColor }}>Text</Text>
</View>
);
}
```
**Cách hoạt động:**
```typescript
// Ưu tiên props override trước, sau đó mới dùng Colors
const colorFromProps = props[colorScheme];
return colorFromProps || Colors[colorScheme][colorName];
```
### 4. useAppTheme Hook (Recommended)
Hook tiện lợi với pre-built styles và utilities:
```tsx
import { useAppTheme } from "@/hooks/use-app-theme";
function MyComponent() {
const { colors, styles, utils } = useAppTheme();
return (
<View style={styles.container}>
<TouchableOpacity style={styles.primaryButton}>
<Text style={styles.primaryButtonText}>Primary Button</Text>
</TouchableOpacity>
<View style={styles.card}>
<Text style={{ color: colors.text }}>
Theme is {utils.isDark ? "Dark" : "Light"}
</Text>
</View>
<View
style={{
backgroundColor: utils.getOpacityColor("primary", 0.1),
}}
>
<Text>Transparent background</Text>
</View>
</View>
);
}
```
### 5. Themed Components
**ThemedView****ThemedText** - Tự động apply theme colors:
```tsx
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
function MyComponent() {
return (
<ThemedView>
<ThemedText type="title">Title Text</ThemedText>
<ThemedText type="subtitle">Subtitle</ThemedText>
<ThemedText type="default">Regular Text</ThemedText>
<ThemedText type="link">Link Text</ThemedText>
{/* Override với custom colors */}
<ThemedText lightColor="#000000" darkColor="#FFFFFF">
Custom colored text
</ThemedText>
</ThemedView>
);
}
```
**ThemedText types:**
- `default` - 16px regular
- `title` - 32px bold
- `subtitle` - 20px bold
- `defaultSemiBold` - 16px semibold
- `link` - 16px với color #0a7ea4
### 6. Theme Toggle Component
Component có sẵn để user chọn theme:
```tsx
import { ThemeToggle } from "@/components/theme-toggle";
function SettingsScreen() {
return (
<View>
<ThemeToggle />
</View>
);
}
```
Component này hiển thị 3 options: Light, Dark, System với icons và labels đa ngôn ngữ.
## Available Styles từ useAppTheme
```typescript
const { styles } = useAppTheme();
// Container styles
styles.container; // Flex 1 container với background
styles.surface; // Surface với padding 16, borderRadius 12
styles.card; // Card với shadow, elevation
// Button styles
styles.primaryButton; // Primary button với colors.primary
styles.secondaryButton; // Secondary button với border
styles.primaryButtonText; // White text cho primary button
styles.secondaryButtonText; // Theme text cho secondary button
// Input styles
styles.textInput; // Text input với border, padding
// Status styles
styles.successContainer; // Success background với border
styles.warningContainer; // Warning background với border
styles.errorContainer; // Error background với border
// Utility
styles.separator; // 1px line separator
```
## Theme Utilities
```typescript
const { utils } = useAppTheme();
// Check theme
utils.isDark; // boolean - true nếu dark mode
utils.isLight; // boolean - true nếu light mode
// Toggle theme (ignores system mode)
utils.toggleTheme(); // Switch giữa light ↔ dark
// Get color với opacity
utils.getOpacityColor("primary", 0.1); // rgba(0, 122, 255, 0.1)
```
## Luồng hoạt động của Theme System
```
1. App khởi động
└─→ ThemeProvider mount
├─→ Load saved themeMode từ AsyncStorage ('light'/'dark'/'system')
├─→ Detect systemScheme từ OS
│ ├─→ Appearance.getColorScheme()
│ ├─→ useColorScheme() hook
│ └─→ Appearance.addChangeListener()
└─→ Tính toán colorScheme cuối cùng
└─→ themeMode === 'system' ? systemScheme : themeMode
2. User thay đổi system theme
└─→ Appearance listener fire
└─→ Update systemScheme state
└─→ Nếu themeMode === 'system'
└─→ colorScheme tự động update
└─→ Components re-render với colors mới
3. User chọn theme trong app
└─→ setThemeMode('light'/'dark'/'system')
├─→ Update themeMode state
├─→ Save vào AsyncStorage
└─→ colorScheme update
└─→ Components re-render
4. App về foreground
└─→ AppState listener fire
└─→ Sync lại systemScheme (phòng user đổi system theme khi app background)
```
## Storage
Theme preference được lưu với key: `'theme_mode'`
```typescript
// Tự động xử lý bởi ThemeProvider
await setStorageItem("theme_mode", "light" | "dark" | "system");
const savedMode = await getStorageItem("theme_mode");
```
## Best Practices
1. **Sử dụng hooks đúng context:**
- `useThemeContext()` - Khi cần full control (themeMode, setThemeMode)
- `useColorScheme()` - Chỉ cần biết light/dark
- `useAppTheme()` - Recommended cho UI components (có styles + utils)
- `useThemeColor()` - Khi cần override colors
2. **Sử dụng Themed Components:**
```tsx
// Good ✅
<ThemedView>
<ThemedText>Hello</ThemedText>
</ThemedView>;
// Also good ✅
const { colors } = useAppTheme();
<View style={{ backgroundColor: colors.background }}>
<Text style={{ color: colors.text }}>Hello</Text>
</View>;
```
3. **Tận dụng pre-built styles:**
```tsx
// Good ✅
const { styles } = useAppTheme();
<TouchableOpacity style={styles.primaryButton}>
// Less good ❌
<TouchableOpacity style={{
backgroundColor: colors.primary,
paddingVertical: 14,
borderRadius: 12
}}>
```
4. **Sử dụng opacity colors:**
```tsx
const { utils } = useAppTheme();
<View
style={{
backgroundColor: utils.getOpacityColor("primary", 0.1),
}}
/>;
```
5. **Check theme correctly:**
```tsx
// Good ✅
const { utils } = useAppTheme();
if (utils.isDark) { ... }
// Also good ✅
const { colorScheme } = useThemeContext();
if (colorScheme === 'dark') { ... }
```
## Troubleshooting
### Theme không được lưu
- Kiểm tra AsyncStorage permissions
- Check logs trong console: `[Theme] Failed to save theme mode:`
### Flash màu sắc khi khởi động
- ThemeProvider đã xử lý với `isHydrated` state
- Chờ AsyncStorage load xong trước khi render
### System theme không update
- Check Appearance listener đã register: `[Theme] Registering appearance listener`
- Check logs: `[Theme] System theme changed to: ...`
- iOS: Restart app sau khi đổi system theme
- Android: Cần `expo-system-ui` plugin trong `app.json`
### Colors không đúng
- Đảm bảo app wrapped trong `<AppThemeProvider>`
- Check `colorScheme` trong console logs
- Verify Colors object trong `constants/theme.ts`
## Migration Guide
Nếu đang dùng old theme system:
```tsx
// Old ❌
import { useColorScheme } from "react-native";
const colorScheme = useColorScheme();
const backgroundColor = colorScheme === "dark" ? "#000" : "#fff";
// New ✅
import { useAppTheme } from "@/hooks/use-app-theme";
const { colors } = useAppTheme();
const backgroundColor = colors.background;
```
```tsx
// Old ❌
const [theme, setTheme] = useState("light");
// New ✅
import { useThemeContext } from "@/hooks/use-theme-context";
const { themeMode, setThemeMode } = useThemeContext();
```
## Debug Logs
Enable logs để debug theme issues:
```tsx
// Trong use-theme-context.tsx, uncomment các dòng:
console.log("[Theme] Appearance.getColorScheme():", scheme);
console.log("[Theme] System theme changed to:", newScheme);
console.log("[Theme] Mode:", themeMode);
console.log("[Theme] Derived colorScheme:", colorScheme);
```
```tsx
// Trong use-theme-color.ts:
console.log("Detected theme:", theme); // Đã có sẵn
```
```tsx
// Trong _layout.tsx:
console.log("Color Scheme: ", colorScheme); // Đã có sẵn
```

View File

@@ -9,7 +9,14 @@
"userInterfaceStyle": "automatic", "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",
{ {
@@ -50,8 +63,14 @@
"expo-localization", "expo-localization",
{ {
"supportedLocales": { "supportedLocales": {
"ios": ["en", "vi"], "ios": [
"android": ["en", "vi"] "en",
"vi"
],
"android": [
"en",
"vi"
]
} }
} }
] ]
@@ -59,6 +78,12 @@
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
"reactCompiler": true "reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "d4ef1318-5427-4c96-ad88-97ec117829cc"
}
} }
} }
} }

View File

@@ -3,8 +3,8 @@ 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 { useI18n } from "@/hooks/use-i18n";
import { useColorScheme } from "@/hooks/use-theme-context";
import { startEvents, stopEvents } from "@/services/device_events"; import { startEvents, stopEvents } from "@/services/device_events";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";

View File

@@ -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,
@@ -53,7 +54,7 @@ export default function HomeScreen() {
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;

View File

@@ -1,15 +1,18 @@
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { StyleSheet, View } from "react-native"; import { ScrollView, StyleSheet, View } from "react-native";
import EnIcon from "@/assets/icons/en_icon.png"; import EnIcon from "@/assets/icons/en_icon.png";
import VnIcon from "@/assets/icons/vi_icon.png"; import VnIcon from "@/assets/icons/vi_icon.png";
import RotateSwitch from "@/components/rotate-switch"; 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 { DOMAIN, TOKEN } from "@/constants"; import { DOMAIN, TOKEN } from "@/constants";
import { useAppTheme } from "@/hooks/use-app-theme";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { removeStorageItem } from "@/utils/storage"; import { removeStorageItem } from "@/utils/storage";
import { SafeAreaView } from "react-native-safe-area-context";
type Todo = { type Todo = {
userId: number; userId: number;
id: number; id: number;
@@ -21,7 +24,9 @@ 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 { t, locale, setLocale } = useI18n();
const { colors } = useAppTheme();
const [isEnabled, setIsEnabled] = useState(locale === "vi"); const [isEnabled, setIsEnabled] = useState(locale === "vi");
// Sync isEnabled state khi locale thay đổi // Sync isEnabled state khi locale thay đổi
useEffect(() => { useEffect(() => {
setIsEnabled(locale === "vi"); setIsEnabled(locale === "vi");
@@ -33,18 +38,31 @@ export default function SettingScreen() {
}; };
return ( return (
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedText type="title">{t("navigation.setting")}</ThemedText> <ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<ThemedText type="title" style={styles.title}>
{t("navigation.setting")}
</ThemedText>
<View style={styles.settingItem}> {/* 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> <ThemedText type="default">{t("common.language")}</ThemedText>
{/* <Switch
trackColor={{ false: "#767577", true: "#81b0ff" }}
thumbColor={isEnabled ? "#f5dd4b" : "#f4f3f4"}
ios_backgroundColor="#3e3e3e"
onValueChange={toggleSwitch}
value={isEnabled}
/> */}
<RotateSwitch <RotateSwitch
initialValue={isEnabled} initialValue={isEnabled}
onChange={toggleSwitch} onChange={toggleSwitch}
@@ -54,51 +72,82 @@ export default function SettingScreen() {
/> />
</View> </View>
{/* Logout Button */}
<ThemedView <ThemedView
style={styles.button} style={[styles.button, { backgroundColor: colors.primary }]}
onTouchEnd={async () => { onTouchEnd={async () => {
await removeStorageItem(TOKEN); await removeStorageItem(TOKEN);
await removeStorageItem(DOMAIN); await removeStorageItem(DOMAIN);
router.navigate("/auth/login"); router.navigate("/auth/login");
}} }}
> >
<ThemedText type="defaultSemiBold">{t("auth.logout")}</ThemedText> <ThemedText type="defaultSemiBold" style={styles.buttonText}>
{t("auth.logout")}
</ThemedText>
</ThemedView> </ThemedView>
{data && ( {data && (
<ThemedView style={{ marginTop: 20 }}> <ThemedView
style={[styles.debugSection, { backgroundColor: colors.surface }]}
>
<ThemedText type="default">{data.title}</ThemedText> <ThemedText type="default">{data.title}</ThemedText>
<ThemedText type="default">{data.completed}</ThemedText> <ThemedText type="default">{data.completed}</ThemedText>
<ThemedText type="default">{data.id}</ThemedText> <ThemedText type="default">{data.id}</ThemedText>
</ThemedView> </ThemedView>
)} )}
</ScrollView>
</ThemedView> </ThemedView>
</SafeAreaView>
); );
} }
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, gap: 16,
}, },
title: {
textAlign: "center",
marginBottom: 20,
},
themeSection: {
marginBottom: 8,
},
settingItem: { settingItem: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
width: "100%",
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 16,
borderRadius: 8, borderRadius: 12,
backgroundColor: "rgba(0, 122, 255, 0.1)", borderWidth: 1,
}, },
button: { button: {
marginTop: 20, marginTop: 20,
paddingVertical: 12, paddingVertical: 14,
paddingHorizontal: 20, paddingHorizontal: 20,
backgroundColor: "#007AFF", borderRadius: 12,
borderRadius: 8, alignItems: "center",
justifyContent: "center",
},
buttonText: {
color: "#fff",
fontSize: 16,
},
debugSection: {
marginTop: 20,
padding: 16,
borderRadius: 12,
gap: 8,
}, },
}); });

View File

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

View File

@@ -9,27 +9,25 @@ 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 { 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 (
<I18nProvider> <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<GluestackUIProvider>
<ThemeProvider
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<Stack <Stack
screenOptions={{ headerShown: false }} screenOptions={{ headerShown: false }}
initialRouteName="auth/login" initialRouteName="auth/login"
@@ -58,7 +56,15 @@ export default function RootLayout() {
<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> </I18nProvider>
); );
} }

View File

@@ -6,8 +6,14 @@ import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view"; import { ThemedView } from "@/components/themed-view";
import SliceSwitch from "@/components/ui/slice-switch"; import SliceSwitch from "@/components/ui/slice-switch";
import { DOMAIN, TOKEN } from "@/constants"; import { DOMAIN, TOKEN } from "@/constants";
import { Colors } from "@/constants/theme";
import { login } from "@/controller/AuthController"; import { login } from "@/controller/AuthController";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import {
ColorScheme as ThemeColorScheme,
useTheme,
useThemeContext,
} from "@/hooks/use-theme-context";
import { showErrorToast, showWarningToast } from "@/services/toast_service"; import { showErrorToast, showWarningToast } from "@/services/toast_service";
import { import {
getStorageItem, getStorageItem,
@@ -17,7 +23,7 @@ import {
import { parseJwtToken } from "@/utils/token"; import { parseJwtToken } from "@/utils/token";
import { Ionicons, MaterialIcons } from "@expo/vector-icons"; 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,
@@ -38,6 +44,14 @@ export default function LoginScreen() {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isShowingQRScanner, setIsShowingQRScanner] = useState(false); const [isShowingQRScanner, setIsShowingQRScanner] = useState(false);
const { t, setLocale, locale } = useI18n(); const { 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 [isVNLang, setIsVNLang] = useState(false);
const checkLogin = useCallback(async () => { const checkLogin = useCallback(async () => {
@@ -154,7 +168,10 @@ export default function LoginScreen() {
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}>
@@ -177,7 +194,7 @@ export default function LoginScreen() {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder={t("auth.username_placeholder")} placeholder={t("auth.username_placeholder")}
placeholderTextColor="#999" placeholderTextColor={placeholderColor}
value={username} value={username}
onChangeText={setUsername} onChangeText={setUsername}
editable={!loading} editable={!loading}
@@ -192,7 +209,7 @@ export default function LoginScreen() {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder={t("auth.password_placeholder")} placeholder={t("auth.password_placeholder")}
placeholderTextColor="#999" placeholderTextColor={placeholderColor}
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
secureTextEntry={!showPassword} secureTextEntry={!showPassword}
@@ -217,7 +234,7 @@ export default function LoginScreen() {
<Ionicons <Ionicons
name={showPassword ? "eye-off" : "eye"} name={showPassword ? "eye-off" : "eye"}
size={22} size={22}
color="#666" color={colors.icon}
/> />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -241,9 +258,13 @@ export default function LoginScreen() {
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
<ActivityIndicator color="#fff" size="small" /> <ActivityIndicator color={buttonTextColor} size="small" />
) : ( ) : (
<Text style={styles.loginButtonText}>{t("auth.login")}</Text> <Text
style={[styles.loginButtonText, { color: buttonTextColor }]}
>
{t("auth.login")}
</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -252,10 +273,10 @@ export default function LoginScreen() {
flex: 1, flex: 1,
paddingVertical: 10, paddingVertical: 10,
marginTop: 0, marginTop: 0,
borderColor: "#ddd", borderColor: colors.border,
borderWidth: 1, borderWidth: 1,
borderRadius: 8, borderRadius: 8,
backgroundColor: "transparent", backgroundColor: colors.surface,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}} }}
@@ -265,7 +286,7 @@ export default function LoginScreen() {
<MaterialIcons <MaterialIcons
name="qr-code-scanner" name="qr-code-scanner"
size={28} size={28}
color="#007AFF" color={colors.primary}
/> />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -282,13 +303,21 @@ export default function LoginScreen() {
<SliceSwitch <SliceSwitch
size="sm" size="sm"
leftIcon="moon" leftIcon="moon"
leftIconColor="white" leftIconColor={
colorScheme === "dark" ? colors.background : colors.surface
}
rightIcon="sunny" rightIcon="sunny"
rightIconColor="orange" rightIconColor={
activeBackgroundColor="black" colorScheme === "dark" ? colors.warning : "orange"
inactiveBackgroundColor="white" }
inactiveOverlayColor="black" activeBackgroundColor={colors.text}
activeOverlayColor="white" inactiveBackgroundColor={colors.surface}
inactiveOverlayColor={colors.textSecondary}
activeOverlayColor={colors.background}
value={colorScheme === "light"}
onChange={(val) => {
setThemeMode(val ? "light" : "dark");
}}
/> />
</View> </View>
@@ -310,10 +339,12 @@ export default function LoginScreen() {
); );
} }
const styles = StyleSheet.create({ const createStyles = (colors: typeof Colors.light, scheme: ThemeColorScheme) =>
StyleSheet.create({
scrollContainer: { scrollContainer: {
flexGrow: 1, flexGrow: 1,
justifyContent: "center", justifyContent: "center",
backgroundColor: colors.background,
}, },
container: { container: {
flex: 1, flex: 1,
@@ -350,16 +381,16 @@ const styles = StyleSheet.create({
}, },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: "#ddd", borderColor: colors.border,
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 12, paddingVertical: 12,
fontSize: 16, fontSize: 16,
backgroundColor: "#f5f5f5", backgroundColor: colors.surface,
color: "#000", color: colors.text,
}, },
loginButton: { loginButton: {
backgroundColor: "#007AFF", backgroundColor: colors.primary,
paddingVertical: 14, paddingVertical: 14,
borderRadius: 8, borderRadius: 8,
alignItems: "center", alignItems: "center",
@@ -369,7 +400,6 @@ const styles = StyleSheet.create({
opacity: 0.6, opacity: 0.6,
}, },
loginButtonText: { loginButtonText: {
color: "#fff",
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: "600",
}, },
@@ -381,7 +411,7 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
}, },
linkText: { linkText: {
color: "#007AFF", color: colors.primary,
fontWeight: "600", fontWeight: "600",
}, },
copyrightContainer: { copyrightContainer: {
@@ -392,12 +422,12 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
opacity: 0.6, opacity: 0.6,
textAlign: "center", textAlign: "center",
color: colors.textSecondary,
}, },
languageSwitcherContainer: { languageSwitcherContainer: {
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginTop: 24, marginTop: 24,
gap: 20, gap: 20,
@@ -417,22 +447,22 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
paddingHorizontal: 12, paddingHorizontal: 12,
borderWidth: 1, borderWidth: 1,
borderColor: "#ddd", borderColor: colors.border,
borderRadius: 8, borderRadius: 8,
alignItems: "center", alignItems: "center",
backgroundColor: "transparent", backgroundColor: colors.surface,
}, },
languageButtonActive: { languageButtonActive: {
backgroundColor: "#007AFF", backgroundColor: colors.primary,
borderColor: "#007AFF", borderColor: colors.primary,
}, },
languageButtonText: { languageButtonText: {
fontSize: 14, fontSize: 14,
fontWeight: "500", fontWeight: "500",
color: "#666", color: colors.textSecondary,
}, },
languageButtonTextActive: { languageButtonTextActive: {
color: "#fff", color: scheme === "dark" ? colors.text : colors.surface,
fontWeight: "600", fontWeight: "600",
}, },
}); });

View File

@@ -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 {
@@ -93,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
@@ -101,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,
]} ]}
@@ -112,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}
@@ -131,24 +143,41 @@ 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
@@ -160,8 +189,13 @@ const Select: React.FC<SelectProps> = ({
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}
@@ -169,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>
))} ))}
@@ -193,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",
@@ -204,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",
@@ -220,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,
@@ -236,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,
@@ -247,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",
@@ -255,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;

View File

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

View File

@@ -1,3 +1,4 @@
import { useAppTheme } from "@/hooks/use-app-theme";
import { useI18n } from "@/hooks/use-i18n"; 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";
@@ -15,6 +16,8 @@ const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
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 { t } = useI18n();
const { colors, styles } = useAppTheme();
useEffect(() => { useEffect(() => {
Animated.timing(translateY, { Animated.timing(translateY, {
toValue: isExpanded ? 0 : 200, // Dịch chuyển xuống 200px khi thu gọn toValue: isExpanded ? 0 : 200, // Dịch chuyển xuống 200px khi thu gọn
@@ -44,45 +47,35 @@ 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={[
styles.card,
{
transform: [{ translateY }], transform: [{ translateY }],
}} backgroundColor: colors.card,
className="absolute bottom-0 gap-3 right-0 p-3 left-0 h-auto w-full rounded-t-xl bg-white shadow-md" 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>
@@ -120,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>
)} )}
</> </>

View File

@@ -8,24 +8,11 @@ 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>();
@@ -34,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(); 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);
@@ -58,8 +61,6 @@ 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 = t("home.sos.statusRequired"); newErrors.customMessage = t("home.sos.statusRequired");
} }
@@ -69,7 +70,11 @@ const SosButton = () => {
}; };
const handleConfirmSos = async () => { const handleConfirmSos = async () => {
if (validateForm()) { if (!validateForm()) {
console.log("Form chưa validate");
return; // Không đóng modal nếu validate fail
}
let messageToSend = ""; let messageToSend = "";
if (selectedSosMessage === 999) { if (selectedSosMessage === 999) {
messageToSend = customMessage.trim(); messageToSend = customMessage.trim();
@@ -79,17 +84,20 @@ const SosButton = () => {
); );
messageToSend = selectedOption ? selectedOption.moTa : ""; messageToSend = selectedOption ? selectedOption.moTa : "";
} }
// Gửi dữ liệu đi // Gửi dữ liệu đi
await sendSosMessage(messageToSend);
// Đóng modal và reset form sau khi gửi thành công
setShowConfirmSosDialog(false); setShowConfirmSosDialog(false);
// Reset form
setSelectedSosMessage(null); setSelectedSosMessage(null);
setCustomMessage(""); setCustomMessage("");
setErrors({}); setErrors({});
await sendSosMessage(messageToSend);
}
}; };
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) {
@@ -115,66 +123,55 @@ const SosButton = () => {
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" />
<ButtonText className="text-center">
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")} {sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
</ButtonText> </IconButton>
{/* <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>
<ModalHeader className="flex-col gap-0.5 items-center">
<Text
style={{ fontSize: 18, fontWeight: "bold", textAlign: "center" }}
>
{t("home.sos.title")}
</Text>
</ModalHeader>
<ModalBody className="mb-4">
<ScrollView style={{ maxHeight: 400 }}>
{/* Dropdown Nội dung SOS */}
<View style={styles.formGroup}> <View style={styles.formGroup}>
<Text style={styles.label}>{t("home.sos.content")}</Text> <Text style={styles.label}>{t("home.sos.content")}</Text>
<TouchableOpacity
style={[ <Select
styles.dropdownButton, value={selectedSosMessage ?? undefined}
errors.sosMessage ? styles.errorBorder : {}, options={sosOptions}
]} placeholder={t("home.sos.selectReason")}
onPress={() => setShowDropdown(!showDropdown)} onChange={(value) => {
> setSelectedSosMessage(value as number);
<Text // Clear custom message nếu chọn khác lý do
style={[ if (value !== 999) {
styles.dropdownButtonText, setCustomMessage("");
!selectedSosMessage && styles.placeholderText, }
]} // Clear error if exists
> if (errors.sosMessage) {
{selectedSosMessage !== null setErrors((prev) => {
? sosOptions.find((opt) => opt.ma === selectedSosMessage) const newErrors = { ...prev };
?.moTa || t("home.sos.selectReason") delete newErrors.sosMessage;
: t("home.sos.selectReason")} return newErrors;
</Text> });
<MaterialIcons }
name={showDropdown ? "expand-less" : "expand-more"} }}
size={20} showSearch={false}
color="#666" style={[errors.sosMessage ? styles.errorBorder : undefined]}
/> />
</TouchableOpacity>
{errors.sosMessage && ( {errors.sosMessage && (
<Text style={styles.errorText}>{errors.sosMessage}</Text> <Text style={styles.errorText}>{errors.sosMessage}</Text>
)} )}
@@ -190,7 +187,7 @@ const SosButton = () => {
errors.customMessage ? styles.errorInput : {}, errors.customMessage ? styles.errorInput : {},
]} ]}
placeholder={t("home.sos.enterStatus")} placeholder={t("home.sos.enterStatus")}
placeholderTextColor="#999" placeholderTextColor={textColor + '99'} // Add transparency
value={customMessage} value={customMessage}
onChangeText={(text) => { onChangeText={(text) => {
setCustomMessage(text); setCustomMessage(text);
@@ -210,77 +207,12 @@ const SosButton = () => {
)} )}
</View> </View>
)} )}
</ScrollView>
</ModalBody>
<ModalFooter className="flex-row items-start gap-2">
<Button
onPress={handleConfirmSos}
// className="w-1/3"
action="negative"
>
<ButtonText>{t("home.sos.confirm")}</ButtonText>
</Button>
<Button
onPress={() => {
setShowConfirmSosDialog(false);
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
}}
// className="w-1/3"
action="secondary"
>
<ButtonText>{t("home.sos.cancel")}</ButtonText>
</Button>
</ModalFooter>
</ModalContent>
</Modal> </Modal>
{/* Dropdown Modal - Nổi lên */}
{showDropdown && showConfirmSosDialog && (
<Modal isOpen={showDropdown} onClose={() => setShowDropdown(false)}>
<TouchableOpacity
style={styles.dropdownOverlay}
activeOpacity={1}
onPress={() => setShowDropdown(false)}
>
<View style={styles.dropdownModalContainer}>
<FlatList
data={sosOptions}
keyExtractor={(item) => item.ma.toString()}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.dropdownModalItem}
onPress={() => {
setSelectedSosMessage(item.ma);
setShowDropdown(false);
// Clear custom message nếu chọn khác lý do
if (item.ma !== 999) {
setCustomMessage("");
}
}}
>
<Text
style={[
styles.dropdownModalItemText,
selectedSosMessage === item.ma &&
styles.selectedItemText,
]}
>
{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,
}, },
@@ -288,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,
}, },

View File

@@ -8,8 +8,8 @@ import Animated, {
} 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(() => {

View File

@@ -0,0 +1,154 @@
/**
* Example component demonstrating theme usage
* Shows different ways to use the theme system
*/
import React from "react";
import { View, Text, TouchableOpacity, ScrollView } from "react-native";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { useAppTheme } from "@/hooks/use-app-theme";
import { useThemeColor } from "@/hooks/use-theme-color";
export function ThemeExampleComponent() {
const { colors, styles, utils } = useAppTheme();
// Example of using useThemeColor hook
const customTextColor = useThemeColor({}, "textSecondary");
const customBackgroundColor = useThemeColor({}, "surfaceSecondary");
return (
<ScrollView style={styles.container}>
<ThemedView style={styles.surface}>
<ThemedText type="title">Theme Examples</ThemedText>
{/* Using themed components */}
<ThemedText type="subtitle">Themed Components</ThemedText>
<ThemedView style={styles.card}>
<ThemedText>This is a themed text</ThemedText>
<ThemedText type="defaultSemiBold">
This is bold themed text
</ThemedText>
</ThemedView>
{/* Using theme colors directly */}
<ThemedText type="subtitle">Direct Color Usage</ThemedText>
<View
style={[styles.card, { borderColor: colors.primary, borderWidth: 2 }]}
>
<Text style={{ color: colors.text, fontSize: 16 }}>
Using colors.text directly
</Text>
<Text
style={{ color: colors.primary, fontSize: 14, fontWeight: "600" }}
>
Primary color text
</Text>
</View>
{/* Using pre-styled components */}
<ThemedText type="subtitle">Pre-styled Components</ThemedText>
<View style={styles.card}>
<TouchableOpacity style={styles.primaryButton}>
<Text style={styles.primaryButtonText}>Primary Button</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.secondaryButton}>
<Text style={styles.secondaryButtonText}>Secondary Button</Text>
</TouchableOpacity>
</View>
{/* Status containers */}
<ThemedText type="subtitle">Status Indicators</ThemedText>
<View style={styles.card}>
<View style={styles.successContainer}>
<Text style={{ color: colors.success, fontWeight: "600" }}>
Success Message
</Text>
</View>
<View style={styles.warningContainer}>
<Text style={{ color: colors.warning, fontWeight: "600" }}>
Warning Message
</Text>
</View>
<View style={styles.errorContainer}>
<Text style={{ color: colors.error, fontWeight: "600" }}>
Error Message
</Text>
</View>
</View>
{/* Using opacity colors */}
<ThemedText type="subtitle">Opacity Colors</ThemedText>
<View style={styles.card}>
<View
style={[
styles.surface,
{ backgroundColor: utils.getOpacityColor("primary", 0.1) },
]}
>
<Text style={{ color: colors.primary }}>
Primary with 10% opacity background
</Text>
</View>
<View
style={[
styles.surface,
{ backgroundColor: utils.getOpacityColor("error", 0.2) },
]}
>
<Text style={{ color: colors.error }}>
Error with 20% opacity background
</Text>
</View>
</View>
{/* Theme utilities */}
<ThemedText type="subtitle">Theme Utilities</ThemedText>
<View style={styles.card}>
<Text style={{ color: colors.text }}>
Is Dark Mode: {utils.isDark ? "Yes" : "No"}
</Text>
<Text style={{ color: colors.text }}>
Is Light Mode: {utils.isLight ? "Yes" : "No"}
</Text>
<TouchableOpacity
style={[styles.primaryButton, { marginTop: 10 }]}
onPress={utils.toggleTheme}
>
<Text style={styles.primaryButtonText}>
Toggle Theme (Light/Dark)
</Text>
</TouchableOpacity>
</View>
{/* Custom themed component example */}
<ThemedText type="subtitle">Custom Component</ThemedText>
<View
style={[
styles.card,
{
backgroundColor: customBackgroundColor,
borderColor: colors.border,
borderWidth: 1,
},
]}
>
<Text
style={{
color: customTextColor,
fontSize: 16,
textAlign: "center",
}}
>
Custom component using useThemeColor
</Text>
</View>
</ThemedView>
</ScrollView>
);
}

109
components/theme-toggle.tsx Normal file
View File

@@ -0,0 +1,109 @@
/**
* Theme Toggle Component for switching between light, dark, and system themes
*/
import React from "react";
import { View, TouchableOpacity, StyleSheet } from "react-native";
import { ThemedText } from "@/components/themed-text";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useI18n } from "@/hooks/use-i18n";
import { Ionicons } from "@expo/vector-icons";
interface ThemeToggleProps {
style?: any;
}
export function ThemeToggle({ style }: ThemeToggleProps) {
const { themeMode, setThemeMode, colors } = useThemeContext();
const { t } = useI18n();
const themeOptions = [
{
mode: "light" as const,
label: t("common.theme_light"),
icon: "sunny-outline" as const,
},
{
mode: "dark" as const,
label: t("common.theme_dark"),
icon: "moon-outline" as const,
},
{
mode: "system" as const,
label: t("common.theme_system"),
icon: "phone-portrait-outline" as const,
},
];
return (
<View
style={[styles.container, style, { backgroundColor: colors.surface }]}
>
<ThemedText style={styles.title}>{t("common.theme")}</ThemedText>
<View style={styles.optionsContainer}>
{themeOptions.map((option) => (
<TouchableOpacity
key={option.mode}
style={[
styles.option,
{
backgroundColor:
themeMode === option.mode
? colors.primary
: colors.backgroundSecondary,
borderColor: colors.border,
},
]}
onPress={() => setThemeMode(option.mode)}
>
<Ionicons
name={option.icon}
size={20}
color={themeMode === option.mode ? "#fff" : colors.icon}
/>
<ThemedText
style={[
styles.optionText,
{ color: themeMode === option.mode ? "#fff" : colors.text },
]}
>
{option.label}
</ThemedText>
</TouchableOpacity>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
borderRadius: 12,
marginVertical: 8,
},
title: {
fontSize: 16,
fontWeight: "600",
marginBottom: 12,
},
optionsContainer: {
flexDirection: "row",
gap: 8,
},
option: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 12,
paddingHorizontal: 8,
borderRadius: 8,
borderWidth: 1,
gap: 6,
},
optionText: {
fontSize: 14,
fontWeight: "500",
},
});

View File

@@ -1,10 +1,12 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n"; 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);
@@ -15,6 +17,9 @@ const CrewListTable: React.FC = () => {
null null
); );
const { t } = useI18n(); const { t } = useI18n();
const { colorScheme } = useAppTheme();
const { colors } = useThemeContext();
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
const { trip } = useTrip(); const { trip } = useTrip();
@@ -60,7 +65,7 @@ const CrewListTable: React.FC = () => {
<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>

View File

@@ -1,15 +1,20 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n"; 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 { 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 ?? [];
@@ -38,7 +43,7 @@ const FishingToolsTable: React.FC = () => {
<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>

View File

@@ -2,10 +2,12 @@ import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n"; 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 CreateOrUpdateHaulModal from "./modal/CreateOrUpdateHaulModal"; 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);
@@ -14,17 +16,16 @@ const NetListTable: React.FC = () => {
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 { 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();
}, []); }, []);
// useEffect(() => {
// console.log("Trip thay đổi: ", trip?.fishing_logs?.length);
// }, [trip]);
// const data: Model.FishingLog[] = trip?.fishing_logs ?? [];
const handleToggle = () => { const handleToggle = () => {
const toValue = collapsed ? contentHeight : 0; const toValue = collapsed ? contentHeight : 0;
Animated.timing(animatedHeight, { Animated.timing(animatedHeight, {
@@ -60,7 +61,7 @@ const NetListTable: React.FC = () => {
<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>

View File

@@ -0,0 +1,29 @@
/**
* Wrapper component to easily apply theme-aware table styles
*/
import React, { useMemo } from "react";
import { View, ViewProps } from "react-native";
import { useAppTheme } from "@/hooks/use-app-theme";
import { createTableStyles } from "./style/createTableStyles";
interface ThemedTableProps extends ViewProps {
children: React.ReactNode;
}
export function ThemedTable({ style, children, ...props }: ThemedTableProps) {
const { colorScheme } = useAppTheme();
const tableStyles = useMemo(
() => createTableStyles(colorScheme),
[colorScheme]
);
return (
<View style={[tableStyles.container, style]} {...props}>
{children}
</View>
);
}
export { createTableStyles };
export type { TableStyles } from "./style/createTableStyles";

View File

@@ -1,14 +1,12 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n"; 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);
@@ -16,9 +14,13 @@ const TripCostTable: React.FC = () => {
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 { 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);
@@ -54,19 +56,14 @@ const TripCostTable: React.FC = () => {
> >
<Text style={styles.title}>{t("trip.costTable.title")}</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>

View File

@@ -3,6 +3,7 @@ import { IconSymbol } from "@/components/ui/icon-symbol";
import { queryGpsData } from "@/controller/DeviceController"; import { queryGpsData } from "@/controller/DeviceController";
import { queryUpdateFishingLogs } from "@/controller/TripController"; import { queryUpdateFishingLogs } from "@/controller/TripController";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { showErrorToast, showSuccessToast } from "@/services/toast_service"; import { showErrorToast, showSuccessToast } from "@/services/toast_service";
import { useFishes } from "@/state/use-fish"; import { useFishes } from "@/state/use-fish";
import { useTrip } from "@/state/use-trip"; import { useTrip } from "@/state/use-trip";
@@ -20,8 +21,8 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { z } from "zod"; import { z } from "zod";
import { InfoSection } from "./NetDetailModal/components"; import { InfoSection } from "./components/InfoSection";
import styles from "./style/CreateOrUpdateHaulModal.styles"; import { createStyles } from "./style/CreateOrUpdateHaulModal.styles";
interface CreateOrUpdateHaulModalProps { interface CreateOrUpdateHaulModalProps {
isVisible: boolean; isVisible: boolean;
@@ -74,6 +75,8 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
fishingLog, fishingLog,
fishingLogIndex, fishingLogIndex,
}) => { }) => {
const { colors } = useThemeContext();
const styles = React.useMemo(() => createStyles(colors), [colors]);
const { t } = useI18n(); const { t } = useI18n();
const [isCreateMode, setIsCreateMode] = React.useState(!fishingLog?.info); const [isCreateMode, setIsCreateMode] = React.useState(!fishingLog?.info);
const [isEditing, setIsEditing] = React.useState(false); const [isEditing, setIsEditing] = React.useState(false);
@@ -256,7 +259,7 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
<TouchableOpacity <TouchableOpacity
onPress={() => remove(index)} onPress={() => remove(index)}
style={{ style={{
backgroundColor: "#FF3B30", backgroundColor: colors.error,
borderRadius: 8, borderRadius: 8,
width: 40, width: 40,
height: 40, height: 40,
@@ -277,7 +280,7 @@ const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
<TouchableOpacity <TouchableOpacity
onPress={() => handleToggleExpanded(index)} onPress={() => handleToggleExpanded(index)}
style={{ style={{
backgroundColor: "#007AFF", backgroundColor: colors.primary,
borderRadius: 8, borderRadius: 8,
width: 40, width: 40,
height: 40, height: 40,

View File

@@ -1,8 +1,9 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n"; 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
@@ -23,6 +24,8 @@ const CrewDetailModal: React.FC<CrewDetailModalProps> = ({
crewData, crewData,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext();
const styles = React.useMemo(() => createStyles(colors), [colors]);
if (!crewData) return null; if (!crewData) return null;

View File

@@ -1,362 +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 { 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;

View File

@@ -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 bắt đưc</Text>
<Text style={styles.totalCatchText}>
Tổng: {totalCatch.toLocaleString()} kg
</Text>
</View>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n"; 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,
@@ -11,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
@@ -31,6 +32,8 @@ const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
data, data,
}) => { }) => {
const { t } = useI18n(); 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);

View File

@@ -1,7 +1,7 @@
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import React from "react"; import React from "react";
import { Text, View } from "react-native"; import { StyleSheet, Text, View } from "react-native";
import styles from "../../style/NetDetailModal.styles";
interface InfoSectionProps { interface InfoSectionProps {
fishingLog?: Model.FishingLog; fishingLog?: Model.FishingLog;
@@ -13,6 +13,9 @@ export const InfoSection: React.FC<InfoSectionProps> = ({
stt, stt,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext();
const styles = React.useMemo(() => createStyles(colors), [colors]);
if (!fishingLog) { if (!fishingLog) {
return null; return null;
} }
@@ -42,22 +45,6 @@ export const InfoSection: React.FC<InfoSectionProps> = ({
? new Date(fishingLog.end_at).toLocaleString() ? new Date(fishingLog.end_at).toLocaleString()
: "-", : "-",
}, },
// {
// label: "Vị trí hạ thu",
// value: fishingLog.viTriHaThu || "Chưa cập nhật",
// },
// {
// label: "Vị trí thu lưới",
// value: fishingLog.viTriThuLuoi || "Chưa cập nhật",
// },
// {
// label: "Độ sâu hạ thu",
// value: fishingLog.doSauHaThu || "Chưa cập nhật",
// },
// {
// label: "Độ sâu thu lưới",
// value: fishingLog.doSauThuLuoi || "Chưa cập nhật",
// },
]; ];
return ( return (
@@ -69,21 +56,12 @@ export const InfoSection: React.FC<InfoSectionProps> = ({
<View <View
style={[ style={[
styles.statusBadge, styles.statusBadge,
item.value === "Đã hoàn thành" item.value === t("trip.infoSection.statusCompleted")
? styles.statusBadgeCompleted ? styles.statusBadgeCompleted
: styles.statusBadgeInProgress, : styles.statusBadgeInProgress,
]} ]}
> >
<Text <Text style={styles.statusBadgeText}>{item.value}</Text>
style={[
styles.statusBadgeText,
item.value === "Đã hoàn thành"
? styles.statusBadgeTextCompleted
: styles.statusBadgeTextInProgress,
]}
>
{item.value}
</Text>
</View> </View>
) : ( ) : (
<Text style={styles.infoValue}>{item.value}</Text> <Text style={styles.infoValue}>{item.value}</Text>
@@ -93,3 +71,49 @@ export const InfoSection: React.FC<InfoSectionProps> = ({
</View> </View>
); );
}; };
const createStyles = (colors: any) =>
StyleSheet.create({
infoCard: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
padding: 12,
marginBottom: 12,
backgroundColor: colors.surfaceSecondary,
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
},
infoLabel: {
fontSize: 14,
fontWeight: "600",
color: colors.textSecondary,
},
infoValue: {
fontSize: 16,
color: colors.text,
paddingVertical: 8,
},
statusBadge: {
paddingVertical: 4,
paddingHorizontal: 12,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
statusBadgeCompleted: {
backgroundColor: colors.success,
},
statusBadgeInProgress: {
backgroundColor: colors.warning,
},
statusBadgeText: {
fontSize: 14,
fontWeight: "600",
color: "#fff",
},
});

View File

@@ -1,9 +1,11 @@
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) =>
StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: "#f5f5f5", backgroundColor: colors.backgroundSecondary,
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",
@@ -12,14 +14,14 @@ const styles = StyleSheet.create({
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 30, paddingTop: 30,
paddingBottom: 16, paddingBottom: 16,
backgroundColor: "#fff", backgroundColor: colors.surface,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#eee", borderBottomColor: colors.separator,
}, },
title: { title: {
fontSize: 22, fontSize: 22,
fontWeight: "700", fontWeight: "700",
color: "#000", color: colors.text,
flex: 1, flex: 1,
}, },
headerButtons: { headerButtons: {
@@ -31,14 +33,14 @@ const styles = StyleSheet.create({
padding: 4, padding: 4,
}, },
closeIconButton: { closeIconButton: {
backgroundColor: "#FF3B30", backgroundColor: colors.error,
borderRadius: 10, borderRadius: 10,
padding: 10, padding: 10,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
saveButton: { saveButton: {
backgroundColor: "#007bff", backgroundColor: colors.primary,
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 20, paddingHorizontal: 20,
paddingVertical: 10, paddingVertical: 10,
@@ -56,7 +58,7 @@ const styles = StyleSheet.create({
marginBottom: 15, marginBottom: 15,
}, },
fishCard: { fishCard: {
backgroundColor: "#fff", backgroundColor: colors.surfaceSecondary,
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 16,
marginBottom: 16, marginBottom: 16,
@@ -75,11 +77,11 @@ const styles = StyleSheet.create({
fishCardTitle: { fishCardTitle: {
fontSize: 16, fontSize: 16,
fontWeight: "700", fontWeight: "700",
color: "#000", color: colors.text,
}, },
fishCardSubtitle: { fishCardSubtitle: {
fontSize: 15, fontSize: 15,
color: "#ff6600", color: colors.warning,
fontWeight: "500", fontWeight: "500",
}, },
fieldGroup: { fieldGroup: {
@@ -88,26 +90,26 @@ const styles = StyleSheet.create({
label: { label: {
fontSize: 14, fontSize: 14,
fontWeight: "600", fontWeight: "600",
color: "#333", color: colors.textSecondary,
marginBottom: 6, marginBottom: 6,
}, },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: "#ddd", borderColor: colors.primary,
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
backgroundColor: "#fff", backgroundColor: colors.surface,
color: "#000", color: colors.text,
}, },
inputDisabled: { inputDisabled: {
backgroundColor: "#f5f5f5", backgroundColor: colors.backgroundSecondary,
color: "#999", color: colors.textSecondary,
borderColor: "#eee", borderColor: colors.border,
}, },
errorText: { errorText: {
color: "#dc3545", color: colors.error,
fontSize: 12, fontSize: 12,
marginTop: 4, marginTop: 4,
fontWeight: "500", fontWeight: "500",
@@ -119,7 +121,7 @@ const styles = StyleSheet.create({
marginTop: 12, marginTop: 12,
}, },
removeButton: { removeButton: {
backgroundColor: "#dc3545", backgroundColor: colors.error,
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 8, paddingVertical: 8,
@@ -132,7 +134,7 @@ const styles = StyleSheet.create({
fontWeight: "600", fontWeight: "600",
}, },
addButton: { addButton: {
backgroundColor: "#007AFF", backgroundColor: colors.primary,
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 16,
marginBottom: 12, marginBottom: 12,
@@ -152,12 +154,12 @@ const styles = StyleSheet.create({
footerSection: { footerSection: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 16, paddingVertical: 16,
backgroundColor: "#fff", backgroundColor: colors.surface,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: "#eee", borderTopColor: colors.separator,
}, },
saveButtonLarge: { saveButtonLarge: {
backgroundColor: "#007bff", backgroundColor: colors.primary,
borderRadius: 8, borderRadius: 8,
paddingVertical: 14, paddingVertical: 14,
justifyContent: "center", justifyContent: "center",
@@ -170,10 +172,8 @@ const styles = StyleSheet.create({
}, },
emptyStateText: { emptyStateText: {
textAlign: "center", textAlign: "center",
color: "#999", color: colors.textSecondary,
fontSize: 14, fontSize: 14,
marginTop: 20, marginTop: 20,
}, },
}); });
export default styles;

View File

@@ -1,9 +1,11 @@
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) =>
StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: "#f5f5f5", backgroundColor: colors.backgroundSecondary,
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",
@@ -12,21 +14,21 @@ const styles = StyleSheet.create({
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 30, paddingTop: 30,
paddingBottom: 16, paddingBottom: 16,
backgroundColor: "#fff", backgroundColor: colors.surface,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#eee", borderBottomColor: colors.separator,
}, },
title: { title: {
fontSize: 22, fontSize: 22,
fontWeight: "700", fontWeight: "700",
color: "#000", color: colors.text,
flex: 1, flex: 1,
}, },
closeButton: { closeButton: {
padding: 4, padding: 4,
}, },
closeIconButton: { closeIconButton: {
backgroundColor: "#FF3B30", backgroundColor: colors.error,
borderRadius: 10, borderRadius: 10,
padding: 10, padding: 10,
justifyContent: "center", justifyContent: "center",
@@ -38,7 +40,7 @@ const styles = StyleSheet.create({
marginBottom: 15, marginBottom: 15,
}, },
infoCard: { infoCard: {
backgroundColor: "#fff", backgroundColor: colors.card,
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 16,
marginBottom: 35, marginBottom: 35,
@@ -51,19 +53,17 @@ const styles = StyleSheet.create({
infoRow: { infoRow: {
paddingVertical: 12, paddingVertical: 12,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#f0f0f0", borderBottomColor: colors.separator,
}, },
infoLabel: { infoLabel: {
fontSize: 13, fontSize: 13,
fontWeight: "600", fontWeight: "600",
color: "#666", color: colors.textSecondary,
marginBottom: 6, marginBottom: 6,
}, },
infoValue: { infoValue: {
fontSize: 16, fontSize: 16,
color: "#000", color: colors.text,
fontWeight: "500", fontWeight: "500",
}, },
}); });
export default styles;

View File

@@ -1,9 +1,11 @@
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) =>
StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: "#f5f5f5", backgroundColor: colors.backgroundSecondary,
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",
@@ -12,21 +14,21 @@ const styles = StyleSheet.create({
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 30, paddingTop: 30,
paddingBottom: 16, paddingBottom: 16,
backgroundColor: "#fff", backgroundColor: colors.surface,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#eee", borderBottomColor: colors.separator,
}, },
title: { title: {
fontSize: 22, fontSize: 22,
fontWeight: "700", fontWeight: "700",
color: "#000", color: colors.text,
flex: 1, flex: 1,
}, },
closeButton: { closeButton: {
padding: 4, padding: 4,
}, },
closeIconButton: { closeIconButton: {
backgroundColor: "#FF3B30", backgroundColor: colors.error,
borderRadius: 10, borderRadius: 10,
padding: 10, padding: 10,
justifyContent: "center", justifyContent: "center",
@@ -38,7 +40,7 @@ const styles = StyleSheet.create({
marginBottom: 15, marginBottom: 15,
}, },
infoCard: { infoCard: {
backgroundColor: "#fff", backgroundColor: colors.card,
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 16,
marginBottom: 35, marginBottom: 35,
@@ -51,17 +53,17 @@ const styles = StyleSheet.create({
infoRow: { infoRow: {
paddingVertical: 12, paddingVertical: 12,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#f0f0f0", borderBottomColor: colors.separator,
}, },
infoLabel: { infoLabel: {
fontSize: 13, fontSize: 13,
fontWeight: "600", fontWeight: "600",
color: "#666", color: colors.textSecondary,
marginBottom: 6, marginBottom: 6,
}, },
infoValue: { infoValue: {
fontSize: 16, fontSize: 16,
color: "#000", color: colors.text,
fontWeight: "500", fontWeight: "500",
}, },
statusBadge: { statusBadge: {
@@ -71,20 +73,21 @@ const styles = StyleSheet.create({
alignSelf: "flex-start", alignSelf: "flex-start",
}, },
statusBadgeCompleted: { statusBadgeCompleted: {
backgroundColor: "#e8f5e9", backgroundColor: colors.success,
}, },
statusBadgeInProgress: { statusBadgeInProgress: {
backgroundColor: "#fff3e0", backgroundColor: colors.warning,
}, },
statusBadgeText: { statusBadgeText: {
fontSize: 14, fontSize: 14,
fontWeight: "600", fontWeight: "600",
color: "#fff",
}, },
statusBadgeTextCompleted: { statusBadgeTextCompleted: {
color: "#2e7d32", color: "#fff",
}, },
statusBadgeTextInProgress: { statusBadgeTextInProgress: {
color: "#f57c00", color: "#fff",
}, },
headerButtons: { headerButtons: {
flexDirection: "row", flexDirection: "row",
@@ -95,7 +98,7 @@ const styles = StyleSheet.create({
padding: 4, padding: 4,
}, },
editIconButton: { editIconButton: {
backgroundColor: "#007AFF", backgroundColor: colors.primary,
borderRadius: 10, borderRadius: 10,
padding: 10, padding: 10,
justifyContent: "center", justifyContent: "center",
@@ -106,12 +109,12 @@ const styles = StyleSheet.create({
paddingVertical: 6, paddingVertical: 6,
}, },
cancelButtonText: { cancelButtonText: {
color: "#007AFF", color: colors.primary,
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: "600",
}, },
saveButton: { saveButton: {
backgroundColor: "#007AFF", backgroundColor: colors.primary,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 6, paddingVertical: 6,
borderRadius: 6, borderRadius: 6,
@@ -132,16 +135,16 @@ const styles = StyleSheet.create({
sectionTitle: { sectionTitle: {
fontSize: 18, fontSize: 18,
fontWeight: "700", fontWeight: "700",
color: "#000", color: colors.text,
}, },
totalCatchText: { totalCatchText: {
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: "600",
color: "#007AFF", color: colors.primary,
}, },
fishCard: { fishCard: {
position: "relative", position: "relative",
backgroundColor: "#fff", backgroundColor: colors.card,
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 16,
marginBottom: 12, marginBottom: 12,
@@ -162,33 +165,33 @@ const styles = StyleSheet.create({
label: { label: {
fontSize: 13, fontSize: 13,
fontWeight: "600", fontWeight: "600",
color: "#666", color: colors.textSecondary,
marginBottom: 6, marginBottom: 6,
}, },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: "#007AFF", borderColor: colors.primary,
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 15, fontSize: 15,
color: "#000", color: colors.text,
backgroundColor: "#fff", backgroundColor: colors.surface,
}, },
selectButton: { selectButton: {
borderWidth: 1, borderWidth: 1,
borderColor: "#007AFF", borderColor: colors.primary,
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
backgroundColor: "#fff", backgroundColor: colors.surface,
}, },
selectButtonText: { selectButtonText: {
fontSize: 15, fontSize: 15,
color: "#000", color: colors.text,
}, },
optionsList: { optionsList: {
position: "absolute", position: "absolute",
@@ -196,10 +199,10 @@ const styles = StyleSheet.create({
left: 0, left: 0,
right: 0, right: 0,
borderWidth: 1, borderWidth: 1,
borderColor: "#007AFF", borderColor: colors.primary,
borderRadius: 8, borderRadius: 8,
marginTop: 4, marginTop: 4,
backgroundColor: "#fff", backgroundColor: colors.surface,
maxHeight: 100, maxHeight: 100,
zIndex: 1000, zIndex: 1000,
elevation: 5, elevation: 5,
@@ -212,18 +215,18 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 12, paddingVertical: 12,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#f0f0f0", borderBottomColor: colors.separator,
}, },
optionText: { optionText: {
fontSize: 15, fontSize: 15,
color: "#000", color: colors.text,
}, },
optionsStatusFishList: { optionsStatusFishList: {
borderWidth: 1, borderWidth: 1,
borderColor: "#007AFF", borderColor: colors.primary,
borderRadius: 8, borderRadius: 8,
marginTop: 4, marginTop: 4,
backgroundColor: "#fff", backgroundColor: colors.surface,
maxHeight: 120, maxHeight: 120,
zIndex: 1000, zIndex: 1000,
elevation: 5, elevation: 5,
@@ -238,10 +241,10 @@ const styles = StyleSheet.create({
left: 0, left: 0,
right: 0, right: 0,
borderWidth: 1, borderWidth: 1,
borderColor: "#007AFF", borderColor: colors.primary,
borderRadius: 8, borderRadius: 8,
marginTop: 4, marginTop: 4,
backgroundColor: "#fff", backgroundColor: colors.surface,
maxHeight: 180, maxHeight: 180,
zIndex: 1000, zIndex: 1000,
elevation: 5, elevation: 5,
@@ -257,15 +260,15 @@ const styles = StyleSheet.create({
fishCardTitle: { fishCardTitle: {
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: "600",
color: "#000", color: colors.text,
}, },
fishCardSubtitle: { fishCardSubtitle: {
fontSize: 15, fontSize: 15,
color: "#ff6600", color: colors.warning,
marginTop: 0, marginTop: 0,
}, },
addFishButton: { addFishButton: {
backgroundColor: "#007AFF", backgroundColor: colors.primary,
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 16,
marginBottom: 12, marginBottom: 12,
@@ -287,6 +290,4 @@ const styles = StyleSheet.create({
fontWeight: "600", fontWeight: "600",
color: "#fff", color: "#fff",
}, },
}); });
export default styles;

View File

@@ -1,8 +1,10 @@
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) =>
StyleSheet.create({
closeIconButton: { closeIconButton: {
backgroundColor: "#FF3B30", backgroundColor: colors.error,
borderRadius: 10, borderRadius: 10,
padding: 10, padding: 10,
justifyContent: "center", justifyContent: "center",
@@ -10,7 +12,7 @@ const styles = StyleSheet.create({
}, },
container: { container: {
flex: 1, flex: 1,
backgroundColor: "#f5f5f5", backgroundColor: colors.backgroundSecondary,
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",
@@ -19,14 +21,14 @@ const styles = StyleSheet.create({
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 30, paddingTop: 30,
paddingBottom: 16, paddingBottom: 16,
backgroundColor: "#fff", backgroundColor: colors.surface,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#eee", borderBottomColor: colors.separator,
}, },
title: { title: {
fontSize: 22, fontSize: 22,
fontWeight: "700", fontWeight: "700",
color: "#000", color: colors.text,
flex: 1, flex: 1,
}, },
headerButtons: { headerButtons: {
@@ -38,7 +40,7 @@ const styles = StyleSheet.create({
padding: 4, padding: 4,
}, },
editIconButton: { editIconButton: {
backgroundColor: "#007AFF", backgroundColor: colors.primary,
borderRadius: 10, borderRadius: 10,
padding: 10, padding: 10,
justifyContent: "center", justifyContent: "center",
@@ -49,12 +51,12 @@ const styles = StyleSheet.create({
paddingVertical: 6, paddingVertical: 6,
}, },
cancelButtonText: { cancelButtonText: {
color: "#007AFF", color: colors.primary,
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: "600",
}, },
saveButton: { saveButton: {
backgroundColor: "#007AFF", backgroundColor: colors.primary,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 6, paddingVertical: 6,
borderRadius: 6, borderRadius: 6,
@@ -72,7 +74,7 @@ const styles = StyleSheet.create({
padding: 16, padding: 16,
}, },
itemCard: { itemCard: {
backgroundColor: "#fff", backgroundColor: colors.surfaceSecondary,
borderRadius: 12, borderRadius: 12,
padding: 16, padding: 16,
marginBottom: 12, marginBottom: 12,
@@ -92,39 +94,39 @@ const styles = StyleSheet.create({
label: { label: {
fontSize: 13, fontSize: 13,
fontWeight: "600", fontWeight: "600",
color: "#666", color: colors.textSecondary,
marginBottom: 6, marginBottom: 6,
}, },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: "#007AFF", borderColor: colors.primary,
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 15, fontSize: 15,
color: "#000", color: colors.text,
backgroundColor: "#fff", backgroundColor: colors.surface,
}, },
inputDisabled: { inputDisabled: {
borderColor: "#ddd", borderColor: colors.border,
backgroundColor: "#f9f9f9", backgroundColor: colors.backgroundSecondary,
color: "#666", color: colors.textSecondary,
}, },
totalContainer: { totalContainer: {
backgroundColor: "#fff5e6", backgroundColor: colors.backgroundSecondary,
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
borderWidth: 1, borderWidth: 1,
borderColor: "#ffd699", borderColor: colors.border,
}, },
totalText: { totalText: {
fontSize: 16, fontSize: 16,
fontWeight: "700", fontWeight: "700",
color: "#ff6600", color: colors.warning,
}, },
footerTotal: { footerTotal: {
backgroundColor: "#fff", backgroundColor: colors.card,
borderRadius: 12, borderRadius: 12,
padding: 20, padding: 20,
marginTop: 8, marginTop: 8,
@@ -141,13 +143,11 @@ const styles = StyleSheet.create({
footerLabel: { footerLabel: {
fontSize: 18, fontSize: 18,
fontWeight: "700", fontWeight: "700",
color: "#007bff", color: colors.primary,
}, },
footerAmount: { footerAmount: {
fontSize: 20, fontSize: 20,
fontWeight: "700", fontWeight: "700",
color: "#ff6600", color: colors.warning,
}, },
}); });
export default styles;

View File

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

View File

@@ -1,66 +0,0 @@
import { StyleSheet } from "react-native";
export default StyleSheet.create({
container: {
width: "100%",
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginVertical: 10,
borderWidth: 1,
borderColor: "#fff",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
title: {
fontSize: 18,
fontWeight: "700",
},
totalCollapsed: {
color: "#ff6600",
fontSize: 18,
fontWeight: "700",
textAlign: "center",
},
row: {
flexDirection: "row",
paddingVertical: 8,
borderBottomWidth: 0.5,
borderBottomColor: "#eee",
paddingLeft: 15,
},
cell: {
flex: 1,
fontSize: 15,
color: "#111",
},
left: {
textAlign: "left",
},
right: {
textAlign: "center",
},
tableHeader: {
backgroundColor: "#fafafa",
borderRadius: 6,
marginTop: 10,
},
headerText: {
fontWeight: "600",
},
footerText: {
color: "#007bff",
fontWeight: "600",
},
footerTotal: {
color: "#ff6600",
fontWeight: "800",
},
});

View File

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

View File

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

View File

@@ -0,0 +1,175 @@
import { StyleSheet } from "react-native";
import { Colors } from "@/constants/theme";
export type ColorScheme = "light" | "dark";
export function createTableStyles(colorScheme: ColorScheme) {
const colors = Colors[colorScheme];
return StyleSheet.create({
container: {
width: "100%",
backgroundColor: colors.surface,
borderRadius: 12,
padding: 16,
marginVertical: 10,
borderWidth: 1,
borderColor: colors.border,
shadowColor: colors.text,
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
title: {
fontSize: 18,
fontWeight: "700",
color: colors.text,
},
totalCollapsed: {
color: colors.warning,
fontSize: 18,
fontWeight: "700",
textAlign: "center",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 8,
borderBottomWidth: 0.5,
borderBottomColor: colors.separator,
},
header: {
backgroundColor: colors.backgroundSecondary,
borderRadius: 6,
marginTop: 10,
},
left: {
textAlign: "left",
},
rowHorizontal: {
flexDirection: "row",
paddingVertical: 8,
borderBottomWidth: 0.5,
borderBottomColor: colors.separator,
paddingLeft: 15,
},
tableHeader: {
backgroundColor: colors.backgroundSecondary,
borderRadius: 6,
marginTop: 10,
},
headerCell: {
flex: 1,
fontSize: 15,
fontWeight: "600",
color: colors.text,
textAlign: "center",
},
headerCellLeft: {
flex: 1,
fontSize: 15,
fontWeight: "600",
color: colors.text,
textAlign: "left",
},
cell: {
flex: 1,
fontSize: 15,
color: colors.text,
textAlign: "center",
},
cellLeft: {
flex: 1,
fontSize: 15,
color: colors.text,
textAlign: "left",
},
cellRight: {
flex: 1,
fontSize: 15,
color: colors.text,
textAlign: "right",
},
cellWrapper: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
headerText: {
fontWeight: "600",
color: colors.text,
},
footerText: {
color: colors.primary,
fontWeight: "600",
},
footerTotal: {
color: colors.warning,
fontWeight: "800",
},
sttCell: {
flex: 0.3,
fontSize: 15,
color: colors.text,
textAlign: "center",
paddingLeft: 10,
},
statusContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.success,
marginRight: 6,
},
statusDotPending: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.warning,
marginRight: 6,
},
statusText: {
fontSize: 15,
color: colors.primary,
textDecorationLine: "underline",
},
linkText: {
color: colors.primary,
textDecorationLine: "underline",
},
viewDetailButton: {
marginTop: 12,
paddingVertical: 8,
alignItems: "center",
},
viewDetailText: {
color: colors.primary,
fontSize: 15,
fontWeight: "600",
textDecorationLine: "underline",
},
total: {
color: colors.warning,
fontWeight: "700",
},
right: {
color: colors.warning,
fontWeight: "600",
},
footerRow: {
marginTop: 6,
},
});
}
export type TableStyles = ReturnType<typeof createTableStyles>;

View File

@@ -5,11 +5,11 @@ import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view'; import { 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,578 @@
import { Ionicons } from "@expo/vector-icons";
import React, { ReactNode, useEffect, useState } from "react";
import {
ActivityIndicator,
Dimensions,
Pressable,
Modal as RNModal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
// Types
export interface ModalProps {
/** Whether the modal dialog is visible or not */
open?: boolean;
/** The modal dialog's title */
title?: ReactNode;
/** Whether a close (x) button is visible on top right or not */
closable?: boolean;
/** Custom close icon */
closeIcon?: ReactNode;
/** Whether to close the modal dialog when the mask (area outside the modal) is clicked */
maskClosable?: boolean;
/** Centered Modal */
centered?: boolean;
/** Width of the modal dialog */
width?: number | string;
/** Whether to apply loading visual effect for OK button or not */
confirmLoading?: boolean;
/** Text of the OK button */
okText?: string;
/** Text of the Cancel button */
cancelText?: string;
/** Button type of the OK button */
okType?: "primary" | "default" | "dashed" | "text" | "link";
/** Footer content, set as footer={null} when you don't need default buttons */
footer?: ReactNode | null;
/** Whether show mask or not */
mask?: boolean;
/** The z-index of the Modal */
zIndex?: number;
/** Specify a function that will be called when a user clicks the OK button */
onOk?: (e?: any) => void | Promise<void>;
/** Specify a function that will be called when a user clicks mask, close button on top right or Cancel button */
onCancel?: (e?: any) => void;
/** Callback when the animation ends when Modal is turned on and off */
afterOpenChange?: (open: boolean) => void;
/** Specify a function that will be called when modal is closed completely */
afterClose?: () => void;
/** Custom className */
className?: string;
/** Modal body content */
children?: ReactNode;
/** Whether to unmount child components on close */
destroyOnClose?: boolean;
/** The ok button props */
okButtonProps?: any;
/** The cancel button props */
cancelButtonProps?: any;
/** Whether support press esc to close */
keyboard?: boolean;
}
export interface ConfirmModalProps extends Omit<ModalProps, "open"> {
/** Type of the confirm modal */
type?: "info" | "success" | "error" | "warning" | "confirm";
/** Content */
content?: ReactNode;
/** Custom icon */
icon?: ReactNode;
}
// Modal Component
const Modal: React.FC<ModalProps> & {
info: (props: ConfirmModalProps) => ModalInstance;
success: (props: ConfirmModalProps) => ModalInstance;
error: (props: ConfirmModalProps) => ModalInstance;
warning: (props: ConfirmModalProps) => ModalInstance;
confirm: (props: ConfirmModalProps) => ModalInstance;
useModal: () => [
{
info: (props: ConfirmModalProps) => ModalInstance;
success: (props: ConfirmModalProps) => ModalInstance;
error: (props: ConfirmModalProps) => ModalInstance;
warning: (props: ConfirmModalProps) => ModalInstance;
confirm: (props: ConfirmModalProps) => ModalInstance;
},
ReactNode
];
} = ({
open = false,
title,
closable = true,
closeIcon,
maskClosable = true,
centered = false,
width = 520,
confirmLoading = false,
okText = "OK",
cancelText = "Cancel",
okType = "primary",
footer,
mask = true,
zIndex = 1000,
onOk,
onCancel,
afterOpenChange,
afterClose,
className,
children,
destroyOnClose = false,
okButtonProps,
cancelButtonProps,
keyboard = true,
}) => {
const [visible, setVisible] = useState(open);
const [loading, setLoading] = useState(false);
useEffect(() => {
setVisible(open);
if (afterOpenChange) {
afterOpenChange(open);
}
}, [open, afterOpenChange]);
const handleOk = async () => {
if (onOk) {
setLoading(true);
try {
await onOk();
// Không tự động đóng modal - để parent component quyết định
} catch (error) {
console.error("Modal onOk error:", error);
} finally {
setLoading(false);
}
} else {
setVisible(false);
}
};
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
// Nếu không có onCancel, tự động đóng modal
setVisible(false);
}
};
const handleMaskPress = () => {
if (maskClosable) {
handleCancel();
}
};
const handleRequestClose = () => {
if (keyboard) {
handleCancel();
}
};
useEffect(() => {
if (!visible && afterClose) {
const timer = setTimeout(() => {
afterClose();
}, 300); // Wait for animation to complete
return () => clearTimeout(timer);
}
}, [visible, afterClose]);
const renderFooter = () => {
if (footer === null) {
return null;
}
if (footer !== undefined) {
return <View style={styles.footer}>{footer}</View>;
}
return (
<View style={styles.footer}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={handleCancel}
disabled={loading || confirmLoading}
{...cancelButtonProps}
>
<Text style={styles.cancelButtonText}>{cancelText}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.button,
styles.okButton,
okType === "primary" && styles.primaryButton,
(loading || confirmLoading) && styles.disabledButton,
]}
onPress={handleOk}
disabled={loading || confirmLoading}
{...okButtonProps}
>
{loading || confirmLoading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.okButtonText}>{okText}</Text>
)}
</TouchableOpacity>
</View>
);
};
const modalWidth =
typeof width === "number" ? width : Dimensions.get("window").width * 0.9;
return (
<RNModal
visible={visible}
transparent
animationType="fade"
onRequestClose={handleRequestClose}
statusBarTranslucent
>
<Pressable
style={[
styles.overlay,
centered && styles.centered,
{ zIndex },
!mask && styles.noMask,
]}
onPress={handleMaskPress}
>
<Pressable
style={[styles.modal, { width: modalWidth, maxWidth: "90%" }]}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || closable) && (
<View style={styles.header}>
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
{closable && (
<TouchableOpacity
style={styles.closeButton}
onPress={handleCancel}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
{closeIcon || (
<Ionicons name="close" size={24} color="#666" />
)}
</TouchableOpacity>
)}
</View>
)}
{/* Body */}
<View style={styles.body}>
{(!destroyOnClose || visible) && children}
</View>
{/* Footer */}
{renderFooter()}
</Pressable>
</Pressable>
</RNModal>
);
};
// Confirm Modal Component
const ConfirmModal: React.FC<
ConfirmModalProps & { visible: boolean; onClose: () => void }
> = ({
visible,
onClose,
type = "confirm",
title,
content,
icon,
okText = "OK",
cancelText = "Cancel",
onOk,
onCancel,
...restProps
}) => {
const [loading, setLoading] = useState(false);
const getIcon = () => {
if (icon !== undefined) return icon;
const iconProps = { size: 24, style: { marginRight: 12 } };
switch (type) {
case "info":
return (
<Ionicons name="information-circle" color="#1890ff" {...iconProps} />
);
case "success":
return (
<Ionicons name="checkmark-circle" color="#52c41a" {...iconProps} />
);
case "error":
return <Ionicons name="close-circle" color="#ff4d4f" {...iconProps} />;
case "warning":
return <Ionicons name="warning" color="#faad14" {...iconProps} />;
default:
return <Ionicons name="help-circle" color="#1890ff" {...iconProps} />;
}
};
const handleOk = async () => {
if (onOk) {
setLoading(true);
try {
await onOk();
onClose();
} catch (error) {
console.error("Confirm modal onOk error:", error);
} finally {
setLoading(false);
}
} else {
onClose();
}
};
const handleCancel = () => {
if (onCancel) {
onCancel();
}
onClose();
};
return (
<Modal
open={visible}
title={title}
onOk={handleOk}
onCancel={type === "confirm" ? handleCancel : undefined}
okText={okText}
cancelText={cancelText}
confirmLoading={loading}
footer={
type === "confirm" ? undefined : (
<View style={styles.footer}>
<TouchableOpacity
style={[styles.button, styles.okButton, styles.primaryButton]}
onPress={handleOk}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.okButtonText}>{okText}</Text>
)}
</TouchableOpacity>
</View>
)
}
{...restProps}
>
<View style={styles.confirmContent}>
{getIcon()}
<Text style={styles.confirmText}>{content}</Text>
</View>
</Modal>
);
};
// Modal Instance
export interface ModalInstance {
destroy: () => void;
update: (config: ConfirmModalProps) => void;
}
// Container for imperatively created modals - Not used in React Native
// Static methods will return instance but won't render imperatively
// Use Modal.useModal() hook for proper context support
const createConfirmModal = (config: ConfirmModalProps): ModalInstance => {
console.warn(
"Modal static methods are not fully supported in React Native. Please use Modal.useModal() hook for better context support."
);
return {
destroy: () => {
console.warn(
"Modal.destroy() called but static modals are not supported in React Native"
);
},
update: (newConfig: ConfirmModalProps) => {
console.warn(
"Modal.update() called but static modals are not supported in React Native"
);
},
};
};
// Static methods
Modal.info = (props: ConfirmModalProps) =>
createConfirmModal({ ...props, type: "info" });
Modal.success = (props: ConfirmModalProps) =>
createConfirmModal({ ...props, type: "success" });
Modal.error = (props: ConfirmModalProps) =>
createConfirmModal({ ...props, type: "error" });
Modal.warning = (props: ConfirmModalProps) =>
createConfirmModal({ ...props, type: "warning" });
Modal.confirm = (props: ConfirmModalProps) =>
createConfirmModal({ ...props, type: "confirm" });
// useModal hook
Modal.useModal = () => {
const [modals, setModals] = useState<ReactNode[]>([]);
const createModal = (
config: ConfirmModalProps,
type: ConfirmModalProps["type"]
) => {
const id = `modal-${Date.now()}-${Math.random()}`;
const destroy = () => {
setModals((prev) => prev.filter((modal: any) => modal.key !== id));
};
const update = (newConfig: ConfirmModalProps) => {
setModals((prev) =>
prev.map((modal: any) =>
modal.key === id ? (
<ConfirmModal
key={id}
visible={true}
onClose={destroy}
{...config}
{...newConfig}
type={type}
/>
) : (
modal
)
)
);
};
const modalElement = (
<ConfirmModal
key={id}
visible={true}
onClose={destroy}
{...config}
type={type}
/>
);
setModals((prev) => [...prev, modalElement]);
return { destroy, update };
};
const modalMethods = {
info: (props: ConfirmModalProps) => createModal(props, "info"),
success: (props: ConfirmModalProps) => createModal(props, "success"),
error: (props: ConfirmModalProps) => createModal(props, "error"),
warning: (props: ConfirmModalProps) => createModal(props, "warning"),
confirm: (props: ConfirmModalProps) => createModal(props, "confirm"),
};
const contextHolder = <>{modals}</>;
return [modalMethods, contextHolder];
};
// Styles
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.45)",
justifyContent: "flex-start",
paddingTop: 100,
alignItems: "center",
},
centered: {
justifyContent: "center",
paddingTop: 0,
},
noMask: {
backgroundColor: "transparent",
},
modal: {
backgroundColor: "#fff",
borderRadius: 8,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
maxHeight: "90%",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 24,
paddingTop: 20,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
title: {
fontSize: 18,
fontWeight: "600",
color: "#000",
flex: 1,
},
closeButton: {
padding: 4,
marginLeft: 12,
},
body: {
paddingHorizontal: 24,
paddingVertical: 20,
},
footer: {
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
paddingHorizontal: 24,
paddingBottom: 20,
paddingTop: 12,
gap: 8,
},
button: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 6,
minWidth: 70,
alignItems: "center",
justifyContent: "center",
height: 36,
},
cancelButton: {
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "#d9d9d9",
},
cancelButtonText: {
color: "#000",
fontSize: 14,
fontWeight: "500",
},
okButton: {
backgroundColor: "#1890ff",
},
primaryButton: {
backgroundColor: "#1890ff",
},
okButtonText: {
color: "#fff",
fontSize: 14,
fontWeight: "500",
},
disabledButton: {
opacity: 0.6,
},
confirmContent: {
flexDirection: "row",
alignItems: "flex-start",
},
confirmText: {
flex: 1,
fontSize: 14,
color: "#000",
lineHeight: 22,
},
});
export default Modal;

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
Animated, Animated,
OpaqueColorValue, OpaqueColorValue,
@@ -29,7 +29,7 @@ const PRESS_FEEDBACK_DURATION = 120;
type IoniconName = ComponentProps<typeof Ionicons>["name"]; type IoniconName = ComponentProps<typeof Ionicons>["name"];
type RotateSwitchProps = { type SliceSwitchProps = {
size?: SwitchSize; size?: SwitchSize;
leftIcon?: IoniconName; leftIcon?: IoniconName;
leftIconColor?: string | OpaqueColorValue | undefined; leftIconColor?: string | OpaqueColorValue | undefined;
@@ -42,6 +42,7 @@ type RotateSwitchProps = {
activeOverlayColor?: string; activeOverlayColor?: string;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
onChange?: (value: boolean) => void; onChange?: (value: boolean) => void;
value?: boolean;
}; };
const SliceSwitch = ({ const SliceSwitch = ({
@@ -57,19 +58,28 @@ const SliceSwitch = ({
activeOverlayColor = "#000", activeOverlayColor = "#000",
style, style,
onChange, onChange,
}: RotateSwitchProps) => { value,
}: SliceSwitchProps) => {
const { width: containerWidth, height: containerHeight } = const { width: containerWidth, height: containerHeight } =
SIZE_PRESETS[size] ?? SIZE_PRESETS.md; SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0); const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
const [isOn, setIsOn] = useState(false); const [isOn, setIsOn] = useState(value ?? false);
const [bgOn, setBgOn] = useState(false); const [bgOn, setBgOn] = useState(value ?? false);
const progress = useRef(new Animated.Value(0)).current; const progress = useRef(new Animated.Value(value ? 1 : 0)).current;
const pressScale = useRef(new Animated.Value(1)).current; const pressScale = useRef(new Animated.Value(1)).current;
const overlayTranslateX = useRef(new Animated.Value(0)).current; const overlayTranslateX = useRef(
new Animated.Value(value ? containerWidth / 2 : 0)
).current;
const listenerIdRef = useRef<string | number | null>(null); const listenerIdRef = useRef<string | number | null>(null);
const handleToggle = () => { // Sync with external value prop if provided
const next = !isOn; useEffect(() => {
if (value !== undefined && value !== isOn) {
animateToValue(value);
}
}, [value]);
const animateToValue = (next: boolean) => {
const targetValue = next ? 1 : 0; const targetValue = next ? 1 : 0;
const overlayTarget = next ? containerWidth / 2 : 0; const overlayTarget = next ? containerWidth / 2 : 0;
@@ -81,7 +91,6 @@ const SliceSwitch = ({
overlayTranslateX.setValue(overlayTarget); overlayTranslateX.setValue(overlayTarget);
setIsOn(next); setIsOn(next);
setBgOn(next); setBgOn(next);
onChange?.(next);
return; return;
} }
@@ -100,7 +109,6 @@ const SliceSwitch = ({
}), }),
]).start(() => { ]).start(() => {
setBgOn(next); setBgOn(next);
onChange?.(next);
}); });
// Remove any previous listener // Remove any previous listener
@@ -132,6 +140,14 @@ const SliceSwitch = ({
}); });
}; };
const handleToggle = () => {
const next = !isOn;
if (value === undefined) {
animateToValue(next);
}
onChange?.(next);
};
const handlePressIn = () => { const handlePressIn = () => {
pressScale.stopAnimation(); pressScale.stopAnimation();
Animated.timing(pressScale, { Animated.timing(pressScale, {

View File

@@ -3,51 +3,82 @@
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. * 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",
}, },
}); });

163
hooks/use-app-theme.ts Normal file
View File

@@ -0,0 +1,163 @@
/**
* Custom hook for easy theme access throughout the app
* Provides styled components and theme utilities
*/
import { useThemeContext } from "@/hooks/use-theme-context";
import { useMemo } from "react";
import { StyleSheet, TextStyle, ViewStyle } from "react-native";
export function useAppTheme() {
const { colors, colorScheme, themeMode, setThemeMode, getColor } =
useThemeContext();
// Common styled components
const styles = useMemo(
() =>
StyleSheet.create({
// Container styles
container: {
flex: 1,
backgroundColor: colors.background,
} as ViewStyle,
surface: {
backgroundColor: colors.surface,
borderRadius: 12,
padding: 16,
} as ViewStyle,
card: {
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
shadowColor: colors.text,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
} as ViewStyle,
// Button styles
primaryButton: {
backgroundColor: colors.primary,
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
} as ViewStyle,
secondaryButton: {
backgroundColor: colors.backgroundSecondary,
borderWidth: 1,
borderColor: colors.border,
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
} as ViewStyle,
// Text styles
primaryButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
} as TextStyle,
secondaryButtonText: {
color: colors.text,
fontSize: 16,
fontWeight: "600",
} as TextStyle,
// Input styles
textInput: {
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
fontSize: 16,
color: colors.text,
} as ViewStyle & TextStyle,
// Separator
separator: {
height: 1,
backgroundColor: colors.separator,
} as ViewStyle,
// Status styles
successContainer: {
backgroundColor: `${colors.success}20`,
borderColor: colors.success,
borderWidth: 1,
borderRadius: 8,
padding: 12,
} as ViewStyle,
warningContainer: {
backgroundColor: `${colors.warning}20`,
borderColor: colors.warning,
borderWidth: 1,
borderRadius: 8,
padding: 12,
} as ViewStyle,
errorContainer: {
backgroundColor: `${colors.error}20`,
borderColor: colors.error,
borderWidth: 1,
borderRadius: 8,
padding: 12,
} as ViewStyle,
}),
[colors]
);
// Theme utilities
const utils = useMemo(
() => ({
// Get opacity color
getOpacityColor: (
colorName: keyof typeof colors,
opacity: number = 0.1
) => {
const color = colors[colorName];
const hex = color.replace("#", "");
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
},
// Check if current theme is dark
isDark: colorScheme === "dark",
// Check if current theme is light
isLight: colorScheme === "light",
// Toggle between light and dark (ignoring system)
toggleTheme: () => {
const newMode = colorScheme === "dark" ? "light" : "dark";
setThemeMode(newMode);
},
}),
[colors, colorScheme, setThemeMode]
);
return {
colors,
styles,
utils,
colorScheme,
themeMode,
setThemeMode,
getColor,
};
}
export type AppTheme = ReturnType<typeof useAppTheme>;

View File

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

@@ -0,0 +1,193 @@
/**
* Theme Context Hook for managing app-wide theme state.
* Supports Light, Dark, and System (automatic) modes across Expo platforms.
*
* IMPORTANT: Requires expo-system-ui plugin in app.json for Android status bar support.
*/
import { ColorName, Colors } from "@/constants/theme";
import { getStorageItem, setStorageItem } from "@/utils/storage";
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
Appearance,
AppState,
AppStateStatus,
useColorScheme as useRNColorScheme,
} from "react-native";
export type ThemeMode = "light" | "dark" | "system";
export type ColorScheme = "light" | "dark";
interface ThemeContextType {
themeMode: ThemeMode;
colorScheme: ColorScheme;
colors: typeof Colors.light;
setThemeMode: (mode: ThemeMode) => Promise<void>;
getColor: (colorName: ColorName) => string;
isHydrated: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const THEME_STORAGE_KEY = "theme_mode";
const getSystemScheme = (): ColorScheme => {
const scheme = Appearance.getColorScheme();
// console.log("[Theme] Appearance.getColorScheme():", scheme);
return scheme === "dark" ? "dark" : "light";
};
const isThemeMode = (value: unknown): value is ThemeMode => {
return value === "light" || value === "dark" || value === "system";
};
export function ThemeProvider({ children }: { children: ReactNode }) {
const [systemScheme, setSystemScheme] =
useState<ColorScheme>(getSystemScheme);
const [themeMode, setThemeModeState] = useState<ThemeMode>("system");
const [isHydrated, setIsHydrated] = useState(false);
const syncSystemScheme = useCallback(() => {
const next = getSystemScheme();
// console.log("[Theme] syncSystemScheme computed:", next);
setSystemScheme((current) => (current === next ? current : next));
}, []);
const rnScheme = useRNColorScheme();
useEffect(() => {
if (!rnScheme) return;
const next = rnScheme === "dark" ? "dark" : "light";
// console.log("[Theme] useColorScheme hook emitted:", rnScheme);
setSystemScheme((current) => (current === next ? current : next));
}, [rnScheme]);
useEffect(() => {
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
const next = colorScheme === "dark" ? "dark" : "light";
// console.log("[Theme] Appearance listener fired with:", colorScheme);
setSystemScheme((current) => (current === next ? current : next));
});
syncSystemScheme();
return () => {
subscription.remove();
};
}, [syncSystemScheme]);
useEffect(() => {
// console.log("[Theme] System scheme detected:", systemScheme);
}, [systemScheme]);
useEffect(() => {
const handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState === "active") {
// console.log("[Theme] AppState active → scheduling system scheme sync");
setTimeout(() => {
// console.log("[Theme] AppState sync callback running");
syncSystemScheme();
}, 100);
}
};
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => subscription.remove();
}, [syncSystemScheme]);
useEffect(() => {
let isMounted = true;
const hydrateThemeMode = async () => {
try {
const savedThemeMode = await getStorageItem(THEME_STORAGE_KEY);
if (isMounted && isThemeMode(savedThemeMode)) {
setThemeModeState(savedThemeMode);
}
} catch (error) {
console.warn("[Theme] Failed to load theme mode:", error);
} finally {
if (isMounted) {
setIsHydrated(true);
}
}
};
hydrateThemeMode();
return () => {
isMounted = false;
};
}, []);
const colorScheme: ColorScheme =
themeMode === "system" ? systemScheme : themeMode;
const colors = useMemo(() => Colors[colorScheme], [colorScheme]);
const setThemeMode = useCallback(async (mode: ThemeMode) => {
setThemeModeState(mode);
try {
await setStorageItem(THEME_STORAGE_KEY, mode);
} catch (error) {
console.warn("[Theme] Failed to save theme mode:", error);
}
}, []);
useEffect(() => {
// console.log("[Theme] window defined:", typeof window !== "undefined");
}, []);
const getColor = useCallback(
(colorName: ColorName) => colors[colorName] ?? colors.text,
[colors]
);
useEffect(() => {
// console.log("[Theme] Mode:", themeMode);
}, [themeMode]);
useEffect(() => {
// console.log("[Theme] Derived colorScheme:", colorScheme);
}, [colorScheme]);
const value = useMemo(
() => ({
themeMode,
colorScheme,
colors,
setThemeMode,
getColor,
isHydrated,
}),
[themeMode, colorScheme, colors, setThemeMode, getColor, isHydrated]
);
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
export const useThemeContext = useTheme;
export function useColorScheme(): ColorScheme {
return useTheme().colorScheme;
}

View File

@@ -18,7 +18,11 @@
"warning": "Warning", "warning": "Warning",
"language": "Language", "language": "Language",
"language_vi": "Vietnamese", "language_vi": "Vietnamese",
"language_en": "English" "language_en": "English",
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System"
}, },
"navigation": { "navigation": {
"home": "Monitor", "home": "Monitor",
@@ -54,6 +58,7 @@
} }
}, },
"trip": { "trip": {
"infoTrip": "Trip Information",
"createNewTrip": "Create New Trip", "createNewTrip": "Create New Trip",
"endTrip": "End Trip", "endTrip": "End Trip",
"cancelTrip": "Cancel Trip", "cancelTrip": "Cancel Trip",

View File

@@ -18,7 +18,11 @@
"warning": "Cảnh báo", "warning": "Cảnh báo",
"language": "Ngôn ngữ", "language": "Ngôn ngữ",
"language_vi": "Tiếng Việt", "language_vi": "Tiếng Việt",
"language_en": "Tiếng Anh" "language_en": "Tiếng Anh",
"theme": "Giao diện",
"theme_light": "Sáng",
"theme_dark": "Tối",
"theme_system": "Hệ thống"
}, },
"navigation": { "navigation": {
"home": "Giám sát", "home": "Giám sát",
@@ -54,6 +58,7 @@
} }
}, },
"trip": { "trip": {
"infoTrip": "Thông Tin Chuyến Đi",
"createNewTrip": "Tạo chuyến mới", "createNewTrip": "Tạo chuyến mới",
"endTrip": "Kết thúc chuyến", "endTrip": "Kết thúc chuyến",
"cancelTrip": "Hủy chuyến", "cancelTrip": "Hủy chuyến",

50
package-lock.json generated
View File

@@ -10,8 +10,6 @@
"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",
"@gluestack-ui/utils": "^3.0.11",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@islacel/react-native-custom-switch": "^1.0.10", "@islacel/react-native-custom-switch": "^1.0.10",
"@legendapp/motion": "^2.5.3", "@legendapp/motion": "^2.5.3",
@@ -2357,43 +2355,6 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@gluestack-ui/core": {
"version": "3.0.12",
"resolved": "https://registry.npmjs.org/@gluestack-ui/core/-/core-3.0.12.tgz",
"integrity": "sha512-TyNjDUJrZF/FTqcSEPBR87wZQ3yvbWuTjn0tG5AFYzYfMCw0IpfTigmzoajN9KHensN0xNwHoAkXKaHlhy11yQ==",
"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": {
"dom-helpers": "^6.0.1",
"react-aria": "^3.41.1",
"react-stately": "^3.39.0",
"tailwind-variants": "0.1.20"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-native": ">=0.64.0",
"react-native-web": ">=0.19.0",
"tailwindcss": ">=3.0.0"
}
},
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
@@ -7379,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": {
@@ -7625,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",

View File

@@ -5,16 +5,14 @@
"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",
"@gluestack-ui/utils": "^3.0.11",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@islacel/react-native-custom-switch": "^1.0.10", "@islacel/react-native-custom-switch": "^1.0.10",
"@legendapp/motion": "^2.5.3", "@legendapp/motion": "^2.5.3",