Khởi tạo ban đầu
79
.gitignore
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
app-example
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
|
|
||||||
|
# IDE & Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Testing & Coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
.env.test
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
502
THEME_GUIDE.md
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
# Theme System Documentation
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
|
||||||
|
Hệ thống theme hỗ trợ **Light Mode**, **Dark Mode** và **System Mode** (tự động theo hệ thống). Theme preference được lưu trong AsyncStorage và tự động khôi phục khi khởi động lại ứng dụng.
|
||||||
|
|
||||||
|
## Kiến trúc Theme System
|
||||||
|
|
||||||
|
### 1. Theme Provider (`hooks/use-theme-context.tsx`)
|
||||||
|
|
||||||
|
Theme Provider là core của hệ thống theme, quản lý state và đồng bộ với system theme.
|
||||||
|
|
||||||
|
**Các tính năng chính:**
|
||||||
|
|
||||||
|
- Quản lý `themeMode`: `'light' | 'dark' | 'system'`
|
||||||
|
- Tự động detect system theme thông qua nhiều nguồn:
|
||||||
|
- `Appearance.getColorScheme()` - iOS/Android system theme
|
||||||
|
- `useColorScheme()` hook từ React Native
|
||||||
|
- `Appearance.addChangeListener()` - listen system theme changes
|
||||||
|
- `AppState` listener - sync lại khi app active
|
||||||
|
- Lưu và restore theme preference từ AsyncStorage
|
||||||
|
- Export ra `colorScheme` cuối cùng: `'light' | 'dark'`
|
||||||
|
|
||||||
|
**ThemeContextType:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ThemeContextType {
|
||||||
|
themeMode: ThemeMode; // User's choice: 'light' | 'dark' | 'system'
|
||||||
|
colorScheme: ColorScheme; // Final theme: 'light' | 'dark'
|
||||||
|
colors: typeof Colors.light; // Theme colors object
|
||||||
|
setThemeMode: (mode: ThemeMode) => Promise<void>;
|
||||||
|
getColor: (colorName: ColorName) => string;
|
||||||
|
isHydrated: boolean; // AsyncStorage đã load xong
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cách hoạt động:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Xác định colorScheme cuối cùng
|
||||||
|
const colorScheme: ColorScheme =
|
||||||
|
themeMode === "system" ? systemScheme : themeMode;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Colors Configuration (`constants/theme.ts`)
|
||||||
|
|
||||||
|
Định nghĩa tất cả colors cho light và dark theme:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const Colors = {
|
||||||
|
light: {
|
||||||
|
text: "#11181C",
|
||||||
|
textSecondary: "#687076",
|
||||||
|
background: "#fff",
|
||||||
|
backgroundSecondary: "#f5f5f5",
|
||||||
|
surface: "#ffffff",
|
||||||
|
surfaceSecondary: "#f8f9fa",
|
||||||
|
tint: "#0a7ea4",
|
||||||
|
primary: "#007AFF",
|
||||||
|
secondary: "#5AC8FA",
|
||||||
|
success: "#34C759",
|
||||||
|
warning: "#ff6600",
|
||||||
|
error: "#FF3B30",
|
||||||
|
icon: "#687076",
|
||||||
|
border: "#C6C6C8",
|
||||||
|
separator: "#E5E5E7",
|
||||||
|
card: "#ffffff",
|
||||||
|
// ... more colors
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: "#ECEDEE",
|
||||||
|
textSecondary: "#8E8E93",
|
||||||
|
background: "#000000",
|
||||||
|
backgroundSecondary: "#1C1C1E",
|
||||||
|
surface: "#1C1C1E",
|
||||||
|
surfaceSecondary: "#2C2C2E",
|
||||||
|
tint: "#fff",
|
||||||
|
primary: "#0A84FF",
|
||||||
|
secondary: "#64D2FF",
|
||||||
|
success: "#30D158",
|
||||||
|
warning: "#ff6600",
|
||||||
|
error: "#FF453A",
|
||||||
|
icon: "#8E8E93",
|
||||||
|
border: "#38383A",
|
||||||
|
separator: "#38383A",
|
||||||
|
card: "#1C1C1E",
|
||||||
|
// ... more colors
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorName = keyof typeof Colors.light;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Setup trong App (`app/_layout.tsx`)
|
||||||
|
|
||||||
|
Theme Provider phải wrap toàn bộ app:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<AppThemeProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AppThemeProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { colorScheme } = useThemeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||||
|
<Stack>{/* ... routes */}</Stack>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cách sử dụng Theme
|
||||||
|
|
||||||
|
### 1. useThemeContext (Core Hook)
|
||||||
|
|
||||||
|
Hook chính để access theme state:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const {
|
||||||
|
themeMode, // 'light' | 'dark' | 'system'
|
||||||
|
colorScheme, // 'light' | 'dark'
|
||||||
|
colors, // Colors object
|
||||||
|
setThemeMode, // Change theme
|
||||||
|
getColor, // Get color by name
|
||||||
|
isHydrated, // AsyncStorage loaded
|
||||||
|
} = useThemeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ backgroundColor: colors.background }}>
|
||||||
|
<Text style={{ color: colors.text }}>
|
||||||
|
Mode: {themeMode}, Scheme: {colorScheme}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. useColorScheme Hook
|
||||||
|
|
||||||
|
Alias để lấy colorScheme nhanh:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useColorScheme } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const colorScheme = useColorScheme(); // 'light' | 'dark'
|
||||||
|
|
||||||
|
return <Text>Current theme: {colorScheme}</Text>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Lưu ý:** `useColorScheme` từ `use-theme-context.tsx`, KHÔNG phải từ `react-native`.
|
||||||
|
|
||||||
|
### 3. useThemeColor Hook
|
||||||
|
|
||||||
|
Override colors cho specific themes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
// Với override
|
||||||
|
const backgroundColor = useThemeColor(
|
||||||
|
{ light: "#ffffff", dark: "#1C1C1E" },
|
||||||
|
"surface"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Không override, dùng color từ theme
|
||||||
|
const textColor = useThemeColor({}, "text");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ backgroundColor }}>
|
||||||
|
<Text style={{ color: textColor }}>Text</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cách hoạt động:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Ưu tiên props override trước, sau đó mới dùng Colors
|
||||||
|
const colorFromProps = props[colorScheme];
|
||||||
|
return colorFromProps || Colors[colorScheme][colorName];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. useAppTheme Hook (Recommended)
|
||||||
|
|
||||||
|
Hook tiện lợi với pre-built styles và utilities:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { colors, styles, utils } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<TouchableOpacity style={styles.primaryButton}>
|
||||||
|
<Text style={styles.primaryButtonText}>Primary Button</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={{ color: colors.text }}>
|
||||||
|
Theme is {utils.isDark ? "Dark" : "Light"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: utils.getOpacityColor("primary", 0.1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>Transparent background</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Themed Components
|
||||||
|
|
||||||
|
**ThemedView** và **ThemedText** - Tự động apply theme colors:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
return (
|
||||||
|
<ThemedView>
|
||||||
|
<ThemedText type="title">Title Text</ThemedText>
|
||||||
|
<ThemedText type="subtitle">Subtitle</ThemedText>
|
||||||
|
<ThemedText type="default">Regular Text</ThemedText>
|
||||||
|
<ThemedText type="link">Link Text</ThemedText>
|
||||||
|
|
||||||
|
{/* Override với custom colors */}
|
||||||
|
<ThemedText lightColor="#000000" darkColor="#FFFFFF">
|
||||||
|
Custom colored text
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ThemedText types:**
|
||||||
|
|
||||||
|
- `default` - 16px regular
|
||||||
|
- `title` - 32px bold
|
||||||
|
- `subtitle` - 20px bold
|
||||||
|
- `defaultSemiBold` - 16px semibold
|
||||||
|
- `link` - 16px với color #0a7ea4
|
||||||
|
|
||||||
|
### 6. Theme Toggle Component
|
||||||
|
|
||||||
|
Component có sẵn để user chọn theme:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
|
function SettingsScreen() {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ThemeToggle />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Component này hiển thị 3 options: Light, Dark, System với icons và labels đa ngôn ngữ.
|
||||||
|
|
||||||
|
## Available Styles từ useAppTheme
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { styles } = useAppTheme();
|
||||||
|
|
||||||
|
// Container styles
|
||||||
|
styles.container; // Flex 1 container với background
|
||||||
|
styles.surface; // Surface với padding 16, borderRadius 12
|
||||||
|
styles.card; // Card với shadow, elevation
|
||||||
|
|
||||||
|
// Button styles
|
||||||
|
styles.primaryButton; // Primary button với colors.primary
|
||||||
|
styles.secondaryButton; // Secondary button với border
|
||||||
|
styles.primaryButtonText; // White text cho primary button
|
||||||
|
styles.secondaryButtonText; // Theme text cho secondary button
|
||||||
|
|
||||||
|
// Input styles
|
||||||
|
styles.textInput; // Text input với border, padding
|
||||||
|
|
||||||
|
// Status styles
|
||||||
|
styles.successContainer; // Success background với border
|
||||||
|
styles.warningContainer; // Warning background với border
|
||||||
|
styles.errorContainer; // Error background với border
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
styles.separator; // 1px line separator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme Utilities
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { utils } = useAppTheme();
|
||||||
|
|
||||||
|
// Check theme
|
||||||
|
utils.isDark; // boolean - true nếu dark mode
|
||||||
|
utils.isLight; // boolean - true nếu light mode
|
||||||
|
|
||||||
|
// Toggle theme (ignores system mode)
|
||||||
|
utils.toggleTheme(); // Switch giữa light ↔ dark
|
||||||
|
|
||||||
|
// Get color với opacity
|
||||||
|
utils.getOpacityColor("primary", 0.1); // rgba(0, 122, 255, 0.1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Luồng hoạt động của Theme System
|
||||||
|
|
||||||
|
```
|
||||||
|
1. App khởi động
|
||||||
|
└─→ ThemeProvider mount
|
||||||
|
├─→ Load saved themeMode từ AsyncStorage ('light'/'dark'/'system')
|
||||||
|
├─→ Detect systemScheme từ OS
|
||||||
|
│ ├─→ Appearance.getColorScheme()
|
||||||
|
│ ├─→ useColorScheme() hook
|
||||||
|
│ └─→ Appearance.addChangeListener()
|
||||||
|
└─→ Tính toán colorScheme cuối cùng
|
||||||
|
└─→ themeMode === 'system' ? systemScheme : themeMode
|
||||||
|
|
||||||
|
2. User thay đổi system theme
|
||||||
|
└─→ Appearance listener fire
|
||||||
|
└─→ Update systemScheme state
|
||||||
|
└─→ Nếu themeMode === 'system'
|
||||||
|
└─→ colorScheme tự động update
|
||||||
|
└─→ Components re-render với colors mới
|
||||||
|
|
||||||
|
3. User chọn theme trong app
|
||||||
|
└─→ setThemeMode('light'/'dark'/'system')
|
||||||
|
├─→ Update themeMode state
|
||||||
|
├─→ Save vào AsyncStorage
|
||||||
|
└─→ colorScheme update
|
||||||
|
└─→ Components re-render
|
||||||
|
|
||||||
|
4. App về foreground
|
||||||
|
└─→ AppState listener fire
|
||||||
|
└─→ Sync lại systemScheme (phòng user đổi system theme khi app background)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Theme preference được lưu với key: `'theme_mode'`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Tự động xử lý bởi ThemeProvider
|
||||||
|
await setStorageItem("theme_mode", "light" | "dark" | "system");
|
||||||
|
const savedMode = await getStorageItem("theme_mode");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Sử dụng hooks đúng context:**
|
||||||
|
|
||||||
|
- `useThemeContext()` - Khi cần full control (themeMode, setThemeMode)
|
||||||
|
- `useColorScheme()` - Chỉ cần biết light/dark
|
||||||
|
- `useAppTheme()` - Recommended cho UI components (có styles + utils)
|
||||||
|
- `useThemeColor()` - Khi cần override colors
|
||||||
|
|
||||||
|
2. **Sử dụng Themed Components:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good ✅
|
||||||
|
<ThemedView>
|
||||||
|
<ThemedText>Hello</ThemedText>
|
||||||
|
</ThemedView>;
|
||||||
|
|
||||||
|
// Also good ✅
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
<View style={{ backgroundColor: colors.background }}>
|
||||||
|
<Text style={{ color: colors.text }}>Hello</Text>
|
||||||
|
</View>;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Tận dụng pre-built styles:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good ✅
|
||||||
|
const { styles } = useAppTheme();
|
||||||
|
<TouchableOpacity style={styles.primaryButton}>
|
||||||
|
|
||||||
|
// Less good ❌
|
||||||
|
<TouchableOpacity style={{
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12
|
||||||
|
}}>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Sử dụng opacity colors:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { utils } = useAppTheme();
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: utils.getOpacityColor("primary", 0.1),
|
||||||
|
}}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Check theme correctly:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good ✅
|
||||||
|
const { utils } = useAppTheme();
|
||||||
|
if (utils.isDark) { ... }
|
||||||
|
|
||||||
|
// Also good ✅
|
||||||
|
const { colorScheme } = useThemeContext();
|
||||||
|
if (colorScheme === 'dark') { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Theme không được lưu
|
||||||
|
|
||||||
|
- Kiểm tra AsyncStorage permissions
|
||||||
|
- Check logs trong console: `[Theme] Failed to save theme mode:`
|
||||||
|
|
||||||
|
### Flash màu sắc khi khởi động
|
||||||
|
|
||||||
|
- ThemeProvider đã xử lý với `isHydrated` state
|
||||||
|
- Chờ AsyncStorage load xong trước khi render
|
||||||
|
|
||||||
|
### System theme không update
|
||||||
|
|
||||||
|
- Check Appearance listener đã register: `[Theme] Registering appearance listener`
|
||||||
|
- Check logs: `[Theme] System theme changed to: ...`
|
||||||
|
- iOS: Restart app sau khi đổi system theme
|
||||||
|
- Android: Cần `expo-system-ui` plugin trong `app.json`
|
||||||
|
|
||||||
|
### Colors không đúng
|
||||||
|
|
||||||
|
- Đảm bảo app wrapped trong `<AppThemeProvider>`
|
||||||
|
- Check `colorScheme` trong console logs
|
||||||
|
- Verify Colors object trong `constants/theme.ts`
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
Nếu đang dùng old theme system:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Old ❌
|
||||||
|
import { useColorScheme } from "react-native";
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const backgroundColor = colorScheme === "dark" ? "#000" : "#fff";
|
||||||
|
|
||||||
|
// New ✅
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const backgroundColor = colors.background;
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Old ❌
|
||||||
|
const [theme, setTheme] = useState("light");
|
||||||
|
|
||||||
|
// New ✅
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
const { themeMode, setThemeMode } = useThemeContext();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debug Logs
|
||||||
|
|
||||||
|
Enable logs để debug theme issues:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Trong use-theme-context.tsx, uncomment các dòng:
|
||||||
|
console.log("[Theme] Appearance.getColorScheme():", scheme);
|
||||||
|
console.log("[Theme] System theme changed to:", newScheme);
|
||||||
|
console.log("[Theme] Mode:", themeMode);
|
||||||
|
console.log("[Theme] Derived colorScheme:", colorScheme);
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Trong use-theme-color.ts:
|
||||||
|
console.log("Detected theme:", theme); // Đã có sẵn
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Trong _layout.tsx:
|
||||||
|
console.log("Color Scheme: ", colorScheme); // Đã có sẵn
|
||||||
|
```
|
||||||
89
app.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "sgw-owner",
|
||||||
|
"slug": "sgw-owner",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "sgwapp",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"infoPlist": {
|
||||||
|
"CFBundleLocalizations": [
|
||||||
|
"en",
|
||||||
|
"vi"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bundleIdentifier": "com.minhnn86.sgwapp"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.RECORD_AUDIO"
|
||||||
|
],
|
||||||
|
"package": "com.minhnn86.sgwapp"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png",
|
||||||
|
"bundler": "metro"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-system-ui",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 200,
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"dark": {
|
||||||
|
"backgroundColor": "#000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-localization",
|
||||||
|
{
|
||||||
|
"supportedLocales": {
|
||||||
|
"ios": [
|
||||||
|
"en",
|
||||||
|
"vi"
|
||||||
|
],
|
||||||
|
"android": [
|
||||||
|
"en",
|
||||||
|
"vi"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "d4ef1318-5427-4c96-ad88-97ec117829cc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Tabs, useSegments } from "expo-router";
|
||||||
|
|
||||||
|
import { HapticTab } from "@/components/haptic-tab";
|
||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useColorScheme } from "@/hooks/use-theme-context";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const segments = useSegments() as string[];
|
||||||
|
const prev = useRef<string | null>(null);
|
||||||
|
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
useEffect(() => {
|
||||||
|
if (prev.current !== currentSegment) {
|
||||||
|
// console.log("Tab changed ->", { from: prev.current, to: currentSegment });
|
||||||
|
// TODO: xử lý khi chuyển tab ở đây
|
||||||
|
if (prev.current === "(tabs)" && currentSegment !== "(tabs)") {
|
||||||
|
// stopEvents();
|
||||||
|
// console.log("Stop events");
|
||||||
|
} else if (prev.current !== "(tabs)" && currentSegment === "(tabs)") {
|
||||||
|
// we came back into the tabs group — restart polling
|
||||||
|
// startEvents();
|
||||||
|
// console.log("start events");
|
||||||
|
}
|
||||||
|
prev.current = currentSegment;
|
||||||
|
}
|
||||||
|
}, [currentSegment]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
|
||||||
|
headerShown: false,
|
||||||
|
tabBarButton: HapticTab,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: t("navigation.home"),
|
||||||
|
tabBarIcon: ({ color }) => (
|
||||||
|
<IconSymbol size={28} name="map.fill" color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs.Screen
|
||||||
|
name="tripInfo"
|
||||||
|
options={{
|
||||||
|
title: t("navigation.trip"),
|
||||||
|
tabBarIcon: ({ color }) => (
|
||||||
|
<IconSymbol size={28} name="ferry.fill" color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="diary"
|
||||||
|
options={{
|
||||||
|
title: t("navigation.diary"),
|
||||||
|
tabBarIcon: ({ color }) => (
|
||||||
|
<IconSymbol size={28} name="book.closed.fill" color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="sensor"
|
||||||
|
options={{
|
||||||
|
title: t("navigation.sensor"),
|
||||||
|
tabBarIcon: ({ color }) => (
|
||||||
|
<IconSymbol
|
||||||
|
size={28}
|
||||||
|
name="dot.radiowaves.left.and.right"
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="setting"
|
||||||
|
options={{
|
||||||
|
title: t("navigation.setting"),
|
||||||
|
tabBarIcon: ({ color }) => (
|
||||||
|
<IconSymbol size={28} name="gear" color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
app/(tabs)/diary.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import CreateOrUpdateHaulModal from "@/components/tripInfo/modal/CreateOrUpdateHaulModal";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Platform, StyleSheet, View } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function Warning() {
|
||||||
|
const [isShowModal, setIsShowModal] = useState(false);
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
|
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 15,
|
||||||
|
},
|
||||||
|
titleText: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "700",
|
||||||
|
lineHeight: 40,
|
||||||
|
marginBottom: 10,
|
||||||
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
461
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
|
||||||
|
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
|
||||||
|
import { IOS_PLATFORM, LIGHT_THEME } from "@/constants";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Animated, StyleSheet, View } from "react-native";
|
||||||
|
import MapView from "react-native-maps";
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const [gpsData, setGpsData] = useState<Model.GPSResponse | null>(null);
|
||||||
|
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
||||||
|
const [entityData, setEntityData] = useState<
|
||||||
|
Model.TransformedEntity[] | null
|
||||||
|
>(null);
|
||||||
|
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
|
||||||
|
const [trackPointsData, setTrackPointsData] = useState<
|
||||||
|
Model.ShipTrackPoint[] | null
|
||||||
|
>(null);
|
||||||
|
const [circleRadius, setCircleRadius] = useState(100);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(10);
|
||||||
|
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||||
|
const [polylineCoordinates, setPolylineCoordinates] = useState<
|
||||||
|
PolylineWithLabelProps[]
|
||||||
|
>([]);
|
||||||
|
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
||||||
|
PolygonWithLabelProps[]
|
||||||
|
>([]);
|
||||||
|
const platform = usePlatform();
|
||||||
|
const theme = useThemeContext().colorScheme;
|
||||||
|
const scale = useRef(new Animated.Value(0)).current;
|
||||||
|
const opacity = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// getGpsEventBus();
|
||||||
|
// getAlarmEventBus();
|
||||||
|
// getEntitiesEventBus();
|
||||||
|
// getBanzonesEventBus();
|
||||||
|
// getTrackPointsEventBus();
|
||||||
|
// const queryGpsData = (gpsData: Model.GPSResponse) => {
|
||||||
|
// if (gpsData) {
|
||||||
|
// // console.log("GPS Data: ", gpsData);
|
||||||
|
// setGpsData(gpsData);
|
||||||
|
// } else {
|
||||||
|
// setGpsData(null);
|
||||||
|
// setPolygonCoordinates([]);
|
||||||
|
// setPolylineCoordinates([]);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// const queryAlarmData = (alarmData: Model.AlarmResponse) => {
|
||||||
|
// // console.log("Alarm Data: ", alarmData.alarms.length);
|
||||||
|
// setAlarmData(alarmData);
|
||||||
|
// };
|
||||||
|
// const queryEntityData = (entityData: Model.TransformedEntity[]) => {
|
||||||
|
// // console.log("Entities Length Data: ", entityData.length);
|
||||||
|
// setEntityData(entityData);
|
||||||
|
// };
|
||||||
|
// const queryBanzonesData = (banzoneData: Model.Zone[]) => {
|
||||||
|
// // console.log("Banzone Data: ", banzoneData.length);
|
||||||
|
|
||||||
|
// setBanzoneData(banzoneData);
|
||||||
|
// };
|
||||||
|
// const queryTrackPointsData = (TrackPointsData: Model.ShipTrackPoint[]) => {
|
||||||
|
// // console.log("TrackPoints Data: ", TrackPointsData.length);
|
||||||
|
// if (TrackPointsData && TrackPointsData.length > 0) {
|
||||||
|
// setTrackPointsData(TrackPointsData);
|
||||||
|
// } else {
|
||||||
|
// setTrackPointsData([]);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// eventBus.on(EVENT_GPS_DATA, queryGpsData);
|
||||||
|
// // console.log("Registering event handlers in HomeScreen");
|
||||||
|
// eventBus.on(EVENT_GPS_DATA, queryGpsData);
|
||||||
|
// // console.log("Subscribed to EVENT_GPS_DATA");
|
||||||
|
// eventBus.on(EVENT_ALARM_DATA, queryAlarmData);
|
||||||
|
// // console.log("Subscribed to EVENT_ALARM_DATA");
|
||||||
|
// eventBus.on(EVENT_ENTITY_DATA, queryEntityData);
|
||||||
|
// // console.log("Subscribed to EVENT_ENTITY_DATA");
|
||||||
|
// eventBus.on(EVENT_TRACK_POINTS_DATA, queryTrackPointsData);
|
||||||
|
// // console.log("Subscribed to EVENT_TRACK_POINTS_DATA");
|
||||||
|
// eventBus.once(EVENT_BANZONE_DATA, queryBanzonesData);
|
||||||
|
// // console.log("Subscribed once to EVENT_BANZONE_DATA");
|
||||||
|
|
||||||
|
// return () => {
|
||||||
|
// // console.log("Unregistering event handlers in HomeScreen");
|
||||||
|
// eventBus.off(EVENT_GPS_DATA, queryGpsData);
|
||||||
|
// // console.log("Unsubscribed EVENT_GPS_DATA");
|
||||||
|
// eventBus.off(EVENT_ALARM_DATA, queryAlarmData);
|
||||||
|
// // console.log("Unsubscribed EVENT_ALARM_DATA");
|
||||||
|
// eventBus.off(EVENT_ENTITY_DATA, queryEntityData);
|
||||||
|
// // console.log("Unsubscribed EVENT_ENTITY_DATA");
|
||||||
|
// eventBus.off(EVENT_TRACK_POINTS_DATA, queryTrackPointsData);
|
||||||
|
// // console.log("Unsubscribed EVENT_TRACK_POINTS_DATA");
|
||||||
|
// };
|
||||||
|
// }, []);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// setPolylineCoordinates([]);
|
||||||
|
// setPolygonCoordinates([]);
|
||||||
|
// if (!entityData) return;
|
||||||
|
// if (!banzoneData) return;
|
||||||
|
// for (const entity of entityData) {
|
||||||
|
// if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// let zones: any[] = [];
|
||||||
|
// try {
|
||||||
|
// zones = entity.valueString ? JSON.parse(entity.valueString) : [];
|
||||||
|
// } catch (parseError) {
|
||||||
|
// console.error("Error parsing zone list:", parseError);
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
// // Nếu danh sách zone rỗng, clear tất cả
|
||||||
|
// if (zones.length === 0) {
|
||||||
|
// setPolylineCoordinates([]);
|
||||||
|
// setPolygonCoordinates([]);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// let polylines: PolylineWithLabelProps[] = [];
|
||||||
|
// let polygons: PolygonWithLabelProps[] = [];
|
||||||
|
|
||||||
|
// for (const zone of zones) {
|
||||||
|
// // console.log("Zone Data: ", zone);
|
||||||
|
// const geom = banzoneData.find((b) => b.id === zone.zone_id);
|
||||||
|
// if (!geom) {
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
// const { geom_type, geom_lines, geom_poly } = geom.geom || {};
|
||||||
|
// if (typeof geom_type !== "number") {
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
// if (geom_type === 2) {
|
||||||
|
// // if(oldEntityData.find(e => e.id === ))
|
||||||
|
// // foundPolyline = true;
|
||||||
|
// const coordinates = convertWKTLineStringToLatLngArray(
|
||||||
|
// geom_lines || ""
|
||||||
|
// );
|
||||||
|
// if (coordinates.length > 0) {
|
||||||
|
// polylines.push({
|
||||||
|
// coordinates: coordinates.map((coord) => ({
|
||||||
|
// latitude: coord[0],
|
||||||
|
// longitude: coord[1],
|
||||||
|
// })),
|
||||||
|
// label: zone?.zone_name ?? "",
|
||||||
|
// content: zone?.message ?? "",
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// console.log("Không tìm thấy polyline trong alarm");
|
||||||
|
// }
|
||||||
|
// } else if (geom_type === 1) {
|
||||||
|
// // foundPolygon = true;
|
||||||
|
// const coordinates = convertWKTtoLatLngString(geom_poly || "");
|
||||||
|
// if (coordinates.length > 0) {
|
||||||
|
// // console.log("Polygon Coordinate: ", coordinates);
|
||||||
|
// const zonePolygons = coordinates.map((polygon) => ({
|
||||||
|
// coordinates: polygon.map((coord) => ({
|
||||||
|
// latitude: coord[0],
|
||||||
|
// longitude: coord[1],
|
||||||
|
// })),
|
||||||
|
// label: zone?.zone_name ?? "",
|
||||||
|
// content: zone?.message ?? "",
|
||||||
|
// }));
|
||||||
|
// polygons.push(...zonePolygons);
|
||||||
|
// } else {
|
||||||
|
// console.log("Không tìm thấy polygon trong alarm");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setPolylineCoordinates(polylines);
|
||||||
|
// setPolygonCoordinates(polygons);
|
||||||
|
// }
|
||||||
|
// }, [banzoneData, entityData]);
|
||||||
|
|
||||||
|
// Hàm tính radius cố định khi zoom change
|
||||||
|
const calculateRadiusFromZoom = (zoom: number) => {
|
||||||
|
const baseZoom = 10;
|
||||||
|
const baseRadius = 100;
|
||||||
|
const zoomDifference = baseZoom - zoom;
|
||||||
|
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
|
||||||
|
// console.log("Caculate Radius: ", calculatedRadius);
|
||||||
|
|
||||||
|
return Math.max(calculatedRadius, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xử lý khi region (zoom) thay đổi
|
||||||
|
const handleRegionChangeComplete = (newRegion: any) => {
|
||||||
|
// Tính zoom level từ latitudeDelta
|
||||||
|
// zoom = log2(360 / (latitudeDelta * 2)) + 8
|
||||||
|
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
|
||||||
|
const newRadius = calculateRadiusFromZoom(zoom);
|
||||||
|
setCircleRadius(newRadius);
|
||||||
|
setZoomLevel(zoom);
|
||||||
|
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tính toán region để focus vào marker của tàu (chỉ lần đầu tiên)
|
||||||
|
const getMapRegion = () => {
|
||||||
|
if (!isFirstLoad) {
|
||||||
|
// Sau lần đầu, return undefined để không force region
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!gpsData) {
|
||||||
|
return {
|
||||||
|
latitude: 15.70581,
|
||||||
|
longitude: 116.152685,
|
||||||
|
latitudeDelta: 0.05,
|
||||||
|
longitudeDelta: 0.05,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: gpsData.lat,
|
||||||
|
longitude: gpsData.lon,
|
||||||
|
latitudeDelta: 0.05,
|
||||||
|
longitudeDelta: 0.05,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMapReady = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsFirstLoad(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (alarmData?.level === 3) {
|
||||||
|
// const loop = Animated.loop(
|
||||||
|
// Animated.sequence([
|
||||||
|
// Animated.parallel([
|
||||||
|
// Animated.timing(scale, {
|
||||||
|
// toValue: 3, // nở to 3 lần
|
||||||
|
// duration: 1500,
|
||||||
|
// useNativeDriver: true,
|
||||||
|
// }),
|
||||||
|
// Animated.timing(opacity, {
|
||||||
|
// toValue: 0, // mờ dần
|
||||||
|
// duration: 1500,
|
||||||
|
// useNativeDriver: true,
|
||||||
|
// }),
|
||||||
|
// ]),
|
||||||
|
// Animated.parallel([
|
||||||
|
// Animated.timing(scale, {
|
||||||
|
// toValue: 0,
|
||||||
|
// duration: 0,
|
||||||
|
// useNativeDriver: true,
|
||||||
|
// }),
|
||||||
|
// Animated.timing(opacity, {
|
||||||
|
// toValue: 1,
|
||||||
|
// duration: 0,
|
||||||
|
// useNativeDriver: true,
|
||||||
|
// }),
|
||||||
|
// ]),
|
||||||
|
// ])
|
||||||
|
// );
|
||||||
|
// loop.start();
|
||||||
|
// return () => loop.stop();
|
||||||
|
// }
|
||||||
|
// }, [alarmData?.level, scale, opacity]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
// edges={["top"]}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
<MapView
|
||||||
|
onMapReady={handleMapReady}
|
||||||
|
onRegionChangeComplete={handleRegionChangeComplete}
|
||||||
|
style={styles.map}
|
||||||
|
// initialRegion={getMapRegion()}
|
||||||
|
region={getMapRegion()}
|
||||||
|
userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"}
|
||||||
|
showsBuildings={false}
|
||||||
|
showsIndoors={false}
|
||||||
|
loadingEnabled={true}
|
||||||
|
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
|
||||||
|
rotateEnabled={false}
|
||||||
|
>
|
||||||
|
{/* {trackPointsData &&
|
||||||
|
trackPointsData.length > 0 &&
|
||||||
|
trackPointsData.map((point, index) => {
|
||||||
|
// console.log(`Rendering circle ${index}:`, point);
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
key={`circle-${index}`}
|
||||||
|
center={{
|
||||||
|
latitude: point.lat,
|
||||||
|
longitude: point.lon,
|
||||||
|
}}
|
||||||
|
// zIndex={50}
|
||||||
|
// radius={platform === IOS_PLATFORM ? 200 : 50}
|
||||||
|
radius={circleRadius}
|
||||||
|
strokeColor="rgba(16, 85, 201, 0.7)"
|
||||||
|
fillColor="rgba(16, 85, 201, 0.7)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{polylineCoordinates.length > 0 && (
|
||||||
|
<>
|
||||||
|
{polylineCoordinates.map((polyline, index) => (
|
||||||
|
<PolylineWithLabel
|
||||||
|
key={`polyline-${index}-${gpsData?.lat || 0}-${
|
||||||
|
gpsData?.lon || 0
|
||||||
|
}`}
|
||||||
|
coordinates={polyline.coordinates}
|
||||||
|
label={polyline.label}
|
||||||
|
content={polyline.content}
|
||||||
|
strokeColor="#FF5733"
|
||||||
|
strokeWidth={4}
|
||||||
|
showDistance={false}
|
||||||
|
// zIndex={50}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{polygonCoordinates.length > 0 && (
|
||||||
|
<>
|
||||||
|
{polygonCoordinates.map((polygon, index) => {
|
||||||
|
return (
|
||||||
|
<PolygonWithLabel
|
||||||
|
key={`polygon-${index}-${gpsData?.lat || 0}-${
|
||||||
|
gpsData?.lon || 0
|
||||||
|
}`}
|
||||||
|
coordinates={polygon.coordinates}
|
||||||
|
label={polygon.label}
|
||||||
|
content={polygon.content}
|
||||||
|
fillColor="rgba(16, 85, 201, 0.6)"
|
||||||
|
strokeColor="rgba(16, 85, 201, 0.8)"
|
||||||
|
strokeWidth={2}
|
||||||
|
// zIndex={50}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)} */}
|
||||||
|
{/* {gpsData !== null && (
|
||||||
|
<Marker
|
||||||
|
key={
|
||||||
|
platform === IOS_PLATFORM
|
||||||
|
? `${gpsData.lat}-${gpsData.lon}`
|
||||||
|
: "gps-data"
|
||||||
|
}
|
||||||
|
coordinate={{
|
||||||
|
latitude: gpsData.lat,
|
||||||
|
longitude: gpsData.lon,
|
||||||
|
}}
|
||||||
|
zIndex={20}
|
||||||
|
anchor={
|
||||||
|
platform === IOS_PLATFORM
|
||||||
|
? { x: 0.5, y: 0.5 }
|
||||||
|
: { x: 0.6, y: 0.4 }
|
||||||
|
}
|
||||||
|
tracksViewChanges={platform === IOS_PLATFORM ? true : undefined}
|
||||||
|
>
|
||||||
|
<View className="w-8 h-8 items-center justify-center">
|
||||||
|
<View style={styles.pingContainer}>
|
||||||
|
{alarmData?.level === 3 && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.pingCircle,
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
opacity,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<RNImage
|
||||||
|
source={(() => {
|
||||||
|
const icon = getShipIcon(
|
||||||
|
alarmData?.level || 0,
|
||||||
|
gpsData.fishing
|
||||||
|
);
|
||||||
|
// console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing);
|
||||||
|
return typeof icon === "string" ? { uri: icon } : icon;
|
||||||
|
})()}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
rotate: `${
|
||||||
|
typeof gpsData.h === "number" && !isNaN(gpsData.h)
|
||||||
|
? gpsData.h
|
||||||
|
: 0
|
||||||
|
}deg`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
)} */}
|
||||||
|
</MapView>
|
||||||
|
|
||||||
|
{/* <View className="absolute top-14 right-2 shadow-md">
|
||||||
|
<SosButton />
|
||||||
|
</View>
|
||||||
|
<GPSInfoPanel gpsData={gpsData!} /> */}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
// display: "none",
|
||||||
|
position: "absolute",
|
||||||
|
top: 50,
|
||||||
|
right: 20,
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
elevation: 5,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
|
||||||
|
pingContainer: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
pingCircle: {
|
||||||
|
position: "absolute",
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "#ED3F27",
|
||||||
|
},
|
||||||
|
centerDot: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#0096FF",
|
||||||
|
},
|
||||||
|
});
|
||||||
129
app/(tabs)/sensor.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import ScanQRCode from "@/components/ScanQRCode";
|
||||||
|
import Select from "@/components/Select";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function Sensor() {
|
||||||
|
const [scanModalVisible, setScanModalVisible] = useState(false);
|
||||||
|
const [scannedData, setScannedData] = useState<string | null>(null);
|
||||||
|
const handleQRCodeScanned = (data: string) => {
|
||||||
|
setScannedData(data);
|
||||||
|
// Alert.alert("QR Code Scanned", `Result: ${data}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanPress = () => {
|
||||||
|
setScanModalVisible(true);
|
||||||
|
};
|
||||||
|
const [selectedValue, setSelectedValue] = useState<
|
||||||
|
string | number | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ label: "Apple", value: "apple" },
|
||||||
|
{ label: "Banana", value: "banana" },
|
||||||
|
{ label: "Cherry", value: "cherry", disabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.titleText}>Cảm biến trên tàu</Text>
|
||||||
|
<Select
|
||||||
|
style={{ width: "80%", marginBottom: 20 }}
|
||||||
|
options={options}
|
||||||
|
value={selectedValue}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSelectedValue(val);
|
||||||
|
console.log("Value: ", val);
|
||||||
|
}}
|
||||||
|
placeholder="Select a fruit"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Pressable style={styles.scanButton} onPress={handleScanPress}>
|
||||||
|
<Text style={styles.scanButtonText}>📱 Scan QR Code</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{scannedData && (
|
||||||
|
<View style={styles.resultContainer}>
|
||||||
|
<Text style={styles.resultLabel}>Last Scanned:</Text>
|
||||||
|
<Text style={styles.resultText}>{scannedData}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<ScanQRCode
|
||||||
|
visible={scanModalVisible}
|
||||||
|
onClose={() => setScanModalVisible(false)}
|
||||||
|
onScanned={handleQRCodeScanned}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 15,
|
||||||
|
},
|
||||||
|
titleText: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "700",
|
||||||
|
lineHeight: 40,
|
||||||
|
marginBottom: 30,
|
||||||
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
scanButton: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
borderRadius: 10,
|
||||||
|
marginVertical: 15,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
scanButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
resultContainer: {
|
||||||
|
marginTop: 30,
|
||||||
|
padding: 15,
|
||||||
|
backgroundColor: "#f0f0f0",
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: "80%",
|
||||||
|
},
|
||||||
|
resultLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
resultText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#333",
|
||||||
|
fontFamily: "Menlo",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
});
|
||||||
153
app/(tabs)/setting.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ScrollView, StyleSheet, View } from "react-native";
|
||||||
|
|
||||||
|
import EnIcon from "@/assets/icons/en_icon.png";
|
||||||
|
import VnIcon from "@/assets/icons/vi_icon.png";
|
||||||
|
import RotateSwitch from "@/components/rotate-switch";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
import { DOMAIN, TOKEN } from "@/constants";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { removeStorageItem } from "@/utils/storage";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
type Todo = {
|
||||||
|
userId: number;
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
completed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<Todo | null>(null);
|
||||||
|
const { t, locale, setLocale } = useI18n();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [isEnabled, setIsEnabled] = useState(locale === "vi");
|
||||||
|
|
||||||
|
// Sync isEnabled state khi locale thay đổi
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEnabled(locale === "vi");
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
const toggleSwitch = async () => {
|
||||||
|
const newLocale = isEnabled ? "en" : "vi";
|
||||||
|
await setLocale(newLocale);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<ThemedText type="title" style={styles.title}>
|
||||||
|
{t("navigation.setting")}
|
||||||
|
</ThemedText>
|
||||||
|
|
||||||
|
{/* Theme Toggle Section */}
|
||||||
|
<ThemeToggle style={styles.themeSection} />
|
||||||
|
|
||||||
|
{/* Language Section */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.settingItem,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ThemedText type="default">{t("common.language")}</ThemedText>
|
||||||
|
<RotateSwitch
|
||||||
|
initialValue={isEnabled}
|
||||||
|
onChange={toggleSwitch}
|
||||||
|
size="sm"
|
||||||
|
offImage={EnIcon}
|
||||||
|
onImage={VnIcon}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<ThemedView
|
||||||
|
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||||
|
onTouchEnd={async () => {
|
||||||
|
await removeStorageItem(TOKEN);
|
||||||
|
await removeStorageItem(DOMAIN);
|
||||||
|
router.navigate("/auth/login");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemedText type="defaultSemiBold" style={styles.buttonText}>
|
||||||
|
{t("auth.logout")}
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<ThemedView
|
||||||
|
style={[styles.debugSection, { backgroundColor: colors.surface }]}
|
||||||
|
>
|
||||||
|
<ThemedText type="default">{data.title}</ThemedText>
|
||||||
|
<ThemedText type="default">{data.completed}</ThemedText>
|
||||||
|
<ThemedText type="default">{data.id}</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</ThemedView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: 5,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
padding: 20,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
themeSection: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
settingItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
marginTop: 20,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
debugSection: {
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
83
app/(tabs)/tripInfo.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import ButtonCancelTrip from "@/components/ButtonCancelTrip";
|
||||||
|
import ButtonCreateNewHaulOrTrip from "@/components/ButtonCreateNewHaulOrTrip";
|
||||||
|
import ButtonEndTrip from "@/components/ButtonEndTrip";
|
||||||
|
import CrewListTable from "@/components/tripInfo/CrewListTable";
|
||||||
|
import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
|
||||||
|
import NetListTable from "@/components/tripInfo/NetListTable";
|
||||||
|
import TripCostTable from "@/components/tripInfo/TripCostTable";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function TripInfoScreen() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.titleText, { color: colors.text }]}>
|
||||||
|
{t("trip.infoTrip")}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.buttonWrapper}>
|
||||||
|
<ButtonCreateNewHaulOrTrip />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<TripCostTable />
|
||||||
|
<FishingToolsTable />
|
||||||
|
<CrewListTable />
|
||||||
|
<NetListTable />
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<ButtonCancelTrip />
|
||||||
|
<ButtonEndTrip />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: 5,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
width: "100%",
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingTop: 15,
|
||||||
|
paddingBottom: 10,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
buttonWrapper: {
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 15,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
titleText: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "700",
|
||||||
|
lineHeight: 40,
|
||||||
|
paddingBottom: 10,
|
||||||
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
70
app/_layout.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
DarkTheme,
|
||||||
|
DefaultTheme,
|
||||||
|
ThemeProvider,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import "react-native-reanimated";
|
||||||
|
// import Toast from "react-native-toast-message";
|
||||||
|
|
||||||
|
// import { toastConfig } from "@/config";
|
||||||
|
import { toastConfig } from "@/config";
|
||||||
|
import { setRouterInstance } from "@/config/auth";
|
||||||
|
import "@/global.css";
|
||||||
|
import { I18nProvider } from "@/hooks/use-i18n";
|
||||||
|
import { ThemeProvider as AppThemeProvider, useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import Toast from "react-native-toast-message";
|
||||||
|
import "../global.css";
|
||||||
|
function AppContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { colorScheme } = useThemeContext();
|
||||||
|
console.log("Color Scheme: ", colorScheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRouterInstance(router);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||||
|
<Stack
|
||||||
|
screenOptions={{ headerShown: false }}
|
||||||
|
initialRouteName="auth/login"
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="auth/login"
|
||||||
|
options={{
|
||||||
|
title: "Login",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack.Screen
|
||||||
|
name="(tabs)"
|
||||||
|
options={{
|
||||||
|
title: "Home",
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack.Screen
|
||||||
|
name="modal"
|
||||||
|
options={{ presentation: "formSheet", title: "Modal" }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<AppThemeProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AppThemeProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
410
app/auth/login.tsx
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import EnIcon from "@/assets/icons/en_icon.png";
|
||||||
|
import VnIcon from "@/assets/icons/vi_icon.png";
|
||||||
|
import RotateSwitch from "@/components/rotate-switch";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
import SliceSwitch from "@/components/ui/slice-switch";
|
||||||
|
import { TOKEN } from "@/constants";
|
||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
import { queryLogin } from "@/controller/AuthController";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import {
|
||||||
|
ColorScheme as ThemeColorScheme,
|
||||||
|
useTheme,
|
||||||
|
useThemeContext,
|
||||||
|
} from "@/hooks/use-theme-context";
|
||||||
|
import { showErrorToast } from "@/services/toast_service";
|
||||||
|
import {
|
||||||
|
getStorageItem,
|
||||||
|
removeStorageItem,
|
||||||
|
setStorageItem,
|
||||||
|
} from "@/utils/storage";
|
||||||
|
import { parseJwtToken } from "@/utils/token";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const { t, setLocale, locale } = useI18n();
|
||||||
|
const { colors, colorScheme } = useTheme();
|
||||||
|
const { setThemeMode } = useThemeContext();
|
||||||
|
const styles = useMemo(
|
||||||
|
() => createStyles(colors, colorScheme),
|
||||||
|
[colors, colorScheme]
|
||||||
|
);
|
||||||
|
const placeholderColor = colors.textSecondary;
|
||||||
|
const buttonTextColor = colorScheme === "dark" ? colors.text : colors.surface;
|
||||||
|
const [isVNLang, setIsVNLang] = useState(false);
|
||||||
|
|
||||||
|
const checkLogin = useCallback(async () => {
|
||||||
|
const token = await getStorageItem(TOKEN);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseJwtToken(token);
|
||||||
|
|
||||||
|
const { exp } = parsed;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const oneHour = 60 * 60;
|
||||||
|
if (exp - now < oneHour) {
|
||||||
|
await removeStorageItem(TOKEN);
|
||||||
|
} else {
|
||||||
|
console.log("Token còn hạn");
|
||||||
|
|
||||||
|
router.replace("/(tabs)");
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVNLang(locale === "vi");
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkLogin();
|
||||||
|
}, [checkLogin]);
|
||||||
|
|
||||||
|
const handleLogin = async (creds?: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}) => {
|
||||||
|
const user = creds?.username ?? username;
|
||||||
|
const pass = creds?.password ?? password;
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!user?.trim() || !pass?.trim()) {
|
||||||
|
showErrorToast("Vui lòng nhập tài khoản và mật khẩu");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const body: Model.LoginRequestBody = {
|
||||||
|
guid: "9812739812738213",
|
||||||
|
email: user,
|
||||||
|
password: pass,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await queryLogin(body);
|
||||||
|
|
||||||
|
// Nếu thành công, lưu token và chuyển sang (tabs)
|
||||||
|
console.log("Login thành công với data:", response.data);
|
||||||
|
if (response?.data.token) {
|
||||||
|
// Lưu token vào storage nếu cần (thêm logic này sau)
|
||||||
|
console.log("Login Token ");
|
||||||
|
|
||||||
|
await setStorageItem(TOKEN, response.data.token);
|
||||||
|
// console.log("Token:", response.data.token);
|
||||||
|
router.replace("/(tabs)");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(
|
||||||
|
error instanceof Error ? error.message : "Đăng nhập thất bại"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchLanguage = (isVN: boolean) => {
|
||||||
|
if (isVN) {
|
||||||
|
setLocale("vi");
|
||||||
|
} else {
|
||||||
|
setLocale("en");
|
||||||
|
}
|
||||||
|
setIsVNLang(isVN);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1, backgroundColor: colors.background }}
|
||||||
|
contentContainerStyle={styles.scrollContainer}
|
||||||
|
>
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.headerContainer}>
|
||||||
|
{/* Logo */}
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/images/logo.png")}
|
||||||
|
style={styles.logo}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
<ThemedText type="title" style={styles.title}>
|
||||||
|
{t("common.app_name")}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<View style={styles.formContainer}>
|
||||||
|
{/* Username Input */}
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<ThemedText style={styles.label}>{t("auth.username")}</ThemedText>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder={t("auth.username_placeholder")}
|
||||||
|
placeholderTextColor={placeholderColor}
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
editable={!loading}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<ThemedText style={styles.label}>{t("auth.password")}</ThemedText>
|
||||||
|
<View className="relative">
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder={t("auth.password_placeholder")}
|
||||||
|
placeholderTextColor={placeholderColor}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
editable={!loading}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
{/* Position absolute with top:0 and bottom:0 and justifyContent:center
|
||||||
|
ensures the icon remains vertically centered inside the input */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={showPassword ? "eye-off" : "eye"}
|
||||||
|
size={22}
|
||||||
|
color={colors.icon}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Login Button (3/4) + QR Scan (1/4) */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.loginButton,
|
||||||
|
loading && styles.loginButtonDisabled,
|
||||||
|
{ flex: 5, marginTop: 0 },
|
||||||
|
]}
|
||||||
|
onPress={() => handleLogin()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={buttonTextColor} size="small" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[styles.loginButtonText, { color: buttonTextColor }]}
|
||||||
|
>
|
||||||
|
{t("auth.login")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<View style={styles.languageSwitcherContainer}>
|
||||||
|
<RotateSwitch
|
||||||
|
initialValue={isVNLang}
|
||||||
|
onChange={handleSwitchLanguage}
|
||||||
|
size="sm"
|
||||||
|
offImage={EnIcon}
|
||||||
|
onImage={VnIcon}
|
||||||
|
/>
|
||||||
|
<SliceSwitch
|
||||||
|
size="sm"
|
||||||
|
leftIcon="moon"
|
||||||
|
leftIconColor={
|
||||||
|
colorScheme === "dark" ? colors.background : colors.surface
|
||||||
|
}
|
||||||
|
rightIcon="sunny"
|
||||||
|
rightIconColor={
|
||||||
|
colorScheme === "dark" ? colors.warning : "orange"
|
||||||
|
}
|
||||||
|
activeBackgroundColor={colors.text}
|
||||||
|
inactiveBackgroundColor={colors.surface}
|
||||||
|
inactiveOverlayColor={colors.textSecondary}
|
||||||
|
activeOverlayColor={colors.background}
|
||||||
|
value={colorScheme === "light"}
|
||||||
|
onChange={(val) => {
|
||||||
|
setThemeMode(val ? "light" : "dark");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Copyright */}
|
||||||
|
<View style={styles.copyrightContainer}>
|
||||||
|
<ThemedText style={styles.copyrightText}>
|
||||||
|
© {new Date().getFullYear()} - {t("common.footer_text")}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ThemedView>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (colors: typeof Colors.light, scheme: ThemeColorScheme) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
scrollContainer: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
headerContainer: {
|
||||||
|
marginBottom: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
formContainer: {
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
loginButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
loginButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
loginButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
footerContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
copyrightContainer: {
|
||||||
|
marginTop: 20,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
copyrightText: {
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: 0.6,
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
languageSwitcherContainer: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 24,
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
languageSwitcherLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
languageButtonsContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
languageButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
languageButtonActive: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
languageButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
languageButtonTextActive: {
|
||||||
|
color: scheme === "dark" ? colors.text : colors.surface,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
5
app/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return <Redirect href="/auth/login" />;
|
||||||
|
}
|
||||||
29
app/modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Link } from 'expo-router';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
|
||||||
|
export default function ModalScreen() {
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ThemedText type="title">This is a modal</ThemedText>
|
||||||
|
<Link href="/" dismissTo style={styles.link}>
|
||||||
|
<ThemedText type="link">Go to home screen</ThemedText>
|
||||||
|
</Link>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
marginTop: 15,
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
14
assets.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
declare module "*.png" {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.jpg" {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.svg" {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
BIN
assets/icons/alarm_icon.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
assets/icons/en_icon.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/icons/exclamation.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
assets/icons/marker.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/icons/ship_alarm.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
assets/icons/ship_alarm_2.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/icons/ship_alarm_fishing.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
assets/icons/ship_online.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
assets/icons/ship_online_fishing.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
assets/icons/ship_undefine.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
assets/icons/ship_warning.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
assets/icons/ship_warning_fishing.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
assets/icons/sos_icon.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/icons/vi_icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/icons/warning_icon.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/owner.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
22
babel.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
presets: [['babel-preset-expo'], 'nativewind/babel'],
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
'module-resolver',
|
||||||
|
{
|
||||||
|
root: ['./'],
|
||||||
|
|
||||||
|
alias: {
|
||||||
|
'@': './',
|
||||||
|
'tailwind.config': './tailwind.config.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'react-native-worklets/plugin',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
76
components/AlarmList.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import { FlatList, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
type AlarmItem = {
|
||||||
|
name: string;
|
||||||
|
t: number;
|
||||||
|
level: number;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlarmProp = {
|
||||||
|
alarmsData: AlarmItem[];
|
||||||
|
onPress?: (alarm: AlarmItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
|
||||||
|
const sortedAlarmsData = [...alarmsData].sort((a, b) => b.level - a.level);
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={sortedAlarmsData}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onPress?.(item)}
|
||||||
|
className="flex flex-row gap-5 p-3 justify-start items-baseline w-full"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`flex-none h-3 w-3 rounded-full ${getBackgroundColorByLevel(
|
||||||
|
item.level
|
||||||
|
)}`}
|
||||||
|
></View>
|
||||||
|
<View className="flex">
|
||||||
|
<Text className={`grow text-lg ${getTextColorByLevel(item.level)}`}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="grow text-md text-gray-400">
|
||||||
|
{formatTimestamp(item.t)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBackgroundColorByLevel = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case 1:
|
||||||
|
return "bg-yellow-500";
|
||||||
|
case 2:
|
||||||
|
return "bg-orange-500";
|
||||||
|
case 3:
|
||||||
|
return "bg-red-500";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColorByLevel = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case 1:
|
||||||
|
return "text-yellow-600";
|
||||||
|
case 2:
|
||||||
|
return "text-orange-600";
|
||||||
|
case 3:
|
||||||
|
return "text-red-600";
|
||||||
|
default:
|
||||||
|
return "text-gray-600";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: number) => {
|
||||||
|
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlarmList;
|
||||||
48
components/ButtonCancelTrip.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
|
interface ButtonCancelTripProps {
|
||||||
|
title?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
|
||||||
|
title,
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const displayTitle = title || t("trip.buttonCancelTrip.title");
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.button}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.text}>{displayTitle}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#f45b57", // đỏ nhẹ giống ảnh
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 2, // cho Android
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ButtonCancelTrip;
|
||||||
213
components/ButtonCreateNewHaulOrTrip.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { queryGpsData } from "@/controller/DeviceController";
|
||||||
|
import {
|
||||||
|
queryStartNewHaul,
|
||||||
|
queryUpdateTripState,
|
||||||
|
} from "@/controller/TripController";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import {
|
||||||
|
showErrorToast,
|
||||||
|
showSuccessToast,
|
||||||
|
showWarningToast,
|
||||||
|
} from "@/services/toast_service";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import { AntDesign } from "@expo/vector-icons";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Alert, StyleSheet, View } from "react-native";
|
||||||
|
import IconButton from "./IconButton";
|
||||||
|
import CreateOrUpdateHaulModal from "./tripInfo/modal/CreateOrUpdateHaulModal";
|
||||||
|
|
||||||
|
interface StartButtonProps {
|
||||||
|
gpsData?: Model.GPSResponse;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface a {
|
||||||
|
fishingLogs?: Model.FishingLogInfo[] | null;
|
||||||
|
onCallback?: (fishingLogs: Model.FishingLogInfo[]) => void;
|
||||||
|
isEditing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
||||||
|
gpsData,
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
const [isStarted, setIsStarted] = useState(false);
|
||||||
|
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { trip, getTrip } = useTrip();
|
||||||
|
useEffect(() => {
|
||||||
|
getTrip();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkHaulFinished = () => {
|
||||||
|
return trip?.fishing_logs?.some((h) => h.status === 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (isStarted) {
|
||||||
|
Alert.alert(t("trip.endHaulTitle"), t("trip.endHaulConfirm"), [
|
||||||
|
{
|
||||||
|
text: t("trip.cancelButton"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("trip.endButton"),
|
||||||
|
onPress: () => {
|
||||||
|
setIsStarted(false);
|
||||||
|
Alert.alert(t("trip.successTitle"), t("trip.endHaulSuccess"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Alert.alert(t("trip.startHaulTitle"), t("trip.startHaulConfirm"), [
|
||||||
|
{
|
||||||
|
text: t("trip.cancelButton"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("trip.startButton"),
|
||||||
|
onPress: () => {
|
||||||
|
setIsStarted(true);
|
||||||
|
Alert.alert(t("trip.successTitle"), t("trip.startHaulSuccess"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onPress) {
|
||||||
|
onPress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartTrip = async (state: number, note?: string) => {
|
||||||
|
if (trip?.trip_status !== 2) {
|
||||||
|
showWarningToast(t("trip.alreadyStarted"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await queryUpdateTripState({
|
||||||
|
status: state,
|
||||||
|
note: note || "",
|
||||||
|
});
|
||||||
|
if (resp.status === 200) {
|
||||||
|
showSuccessToast(t("trip.startTripSuccess"));
|
||||||
|
await getTrip();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error stating trip :", error);
|
||||||
|
showErrorToast("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewHaul = async () => {
|
||||||
|
if (trip?.fishing_logs?.some((f) => f.status === 0)) {
|
||||||
|
showWarningToast(t("trip.finishCurrentHaul"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!gpsData) {
|
||||||
|
const response = await queryGpsData();
|
||||||
|
gpsData = response.data;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body: Model.NewFishingLogRequest = {
|
||||||
|
trip_id: trip?.id || "",
|
||||||
|
start_at: new Date(),
|
||||||
|
start_lat: gpsData.lat,
|
||||||
|
start_lon: gpsData.lon,
|
||||||
|
weather_description: t("trip.weatherDescription"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await queryStartNewHaul(body);
|
||||||
|
if (resp.status === 200) {
|
||||||
|
showSuccessToast(t("trip.startHaulSuccess"));
|
||||||
|
await getTrip();
|
||||||
|
} else {
|
||||||
|
showErrorToast(t("trip.createHaulFailed"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
// showErrorToast(t("trip.createHaulFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Không render gì nếu trip đã hoàn thành hoặc bị hủy
|
||||||
|
if (trip?.trip_status === 4 || trip?.trip_status === 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{trip?.trip_status === 2 ? (
|
||||||
|
<IconButton
|
||||||
|
icon={<AntDesign name="plus" />}
|
||||||
|
type="primary"
|
||||||
|
style={{ backgroundColor: "green", borderRadius: 10 }}
|
||||||
|
onPress={async () => handleStartTrip(3)}
|
||||||
|
>
|
||||||
|
{t("trip.startTrip")}
|
||||||
|
</IconButton>
|
||||||
|
) : checkHaulFinished() ? (
|
||||||
|
<IconButton
|
||||||
|
icon={<AntDesign name="plus" color={"white"} />}
|
||||||
|
type="primary"
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
|
onPress={() => setIsFinishHaulModalOpen(true)}
|
||||||
|
>
|
||||||
|
{t("trip.endHaul")}
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
icon={<AntDesign name="plus" color={"white"} />}
|
||||||
|
type="primary"
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
|
onPress={async () => {
|
||||||
|
createNewHaul();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("trip.startHaul")}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<CreateOrUpdateHaulModal
|
||||||
|
fishingLog={trip?.fishing_logs?.find((f) => f.status === 0)!}
|
||||||
|
fishingLogIndex={trip?.fishing_logs?.length!}
|
||||||
|
isVisible={isFinishHaulModalOpen}
|
||||||
|
onClose={function (): void {
|
||||||
|
setIsFinishHaulModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#4ecdc4", // màu ngọc lam
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 3,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 3, // hiệu ứng nổi trên Android
|
||||||
|
},
|
||||||
|
buttonActive: {
|
||||||
|
backgroundColor: "#e74c3c", // màu đỏ khi đang hoạt động
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ButtonCreateNewHaulOrTrip;
|
||||||
45
components/ButtonEndTrip.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
|
interface ButtonEndTripProps {
|
||||||
|
title?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({ title, onPress }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const displayTitle = title || t("trip.buttonEndTrip.title");
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.button}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text style={styles.text}>{displayTitle}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#ed9434", // màu cam sáng
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 28,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 3,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 2, // hiệu ứng nổi trên Android
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ButtonEndTrip;
|
||||||
159
components/IconButton.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
GestureResponderEvent,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
type ButtonType = "primary" | "default" | "dashed" | "text" | "link" | "danger";
|
||||||
|
type ButtonShape = "default" | "circle" | "round";
|
||||||
|
type ButtonSize = "small" | "middle" | "large";
|
||||||
|
|
||||||
|
export interface IconButtonProps {
|
||||||
|
type?: ButtonType;
|
||||||
|
shape?: ButtonShape;
|
||||||
|
size?: ButtonSize;
|
||||||
|
icon?: React.ReactNode; // render an icon component, e.g. <AntDesign name="plus" />
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPress?: (e?: GestureResponderEvent) => void;
|
||||||
|
children?: React.ReactNode; // label text
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
block?: boolean; // full width
|
||||||
|
activeOpacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IconButton
|
||||||
|
* A lightweight Button component inspired by Ant Design Button API, tuned for React Native.
|
||||||
|
* Accepts an `icon` prop as a React node for maximum flexibility.
|
||||||
|
*/
|
||||||
|
const IconButton: React.FC<IconButtonProps> = ({
|
||||||
|
type = "default",
|
||||||
|
shape = "default",
|
||||||
|
size = "middle",
|
||||||
|
icon,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
block = false,
|
||||||
|
activeOpacity = 0.8,
|
||||||
|
}) => {
|
||||||
|
const sizeMap = {
|
||||||
|
small: { height: 32, fontSize: 14, paddingHorizontal: 10 },
|
||||||
|
middle: { height: 40, fontSize: 16, paddingHorizontal: 14 },
|
||||||
|
large: { height: 48, fontSize: 18, paddingHorizontal: 18 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const colors: Record<
|
||||||
|
ButtonType,
|
||||||
|
{ backgroundColor?: string; textColor: string; borderColor?: string }
|
||||||
|
> = {
|
||||||
|
primary: { backgroundColor: "#4ecdc4", textColor: "#fff" },
|
||||||
|
default: {
|
||||||
|
backgroundColor: "#f2f2f2",
|
||||||
|
textColor: "#111",
|
||||||
|
borderColor: "#e6e6e6",
|
||||||
|
},
|
||||||
|
dashed: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
textColor: "#111",
|
||||||
|
borderColor: "#d9d9d9",
|
||||||
|
},
|
||||||
|
text: { backgroundColor: "transparent", textColor: "#111" },
|
||||||
|
link: { backgroundColor: "transparent", textColor: "#4ecdc4" },
|
||||||
|
danger: { backgroundColor: "#e74c3c", textColor: "#fff" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sz = sizeMap[size];
|
||||||
|
const color = colors[type];
|
||||||
|
|
||||||
|
const isCircle = shape === "circle";
|
||||||
|
const isRound = shape === "round";
|
||||||
|
|
||||||
|
const handlePress = (e: GestureResponderEvent) => {
|
||||||
|
if (disabled || loading) return;
|
||||||
|
onPress?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={activeOpacity}
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
{
|
||||||
|
height: sz.height,
|
||||||
|
paddingHorizontal: isCircle ? 0 : sz.paddingHorizontal,
|
||||||
|
backgroundColor: color.backgroundColor ?? "transparent",
|
||||||
|
borderColor: color.borderColor ?? "transparent",
|
||||||
|
borderWidth: type === "dashed" ? 1 : color.borderColor ? 1 : 0,
|
||||||
|
width: isCircle ? sz.height : block ? "100%" : undefined,
|
||||||
|
borderRadius: isCircle ? sz.height / 2 : isRound ? 999 : 8,
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
},
|
||||||
|
type === "dashed" ? { borderStyle: "dashed" } : null,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
size={"small"}
|
||||||
|
color={color.textColor}
|
||||||
|
style={styles.iconContainer}
|
||||||
|
/>
|
||||||
|
) : icon ? (
|
||||||
|
<View style={styles.iconContainer}>{icon}</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{children ? (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
styles.text,
|
||||||
|
{
|
||||||
|
color: color.textColor,
|
||||||
|
fontSize: sz.fontSize,
|
||||||
|
marginLeft: icon || loading ? 6 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 8,
|
||||||
|
borderColor: "transparent",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default IconButton;
|
||||||
239
components/ScanQRCode.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
interface ScanQRCodeProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onScanned: (data: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScanQRCode({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onScanned,
|
||||||
|
}: ScanQRCodeProps) {
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const [scanned, setScanned] = useState(false);
|
||||||
|
const cameraRef = useRef(null);
|
||||||
|
// Dùng ref để chặn quét nhiều lần trong cùng một frame/event loop
|
||||||
|
const hasScannedRef = useRef(false);
|
||||||
|
|
||||||
|
// Request camera permission when component mounts or when visible changes to true
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && !permission?.granted) {
|
||||||
|
requestPermission();
|
||||||
|
}
|
||||||
|
}, [visible, permission, requestPermission]);
|
||||||
|
|
||||||
|
// Reset scanned state when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setScanned(false);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Mỗi khi reset scanned state thì reset luôn ref guard
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scanned) {
|
||||||
|
hasScannedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [scanned]);
|
||||||
|
|
||||||
|
const handleBarCodeScanned = ({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
data: string;
|
||||||
|
}) => {
|
||||||
|
// Nếu đã scan rồi, bỏ qua
|
||||||
|
if (hasScannedRef.current || scanned) return;
|
||||||
|
hasScannedRef.current = true;
|
||||||
|
setScanned(true);
|
||||||
|
onScanned(data);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide">
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#0000ff" />
|
||||||
|
<Text style={styles.loadingText}>
|
||||||
|
Requesting camera permission...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permission.granted) {
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide">
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.permissionContainer}>
|
||||||
|
<Text style={styles.permissionTitle}>
|
||||||
|
Camera Permission Required
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.permissionText}>
|
||||||
|
This app needs camera access to scan QR codes. Please allow camera
|
||||||
|
access in your settings.
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.button}
|
||||||
|
onPress={() => requestPermission()}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Request Permission</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.button, styles.cancelButton]}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide">
|
||||||
|
<CameraView
|
||||||
|
ref={cameraRef}
|
||||||
|
style={styles.camera}
|
||||||
|
// Chỉ gắn handler khi chưa scan để ngắt lắng nghe ngay lập tức sau khi quét thành công
|
||||||
|
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||||
|
barcodeScannerSettings={{
|
||||||
|
barcodeTypes: ["qr"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.unfocusedContainer} />
|
||||||
|
<View style={styles.focusedRow}>
|
||||||
|
<View style={styles.focusedContainer} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.unfocusedContainer} />
|
||||||
|
|
||||||
|
<View style={styles.bottomContainer}>
|
||||||
|
<Text style={styles.scanningText}>
|
||||||
|
{/* Align QR code within the frame */}
|
||||||
|
Đặt mã QR vào khung hình
|
||||||
|
</Text>
|
||||||
|
<Pressable style={styles.closeButton} onPress={onClose}>
|
||||||
|
<Text style={styles.closeButtonText}>✕ Đóng</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CameraView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
permissionContainer: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
marginHorizontal: 20,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 15,
|
||||||
|
},
|
||||||
|
permissionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#333",
|
||||||
|
},
|
||||||
|
permissionText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#666",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#007AFF",
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
borderRadius: 8,
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: "#666",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
unfocusedContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
focusedRow: {
|
||||||
|
height: "80%",
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
focusedContainer: {
|
||||||
|
aspectRatio: 1,
|
||||||
|
width: "70%",
|
||||||
|
borderColor: "#00ff00",
|
||||||
|
borderWidth: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
bottomContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingBottom: 40,
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
scanningText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
closeButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
301
components/Select.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { AntDesign } from "@expo/vector-icons";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectProps {
|
||||||
|
value?: string | number;
|
||||||
|
defaultValue?: string | number;
|
||||||
|
options: SelectOption[];
|
||||||
|
onChange?: (value: string | number | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
allowClear?: boolean;
|
||||||
|
showSearch?: boolean;
|
||||||
|
mode?: "single" | "multiple"; // multiple not implemented yet
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
size?: "small" | "middle" | "large";
|
||||||
|
listStyle?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select
|
||||||
|
* A Select component inspired by Ant Design, adapted for React Native.
|
||||||
|
* Supports single selection, search, clear, loading, disabled states.
|
||||||
|
*/
|
||||||
|
const Select: React.FC<SelectProps> = ({
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Select an option",
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
allowClear = false,
|
||||||
|
showSearch = false,
|
||||||
|
mode = "single",
|
||||||
|
style,
|
||||||
|
listStyle,
|
||||||
|
size = "middle",
|
||||||
|
}) => {
|
||||||
|
const [selectedValue, setSelectedValue] = useState<
|
||||||
|
string | number | undefined
|
||||||
|
>(value ?? defaultValue);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [containerHeight, setContainerHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const filteredOptions = showSearch
|
||||||
|
? options.filter((opt) =>
|
||||||
|
opt.label.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
)
|
||||||
|
: options;
|
||||||
|
|
||||||
|
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
||||||
|
|
||||||
|
const handleSelect = (val: string | number) => {
|
||||||
|
setSelectedValue(val);
|
||||||
|
onChange?.(val);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setSelectedValue(undefined);
|
||||||
|
onChange?.(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
small: { height: 32, fontSize: 14, paddingHorizontal: 10 },
|
||||||
|
middle: { height: 40, fontSize: 16, paddingHorizontal: 14 },
|
||||||
|
large: { height: 48, fontSize: 18, paddingHorizontal: 18 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sz = sizeMap[size];
|
||||||
|
|
||||||
|
// Theme colors from context (consistent with other components)
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const selectBackgroundColor = disabled
|
||||||
|
? colors.backgroundSecondary
|
||||||
|
: colors.surface;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
height: sz.height,
|
||||||
|
paddingHorizontal: sz.paddingHorizontal,
|
||||||
|
backgroundColor: selectBackgroundColor,
|
||||||
|
borderColor: disabled ? colors.border : colors.primary,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
onPress={() => !disabled && !loading && setIsOpen(!isOpen)}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onLayout={(e) => setContainerHeight(e.nativeEvent.layout.height)}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.text,
|
||||||
|
{
|
||||||
|
fontSize: sz.fontSize,
|
||||||
|
color: disabled
|
||||||
|
? colors.textSecondary
|
||||||
|
: selectedValue
|
||||||
|
? colors.text
|
||||||
|
: colors.textSecondary,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{selectedOption?.label || placeholder}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.suffix}>
|
||||||
|
{allowClear && selectedValue && !loading ? (
|
||||||
|
<TouchableOpacity onPress={handleClear} style={styles.icon}>
|
||||||
|
<AntDesign name="close" size={16} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
<AntDesign
|
||||||
|
name={isOpen ? "up" : "down"}
|
||||||
|
size={14}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
style={styles.arrow}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.dropdown,
|
||||||
|
{
|
||||||
|
top: containerHeight,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{showSearch && (
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.searchInput,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderColor: colors.border,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder="Search..."
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
value={searchText}
|
||||||
|
onChangeText={setSearchText}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ScrollView style={[styles.list, listStyle]}>
|
||||||
|
{filteredOptions.map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.value}
|
||||||
|
style={[
|
||||||
|
styles.option,
|
||||||
|
{
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
item.disabled && styles.optionDisabled,
|
||||||
|
selectedValue === item.value && {
|
||||||
|
backgroundColor: colors.primary + "20", // Add transparency to primary color
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => !item.disabled && handleSelect(item.value)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.optionText,
|
||||||
|
{
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
item.disabled && {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
selectedValue === item.value && {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{selectedValue === item.value && (
|
||||||
|
<AntDesign name="check" size={16} color={colors.primary} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
// Color is set dynamically via theme
|
||||||
|
},
|
||||||
|
suffix: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
arrow: {
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderBottomLeftRadius: 8,
|
||||||
|
borderBottomRightRadius: 8,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 5,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: 8,
|
||||||
|
margin: 8,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
maxHeight: 200,
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
padding: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
optionDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
// optionSelected is handled dynamically via inline styles
|
||||||
|
optionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
// optionTextDisabled and optionTextSelected are handled dynamically via inline styles
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Select;
|
||||||
25
components/external-link.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Href, Link } from 'expo-router';
|
||||||
|
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||||
|
import { type ComponentProps } from 'react';
|
||||||
|
|
||||||
|
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||||
|
|
||||||
|
export function ExternalLink({ href, ...rest }: Props) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
{...rest}
|
||||||
|
href={href}
|
||||||
|
onPress={async (event) => {
|
||||||
|
if (process.env.EXPO_OS !== 'web') {
|
||||||
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
event.preventDefault();
|
||||||
|
// Open the link in an in-app browser.
|
||||||
|
await openBrowserAsync(href, {
|
||||||
|
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
components/haptic-tab.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||||
|
import { PlatformPressable } from '@react-navigation/elements';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||||
|
return (
|
||||||
|
<PlatformPressable
|
||||||
|
{...props}
|
||||||
|
onPressIn={(ev) => {
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
// Add a soft haptic feedback when pressing down on the tabs.
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
props.onPressIn?.(ev);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/hello-wave.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Animated from 'react-native-reanimated';
|
||||||
|
|
||||||
|
export function HelloWave() {
|
||||||
|
return (
|
||||||
|
<Animated.Text
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 32,
|
||||||
|
marginTop: -6,
|
||||||
|
animationName: {
|
||||||
|
'50%': { transform: [{ rotate: '25deg' }] },
|
||||||
|
},
|
||||||
|
animationIterationCount: 4,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
}}>
|
||||||
|
👋
|
||||||
|
</Animated.Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
components/map/Description.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
interface DescriptionProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
export const Description = ({
|
||||||
|
title = "",
|
||||||
|
description = "",
|
||||||
|
}: DescriptionProps) => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View className="flex-row gap-2 ">
|
||||||
|
<ThemedText
|
||||||
|
style={{ color: colors.textSecondary, fontSize: 16 }}
|
||||||
|
>
|
||||||
|
{title}:
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={{ fontSize: 16 }}>{description}</ThemedText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
126
components/map/GPSInfoPanel.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { convertToDMS, kmhToKnot } from "@/utils/geom";
|
||||||
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Animated, TouchableOpacity, View } from "react-native";
|
||||||
|
import ButtonCreateNewHaulOrTrip from "../ButtonCreateNewHaulOrTrip";
|
||||||
|
import { Description } from "./Description";
|
||||||
|
type GPSInfoPanelProps = {
|
||||||
|
gpsData: Model.GPSResponse | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GPSInfoPanel = ({ gpsData }: GPSInfoPanelProps) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [panelHeight, setPanelHeight] = useState(0);
|
||||||
|
const translateY = useRef(new Animated.Value(0)).current;
|
||||||
|
const blockBottom = useRef(new Animated.Value(0)).current;
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors, styles } = useAppTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(translateY, {
|
||||||
|
toValue: isExpanded ? 0 : 200, // Dịch chuyển xuống 200px khi thu gọn
|
||||||
|
duration: 500,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const targetBottom = isExpanded ? panelHeight + 12 : 10;
|
||||||
|
Animated.timing(blockBottom, {
|
||||||
|
toValue: targetBottom,
|
||||||
|
duration: 500,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}, [isExpanded, panelHeight, blockBottom]);
|
||||||
|
|
||||||
|
const togglePanel = () => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Khối hình vuông */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: blockBottom,
|
||||||
|
left: 5,
|
||||||
|
borderRadius: 4,
|
||||||
|
zIndex: 30,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ButtonCreateNewHaulOrTrip gpsData={gpsData} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
transform: [{ translateY }],
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className="absolute bottom-0 gap-5 right-0 px-4 pt-12 pb-2 left-0 h-auto w-full rounded-t-xl shadow-md"
|
||||||
|
onLayout={(event) => setPanelHeight(event.nativeEvent.layout.height)}
|
||||||
|
>
|
||||||
|
{/* Nút toggle ở top-right */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={togglePanel}
|
||||||
|
className="absolute top-2 right-2 z-10 rounded-full p-1"
|
||||||
|
style={{ backgroundColor: colors.card }}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={isExpanded ? "close" : "close"}
|
||||||
|
size={20}
|
||||||
|
color={colors.icon}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Description
|
||||||
|
title={t("home.latitude")}
|
||||||
|
description={convertToDMS(gpsData?.lat ?? 0, true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Description
|
||||||
|
title={t("home.longitude")}
|
||||||
|
description={convertToDMS(gpsData?.lon ?? 0, false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Description
|
||||||
|
title={t("home.speed")}
|
||||||
|
description={`${kmhToKnot(gpsData?.s ?? 0).toString()} knot`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Description
|
||||||
|
title={t("home.heading")}
|
||||||
|
description={`${gpsData?.h ?? 0}°`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Nút floating để mở lại panel khi thu gọn */}
|
||||||
|
{!isExpanded && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={togglePanel}
|
||||||
|
className="absolute bottom-5 right-2 z-20 rounded-full p-2 shadow-lg"
|
||||||
|
style={{ backgroundColor: colors.card }}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="info-outline" size={24} color={colors.icon} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GPSInfoPanel;
|
||||||
148
components/map/PolygonWithLabel.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { ANDROID_PLATFORM } from "@/constants";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
import { getPolygonCenter } from "@/utils/polyline";
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
import { MapMarker, Marker, Polygon } from "react-native-maps";
|
||||||
|
|
||||||
|
export interface PolygonWithLabelProps {
|
||||||
|
coordinates: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}[];
|
||||||
|
label?: string;
|
||||||
|
content?: string;
|
||||||
|
fillColor?: string;
|
||||||
|
strokeColor?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
zIndex?: number;
|
||||||
|
zoomLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component render Polygon kèm Label/Text ở giữa
|
||||||
|
*/
|
||||||
|
export const PolygonWithLabel: React.FC<PolygonWithLabelProps> = ({
|
||||||
|
coordinates,
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
fillColor = "rgba(16, 85, 201, 0.6)",
|
||||||
|
strokeColor = "rgba(16, 85, 201, 0.8)",
|
||||||
|
strokeWidth = 2,
|
||||||
|
zIndex = 50,
|
||||||
|
zoomLevel = 10,
|
||||||
|
}) => {
|
||||||
|
if (!coordinates || coordinates.length < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const platform = usePlatform();
|
||||||
|
const markerRef = useRef<MapMarker>(null);
|
||||||
|
|
||||||
|
const centerPoint = getPolygonCenter(coordinates);
|
||||||
|
|
||||||
|
// Tính font size dựa trên zoom level
|
||||||
|
// Zoom càng thấp (xa ra) thì font size càng nhỏ
|
||||||
|
const calculateFontSize = (baseSize: number) => {
|
||||||
|
const baseZoom = 10;
|
||||||
|
// Giảm scale factor để text không quá to khi zoom out
|
||||||
|
const scaleFactor = Math.pow(2, (zoomLevel - baseZoom) * 0.3);
|
||||||
|
return Math.max(baseSize * scaleFactor, 5); // Tối thiểu 5px
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelFontSize = calculateFontSize(12);
|
||||||
|
const contentFontSize = calculateFontSize(10);
|
||||||
|
// console.log("zoom level: ", zoomLevel);
|
||||||
|
|
||||||
|
const paddingScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.2), 0.5);
|
||||||
|
const minWidthScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.25), 0.9);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Polygon
|
||||||
|
coordinates={coordinates}
|
||||||
|
fillColor={fillColor}
|
||||||
|
strokeColor={strokeColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
zIndex={zIndex}
|
||||||
|
/>
|
||||||
|
{label && (
|
||||||
|
<Marker
|
||||||
|
ref={markerRef}
|
||||||
|
coordinate={centerPoint}
|
||||||
|
zIndex={50}
|
||||||
|
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
|
||||||
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
title={platform === ANDROID_PLATFORM ? label : undefined}
|
||||||
|
description={platform === ANDROID_PLATFORM ? content : undefined}
|
||||||
|
>
|
||||||
|
<View style={styles.markerContainer}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
paddingHorizontal: 5 * paddingScale,
|
||||||
|
paddingVertical: 5 * paddingScale,
|
||||||
|
minWidth: 80,
|
||||||
|
maxWidth: 150 * minWidthScale,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.labelText, { fontSize: labelFontSize }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{content && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.contentText,
|
||||||
|
{ fontSize: contentFontSize, marginTop: 2 * paddingScale },
|
||||||
|
]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
markerContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
labelContainer: {
|
||||||
|
backgroundColor: "rgba(16, 85, 201, 0.95)",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "#fff",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 5,
|
||||||
|
elevation: 8,
|
||||||
|
minWidth: 80,
|
||||||
|
maxWidth: 250,
|
||||||
|
},
|
||||||
|
labelText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "bold",
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
contentText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "600",
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.95,
|
||||||
|
},
|
||||||
|
});
|
||||||
112
components/map/PolylineWithLabel.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { ANDROID_PLATFORM } from "@/constants";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
import {
|
||||||
|
calculateTotalDistance,
|
||||||
|
getMiddlePointOfPolyline,
|
||||||
|
} from "@/utils/polyline";
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
import { MapMarker, Marker, Polyline } from "react-native-maps";
|
||||||
|
|
||||||
|
export interface PolylineWithLabelProps {
|
||||||
|
coordinates: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}[];
|
||||||
|
label?: string;
|
||||||
|
content?: string;
|
||||||
|
strokeColor?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
showDistance?: boolean;
|
||||||
|
zIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component render Polyline kèm Label/Text ở giữa
|
||||||
|
*/
|
||||||
|
export const PolylineWithLabel: React.FC<PolylineWithLabelProps> = ({
|
||||||
|
coordinates,
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
strokeColor = "#FF5733",
|
||||||
|
strokeWidth = 4,
|
||||||
|
showDistance = false,
|
||||||
|
zIndex = 50,
|
||||||
|
}) => {
|
||||||
|
if (!coordinates || coordinates.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const middlePoint = getMiddlePointOfPolyline(coordinates);
|
||||||
|
const distance = calculateTotalDistance(coordinates);
|
||||||
|
const platform = usePlatform();
|
||||||
|
const markerRef = useRef<MapMarker>(null);
|
||||||
|
let displayText = label || "";
|
||||||
|
if (showDistance) {
|
||||||
|
displayText += displayText
|
||||||
|
? ` (${distance.toFixed(2)}km)`
|
||||||
|
: `${distance.toFixed(2)}km`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Polyline
|
||||||
|
coordinates={coordinates}
|
||||||
|
strokeColor={strokeColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
zIndex={zIndex}
|
||||||
|
/>
|
||||||
|
{displayText && (
|
||||||
|
<Marker
|
||||||
|
ref={markerRef}
|
||||||
|
coordinate={middlePoint}
|
||||||
|
zIndex={zIndex + 10}
|
||||||
|
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
|
||||||
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
title={platform === ANDROID_PLATFORM ? label : undefined}
|
||||||
|
description={platform === ANDROID_PLATFORM ? content : undefined}
|
||||||
|
>
|
||||||
|
<View style={styles.markerContainer}>
|
||||||
|
<View style={styles.labelContainer}>
|
||||||
|
<Text
|
||||||
|
style={styles.labelText}
|
||||||
|
numberOfLines={2}
|
||||||
|
adjustsFontSizeToFit
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
markerContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
labelContainer: {
|
||||||
|
backgroundColor: "rgba(255, 87, 51, 0.95)",
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#fff",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 5,
|
||||||
|
elevation: 8,
|
||||||
|
minWidth: 80,
|
||||||
|
maxWidth: 180,
|
||||||
|
},
|
||||||
|
labelText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "bold",
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
249
components/map/SosButton.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import {
|
||||||
|
queryDeleteSos,
|
||||||
|
queryGetSos,
|
||||||
|
querySendSosMessage,
|
||||||
|
} from "@/controller/DeviceController";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { showErrorToast } from "@/services/toast_service";
|
||||||
|
import { sosMessage } from "@/utils/sosUtils";
|
||||||
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
|
import IconButton from "../IconButton";
|
||||||
|
import Select from "../Select";
|
||||||
|
import Modal from "../ui/modal";
|
||||||
|
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||||
|
|
||||||
|
const SosButton = () => {
|
||||||
|
const [sosData, setSosData] = useState<Model.SosResponse | null>();
|
||||||
|
const [showConfirmSosDialog, setShowConfirmSosDialog] = useState(false);
|
||||||
|
const [selectedSosMessage, setSelectedSosMessage] = useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [customMessage, setCustomMessage] = useState("");
|
||||||
|
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// Theme colors
|
||||||
|
const textColor = useThemeColor({}, 'text');
|
||||||
|
const borderColor = useThemeColor({}, 'border');
|
||||||
|
const errorColor = useThemeColor({}, 'error');
|
||||||
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
|
|
||||||
|
// Dynamic styles
|
||||||
|
const styles = SosButtonStyles(textColor, borderColor, errorColor, backgroundColor);
|
||||||
|
|
||||||
|
const sosOptions = [
|
||||||
|
...sosMessage.map((msg) => ({
|
||||||
|
ma: msg.ma,
|
||||||
|
moTa: msg.moTa,
|
||||||
|
label: msg.moTa,
|
||||||
|
value: msg.ma,
|
||||||
|
})),
|
||||||
|
{ ma: 999, moTa: "Khác", label: "Khác", value: 999 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSosData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await queryGetSos();
|
||||||
|
// console.log("SoS ResponseL: ", response);
|
||||||
|
|
||||||
|
setSosData(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch SOS data:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSosData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors: { [key: string]: string } = {};
|
||||||
|
|
||||||
|
if (selectedSosMessage === 999 && customMessage.trim() === "") {
|
||||||
|
newErrors.customMessage = t("home.sos.statusRequired");
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSos = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
console.log("Form chưa validate");
|
||||||
|
return; // Không đóng modal nếu validate fail
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageToSend = "";
|
||||||
|
if (selectedSosMessage === 999) {
|
||||||
|
messageToSend = customMessage.trim();
|
||||||
|
} else {
|
||||||
|
const selectedOption = sosOptions.find(
|
||||||
|
(opt) => opt.ma === selectedSosMessage
|
||||||
|
);
|
||||||
|
messageToSend = selectedOption ? selectedOption.moTa : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gửi dữ liệu đi
|
||||||
|
await sendSosMessage(messageToSend);
|
||||||
|
|
||||||
|
// Đóng modal và reset form sau khi gửi thành công
|
||||||
|
setShowConfirmSosDialog(false);
|
||||||
|
setSelectedSosMessage(null);
|
||||||
|
setCustomMessage("");
|
||||||
|
setErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickButton = async (isActive: boolean) => {
|
||||||
|
console.log("Is Active: ", isActive);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
const resp = await queryDeleteSos();
|
||||||
|
if (resp.status === 200) {
|
||||||
|
await getSosData();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedSosMessage(11); // Mặc định chọn lý do ma: 11
|
||||||
|
setShowConfirmSosDialog(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendSosMessage = async (message: string) => {
|
||||||
|
try {
|
||||||
|
const resp = await querySendSosMessage(message);
|
||||||
|
if (resp.status === 200) {
|
||||||
|
await getSosData();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error when send sos: ", error);
|
||||||
|
showErrorToast(t("home.sos.sendError"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
icon={<MaterialIcons name="warning" size={20} color="white" />}
|
||||||
|
type="danger"
|
||||||
|
size="middle"
|
||||||
|
onPress={() => handleClickButton(sosData?.active || false)}
|
||||||
|
style={{ borderRadius: 20 }}
|
||||||
|
>
|
||||||
|
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
|
||||||
|
</IconButton>
|
||||||
|
<Modal
|
||||||
|
open={showConfirmSosDialog}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowConfirmSosDialog(false);
|
||||||
|
setSelectedSosMessage(null);
|
||||||
|
setCustomMessage("");
|
||||||
|
setErrors({});
|
||||||
|
}}
|
||||||
|
okText={t("home.sos.confirm")}
|
||||||
|
cancelText={t("home.sos.cancel")}
|
||||||
|
title={t("home.sos.title")}
|
||||||
|
centered
|
||||||
|
onOk={handleConfirmSos}
|
||||||
|
>
|
||||||
|
{/* Select Nội dung SOS */}
|
||||||
|
<View style={styles.formGroup}>
|
||||||
|
<Text style={styles.label}>{t("home.sos.content")}</Text>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={selectedSosMessage ?? undefined}
|
||||||
|
options={sosOptions}
|
||||||
|
placeholder={t("home.sos.selectReason")}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedSosMessage(value as number);
|
||||||
|
// Clear custom message nếu chọn khác lý do
|
||||||
|
if (value !== 999) {
|
||||||
|
setCustomMessage("");
|
||||||
|
}
|
||||||
|
// Clear error if exists
|
||||||
|
if (errors.sosMessage) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors.sosMessage;
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
showSearch={false}
|
||||||
|
style={[errors.sosMessage ? styles.errorBorder : undefined]}
|
||||||
|
/>
|
||||||
|
{errors.sosMessage && (
|
||||||
|
<Text style={styles.errorText}>{errors.sosMessage}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Input Custom Message nếu chọn "Khác" */}
|
||||||
|
{selectedSosMessage === 999 && (
|
||||||
|
<View style={styles.formGroup}>
|
||||||
|
<Text style={styles.label}>{t("home.sos.statusInput")}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
errors.customMessage ? styles.errorInput : {},
|
||||||
|
]}
|
||||||
|
placeholder={t("home.sos.enterStatus")}
|
||||||
|
placeholderTextColor={textColor + '99'} // Add transparency
|
||||||
|
value={customMessage}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setCustomMessage(text);
|
||||||
|
if (text.trim() !== "") {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors.customMessage;
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
/>
|
||||||
|
{errors.customMessage && (
|
||||||
|
<Text style={styles.errorText}>{errors.customMessage}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SosButtonStyles = (textColor: string, borderColor: string, errorColor: string, backgroundColor: string) => StyleSheet.create({
|
||||||
|
formGroup: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: textColor,
|
||||||
|
},
|
||||||
|
errorBorder: {
|
||||||
|
borderColor: errorColor,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: borderColor,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
color: textColor,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
},
|
||||||
|
errorInput: {
|
||||||
|
borderColor: errorColor,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: errorColor,
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SosButton;
|
||||||
79
components/parallax-scroll-view.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { PropsWithChildren, ReactElement } from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
useAnimatedRef,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useScrollOffset,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
import { useColorScheme } from '@/hooks/use-theme-context';
|
||||||
|
|
||||||
|
const HEADER_HEIGHT = 250;
|
||||||
|
|
||||||
|
type Props = PropsWithChildren<{
|
||||||
|
headerImage: ReactElement;
|
||||||
|
headerBackgroundColor: { dark: string; light: string };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export default function ParallaxScrollView({
|
||||||
|
children,
|
||||||
|
headerImage,
|
||||||
|
headerBackgroundColor,
|
||||||
|
}: Props) {
|
||||||
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||||
|
const scrollOffset = useScrollOffset(scrollRef);
|
||||||
|
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: interpolate(
|
||||||
|
scrollOffset.value,
|
||||||
|
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||||
|
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{ backgroundColor, flex: 1 }}
|
||||||
|
scrollEventThrottle={16}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.header,
|
||||||
|
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||||
|
headerAnimatedStyle,
|
||||||
|
]}>
|
||||||
|
{headerImage}
|
||||||
|
</Animated.View>
|
||||||
|
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||||
|
</Animated.ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
height: HEADER_HEIGHT,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 32,
|
||||||
|
gap: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
307
components/rotate-switch.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Image,
|
||||||
|
ImageSourcePropType,
|
||||||
|
Pressable,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
||||||
|
|
||||||
|
const SIZE_PRESETS = {
|
||||||
|
sm: { width: 64, height: 32 },
|
||||||
|
md: { width: 80, height: 40 },
|
||||||
|
lg: { width: 96, height: 48 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SwitchSize = keyof typeof SIZE_PRESETS;
|
||||||
|
|
||||||
|
const DEFAULT_TOGGLE_DURATION = 400;
|
||||||
|
const DEFAULT_OFF_IMAGE =
|
||||||
|
"https://images.vexels.com/media/users/3/163966/isolated/preview/6ecbb5ec8c121c0699c9b9179d6b24aa-england-flag-language-icon-circle.png";
|
||||||
|
const DEFAULT_ON_IMAGE =
|
||||||
|
"https://cdn-icons-png.flaticon.com/512/197/197473.png";
|
||||||
|
const DEFAULT_INACTIVE_BG = "#D3DAD9";
|
||||||
|
const DEFAULT_ACTIVE_BG = "#C2E2FA";
|
||||||
|
const PRESSED_SCALE = 0.96;
|
||||||
|
const PRESS_FEEDBACK_DURATION = 120;
|
||||||
|
|
||||||
|
type RotateSwitchProps = {
|
||||||
|
size?: SwitchSize;
|
||||||
|
onImage?: ImageSourcePropType | string;
|
||||||
|
offImage?: ImageSourcePropType | string;
|
||||||
|
initialValue?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
activeBackgroundColor?: string;
|
||||||
|
inactiveBackgroundColor?: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
onChange?: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toImageSource = (
|
||||||
|
input: ImageSourcePropType | string | undefined,
|
||||||
|
fallbackUri: string
|
||||||
|
): ImageSourcePropType => {
|
||||||
|
if (typeof input === "string") {
|
||||||
|
return { uri: input };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uri: fallbackUri };
|
||||||
|
};
|
||||||
|
|
||||||
|
const RotateSwitch = ({
|
||||||
|
size = "md",
|
||||||
|
onImage,
|
||||||
|
offImage,
|
||||||
|
duration,
|
||||||
|
activeBackgroundColor = DEFAULT_ACTIVE_BG,
|
||||||
|
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
|
||||||
|
initialValue = false,
|
||||||
|
style,
|
||||||
|
onChange,
|
||||||
|
}: RotateSwitchProps) => {
|
||||||
|
const { width: containerWidth, height: containerHeight } =
|
||||||
|
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
|
||||||
|
const knobSize = containerHeight;
|
||||||
|
const knobTravel = containerWidth - knobSize;
|
||||||
|
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
|
||||||
|
|
||||||
|
const resolvedOffImage = useMemo(
|
||||||
|
() => toImageSource(offImage, DEFAULT_OFF_IMAGE),
|
||||||
|
[offImage]
|
||||||
|
);
|
||||||
|
const resolvedOnImage = useMemo(
|
||||||
|
() => toImageSource(onImage, DEFAULT_ON_IMAGE),
|
||||||
|
[onImage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isOn, setIsOn] = useState(initialValue);
|
||||||
|
const [bgOn, setBgOn] = useState(initialValue);
|
||||||
|
const [displaySource, setDisplaySource] = useState<ImageSourcePropType>(
|
||||||
|
initialValue ? resolvedOnImage : resolvedOffImage
|
||||||
|
);
|
||||||
|
|
||||||
|
const progress = useRef(new Animated.Value(initialValue ? 1 : 0)).current;
|
||||||
|
const pressScale = useRef(new Animated.Value(1)).current;
|
||||||
|
const listenerIdRef = useRef<string | number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplaySource(bgOn ? resolvedOnImage : resolvedOffImage);
|
||||||
|
}, [bgOn, resolvedOffImage, resolvedOnImage]);
|
||||||
|
|
||||||
|
const removeProgressListener = () => {
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachHalfwaySwapListener = (next: boolean) => {
|
||||||
|
removeProgressListener();
|
||||||
|
let swapped = false;
|
||||||
|
listenerIdRef.current = progress.addListener(({ value }) => {
|
||||||
|
if (swapped) return;
|
||||||
|
const crossedHalfway = next ? value >= 0.5 : value <= 0.5;
|
||||||
|
if (!crossedHalfway) return;
|
||||||
|
swapped = true;
|
||||||
|
setBgOn(next);
|
||||||
|
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
|
||||||
|
removeProgressListener();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up listener on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
removeProgressListener();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep internal state in sync when `initialValue` prop changes.
|
||||||
|
// Users may pass a changing `initialValue` (like from parent state) and
|
||||||
|
// expect the switch to reflect that. Animate `progress` toward the
|
||||||
|
// corresponding value and update images/background when done.
|
||||||
|
useEffect(() => {
|
||||||
|
// If no change, do nothing
|
||||||
|
if (initialValue === isOn) return;
|
||||||
|
|
||||||
|
const next = initialValue;
|
||||||
|
const targetValue = next ? 1 : 0;
|
||||||
|
|
||||||
|
progress.stopAnimation();
|
||||||
|
removeProgressListener();
|
||||||
|
|
||||||
|
if (animationDuration <= 0) {
|
||||||
|
progress.setValue(targetValue);
|
||||||
|
setIsOn(next);
|
||||||
|
setBgOn(next);
|
||||||
|
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update isOn immediately so accessibilityState etc. reflect change.
|
||||||
|
setIsOn(next);
|
||||||
|
|
||||||
|
attachHalfwaySwapListener(next);
|
||||||
|
|
||||||
|
Animated.timing(progress, {
|
||||||
|
toValue: targetValue,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
// Ensure final state reflects the target in case animation skips halfway listener.
|
||||||
|
setBgOn(next);
|
||||||
|
setDisplaySource(next ? resolvedOnImage : resolvedOffImage);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
initialValue,
|
||||||
|
isOn,
|
||||||
|
animationDuration,
|
||||||
|
progress,
|
||||||
|
resolvedOffImage,
|
||||||
|
resolvedOnImage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const knobTranslateX = progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, knobTravel],
|
||||||
|
});
|
||||||
|
|
||||||
|
const knobRotation = progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ["0deg", "180deg"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatePress = (toValue: number) => {
|
||||||
|
Animated.timing(pressScale, {
|
||||||
|
toValue,
|
||||||
|
duration: PRESS_FEEDBACK_DURATION,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
animatePress(PRESSED_SCALE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressOut = () => {
|
||||||
|
animatePress(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const next = !isOn;
|
||||||
|
const targetValue = next ? 1 : 0;
|
||||||
|
|
||||||
|
progress.stopAnimation();
|
||||||
|
removeProgressListener();
|
||||||
|
|
||||||
|
if (animationDuration <= 0) {
|
||||||
|
progress.setValue(targetValue);
|
||||||
|
setIsOn(next);
|
||||||
|
setBgOn(next);
|
||||||
|
onChange?.(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOn(next);
|
||||||
|
|
||||||
|
attachHalfwaySwapListener(next);
|
||||||
|
|
||||||
|
Animated.timing(progress, {
|
||||||
|
toValue: targetValue,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setBgOn(next);
|
||||||
|
onChange?.(next);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggle}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
|
accessibilityRole="switch"
|
||||||
|
accessibilityState={{ checked: isOn }}
|
||||||
|
style={[styles.pressable, style]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.shadowWrapper,
|
||||||
|
{
|
||||||
|
transform: [{ scale: pressScale }],
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
backgroundColor: bgOn
|
||||||
|
? activeBackgroundColor
|
||||||
|
: inactiveBackgroundColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<AnimatedImage
|
||||||
|
source={displaySource}
|
||||||
|
style={[
|
||||||
|
styles.knob,
|
||||||
|
{
|
||||||
|
width: knobSize,
|
||||||
|
height: knobSize,
|
||||||
|
borderRadius: knobSize / 2,
|
||||||
|
transform: [
|
||||||
|
{ translateX: knobTranslateX },
|
||||||
|
{ rotate: knobRotation },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pressable: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
shadowWrapper: {
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 6,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
knob: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default RotateSwitch;
|
||||||
154
components/theme-example.tsx
Normal file
@@ -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
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
63
components/themed-text.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { StyleSheet, Text, type TextProps } from "react-native";
|
||||||
|
|
||||||
|
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||||
|
|
||||||
|
export type ThemedTextProps = TextProps & {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link";
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedText({
|
||||||
|
style,
|
||||||
|
className = "",
|
||||||
|
lightColor,
|
||||||
|
darkColor,
|
||||||
|
type = "default",
|
||||||
|
...rest
|
||||||
|
}: ThemedTextProps) {
|
||||||
|
const color = useThemeColor({ light: lightColor, dark: darkColor }, "text");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
className={className}
|
||||||
|
style={[
|
||||||
|
{ color },
|
||||||
|
type === "default" ? styles.default : undefined,
|
||||||
|
type === "title" ? styles.title : undefined,
|
||||||
|
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
|
||||||
|
type === "subtitle" ? styles.subtitle : undefined,
|
||||||
|
type === "link" ? styles.link : undefined,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
default: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
defaultSemiBold: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
lineHeight: 32,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
lineHeight: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#0a7ea4",
|
||||||
|
},
|
||||||
|
});
|
||||||
30
components/themed-view.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||||
|
|
||||||
|
export type ThemedViewProps = ViewProps & {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedView({
|
||||||
|
style,
|
||||||
|
className = "",
|
||||||
|
lightColor,
|
||||||
|
darkColor,
|
||||||
|
...otherProps
|
||||||
|
}: ThemedViewProps) {
|
||||||
|
const backgroundColor = useThemeColor(
|
||||||
|
{ light: lightColor, dark: darkColor },
|
||||||
|
"background"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={className}
|
||||||
|
style={[{ backgroundColor }, style]}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
components/tripInfo/CrewListTable.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import CrewDetailModal from "./modal/CrewDetailModal";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { createTableStyles } from "./ThemedTable";
|
||||||
|
|
||||||
|
const CrewListTable: React.FC = () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [selectedCrew, setSelectedCrew] = useState<Model.TripCrews | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
|
const { trip } = useTrip();
|
||||||
|
|
||||||
|
const data: Model.TripCrews[] = trip?.crews ?? [];
|
||||||
|
|
||||||
|
const tongThanhVien = data.length;
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const toValue = collapsed ? contentHeight : 0;
|
||||||
|
Animated.timing(animatedHeight, {
|
||||||
|
toValue,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
setCollapsed((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCrewPress = (crewId: string) => {
|
||||||
|
const crew = data.find((item) => item.Person.personal_id === crewId);
|
||||||
|
if (crew) {
|
||||||
|
setSelectedCrew(crew);
|
||||||
|
setModalVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setSelectedCrew(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header toggle */}
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={handleToggle}
|
||||||
|
style={styles.headerRow}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>{t("trip.crewList.title")}</Text>
|
||||||
|
{collapsed && (
|
||||||
|
<Text style={styles.totalCollapsed}>{tongThanhVien}</Text>
|
||||||
|
)}
|
||||||
|
<IconSymbol
|
||||||
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
|
size={16}
|
||||||
|
color={colors.icon}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Nội dung ẩn để đo chiều cao */}
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
|
||||||
|
onLayout={(event) => {
|
||||||
|
const height = event.nativeEvent.layout.height;
|
||||||
|
if (height > 0 && contentHeight === 0) {
|
||||||
|
setContentHeight(height);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
|
<View style={styles.cellWrapper}>
|
||||||
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.crewList.nameHeader")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||||
|
{t("trip.crewList.roleHeader")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{data.map((item) => (
|
||||||
|
<View key={item.Person.personal_id} style={styles.row}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cellWrapper}
|
||||||
|
onPress={() => handleCrewPress(item.Person.personal_id)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.cell, styles.linkText]}>
|
||||||
|
{item.Person.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.cell, styles.right]}>{item.role}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={[styles.row]}>
|
||||||
|
<Text style={[styles.cell, styles.footerText]}>
|
||||||
|
{t("trip.crewList.totalLabel")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bảng hiển thị với animation */}
|
||||||
|
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
|
<View style={styles.cellWrapper}>
|
||||||
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.crewList.nameHeader")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||||
|
{t("trip.crewList.roleHeader")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{data.map((item) => (
|
||||||
|
<View key={item.Person.personal_id} style={styles.row}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cellWrapper}
|
||||||
|
onPress={() => handleCrewPress(item.Person.personal_id)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.cell, styles.linkText]}>
|
||||||
|
{item.Person.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.cell, styles.right]}>{item.role}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={[styles.row]}>
|
||||||
|
<Text style={[styles.cell, styles.footerText]}>
|
||||||
|
{t("trip.crewList.totalLabel")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Modal chi tiết thuyền viên */}
|
||||||
|
<CrewDetailModal
|
||||||
|
visible={modalVisible}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
crewData={selectedCrew}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CrewListTable;
|
||||||
123
components/tripInfo/FishingToolsList.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { createTableStyles } from "./ThemedTable";
|
||||||
|
|
||||||
|
const FishingToolsTable: React.FC = () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
|
const { trip } = useTrip();
|
||||||
|
const data: Model.FishingGear[] = trip?.fishing_gears ?? [];
|
||||||
|
const tongSoLuong = data.reduce((sum, item) => sum + Number(item.number), 0);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const toValue = collapsed ? contentHeight : 0;
|
||||||
|
Animated.timing(animatedHeight, {
|
||||||
|
toValue,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
setCollapsed((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header / Toggle */}
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={handleToggle}
|
||||||
|
style={styles.headerRow}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>{t("trip.fishingTools.title")}</Text>
|
||||||
|
{collapsed && <Text style={styles.totalCollapsed}>{tongSoLuong}</Text>}
|
||||||
|
<IconSymbol
|
||||||
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
|
size={16}
|
||||||
|
color={colors.icon}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Nội dung ẩn để đo chiều cao */}
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
|
||||||
|
onLayout={(event) => {
|
||||||
|
const height = event.nativeEvent.layout.height;
|
||||||
|
if (height > 0 && contentHeight === 0) {
|
||||||
|
setContentHeight(height);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Table Header */}
|
||||||
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
|
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||||
|
{t("trip.fishingTools.nameHeader")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||||
|
{t("trip.fishingTools.quantityHeader")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<View key={index} style={styles.row}>
|
||||||
|
<Text style={[styles.cell, styles.left]}>{item.name}</Text>
|
||||||
|
<Text style={[styles.cell, styles.right]}>{item.number}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={[styles.row]}>
|
||||||
|
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||||
|
{t("trip.fishingTools.totalLabel")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
|
||||||
|
{tongSoLuong}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Nội dung mở/đóng */}
|
||||||
|
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||||
|
{/* Table Header */}
|
||||||
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
|
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||||
|
{t("trip.fishingTools.nameHeader")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||||
|
{t("trip.fishingTools.quantityHeader")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<View key={index} style={styles.row}>
|
||||||
|
<Text style={[styles.cell, styles.left]}>{item.name}</Text>
|
||||||
|
<Text style={[styles.cell, styles.right]}>{item.number}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={[styles.row]}>
|
||||||
|
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||||
|
{t("trip.fishingTools.totalLabel")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
|
||||||
|
{tongSoLuong}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FishingToolsTable;
|
||||||
197
components/tripInfo/NetListTable.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useFishes } from "@/state/use-fish";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import CreateOrUpdateHaulModal from "./modal/CreateOrUpdateHaulModal";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { createTableStyles } from "./ThemedTable";
|
||||||
|
|
||||||
|
const NetListTable: React.FC = () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [selectedNet, setSelectedNet] = useState<Model.FishingLog | null>(null);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
|
const { trip } = useTrip();
|
||||||
|
const { fishSpecies, getFishSpecies } = useFishes();
|
||||||
|
useEffect(() => {
|
||||||
|
getFishSpecies();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const toValue = collapsed ? contentHeight : 0;
|
||||||
|
Animated.timing(animatedHeight, {
|
||||||
|
toValue,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
setCollapsed((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusPress = (id: string) => {
|
||||||
|
const net = trip?.fishing_logs?.find((item) => item.fishing_log_id === id);
|
||||||
|
if (net) {
|
||||||
|
setSelectedNet(net);
|
||||||
|
setModalVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header toggle */}
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={handleToggle}
|
||||||
|
style={styles.headerRow}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>{t("trip.netList.title")}</Text>
|
||||||
|
{collapsed && (
|
||||||
|
<Text style={styles.totalCollapsed}>
|
||||||
|
{trip?.fishing_logs?.length}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<IconSymbol
|
||||||
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
|
size={16}
|
||||||
|
color={colors.icon}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Nội dung ẩn để đo chiều cao */}
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
|
||||||
|
onLayout={(event) => {
|
||||||
|
const height = event.nativeEvent.layout.height;
|
||||||
|
// Update measured content height whenever it actually changes.
|
||||||
|
if (height > 0 && height !== contentHeight) {
|
||||||
|
setContentHeight(height);
|
||||||
|
// If the panel is currently expanded, animate to the new height so
|
||||||
|
// newly added/removed rows become visible immediately.
|
||||||
|
if (!collapsed) {
|
||||||
|
Animated.timing(animatedHeight, {
|
||||||
|
toValue: height,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
|
<Text style={[styles.sttCell, styles.headerText]}>
|
||||||
|
{t("trip.netList.sttHeader")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.netList.statusHeader")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{trip?.fishing_logs?.map((item, index) => (
|
||||||
|
<View key={item.fishing_log_id} style={styles.row}>
|
||||||
|
{/* Cột STT */}
|
||||||
|
<Text style={styles.sttCell}>
|
||||||
|
{t("trip.netList.haulPrefix")} {index + 1}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Cột Trạng thái */}
|
||||||
|
<View style={[styles.cell, styles.statusContainer]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusDot,
|
||||||
|
{ backgroundColor: item.status ? "#2ecc71" : "#FFD600" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleStatusPress(item.fishing_log_id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{item.status
|
||||||
|
? t("trip.netList.completed")
|
||||||
|
: t("trip.netList.pending")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bảng hiển thị với animation */}
|
||||||
|
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.row, styles.tableHeader]}>
|
||||||
|
<Text style={[styles.sttCell, styles.headerText]}>
|
||||||
|
{t("trip.netList.sttHeader")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.netList.statusHeader")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{trip?.fishing_logs?.map((item, index) => (
|
||||||
|
<View key={item.fishing_log_id} style={styles.row}>
|
||||||
|
{/* Cột STT */}
|
||||||
|
<Text style={styles.sttCell}>
|
||||||
|
{t("trip.netList.haulPrefix")} {index + 1}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Cột Trạng thái */}
|
||||||
|
<View style={[styles.cell, styles.statusContainer]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusDot,
|
||||||
|
{ backgroundColor: item.status ? "#2ecc71" : "#FFD600" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleStatusPress(item.fishing_log_id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{item.status
|
||||||
|
? t("trip.netList.completed")
|
||||||
|
: t("trip.netList.pending")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
<CreateOrUpdateHaulModal
|
||||||
|
isVisible={modalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
console.log("OnCLose");
|
||||||
|
setModalVisible(false);
|
||||||
|
}}
|
||||||
|
fishingLog={selectedNet}
|
||||||
|
fishingLogIndex={
|
||||||
|
selectedNet
|
||||||
|
? trip!.fishing_logs!.findIndex(
|
||||||
|
(item) => item.fishing_log_id === selectedNet.fishing_log_id
|
||||||
|
) + 1
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Modal chi tiết */}
|
||||||
|
{/* <NetDetailModal
|
||||||
|
visible={modalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
console.log("OnCLose");
|
||||||
|
setModalVisible(false);
|
||||||
|
}}
|
||||||
|
netData={selectedNet}
|
||||||
|
/> */}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NetListTable;
|
||||||
29
components/tripInfo/ThemedTable.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Wrapper component to easily apply theme-aware table styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { createTableStyles } from "./style/createTableStyles";
|
||||||
|
|
||||||
|
interface ThemedTableProps extends ViewProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemedTable({ style, children, ...props }: ThemedTableProps) {
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const tableStyles = useMemo(
|
||||||
|
() => createTableStyles(colorScheme),
|
||||||
|
[colorScheme]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[tableStyles.container, style]} {...props}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createTableStyles };
|
||||||
|
export type { TableStyles } from "./style/createTableStyles";
|
||||||
177
components/tripInfo/TripCostTable.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import { createTableStyles } from "./style/createTableStyles";
|
||||||
|
import TripCostDetailModal from "./modal/TripCostDetailModal";
|
||||||
|
import React, { useRef, useState, useMemo } from "react";
|
||||||
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
|
const TripCostTable: React.FC = () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colorScheme } = useAppTheme();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
|
||||||
|
const { trip } = useTrip();
|
||||||
|
|
||||||
|
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
|
||||||
|
|
||||||
|
const data: Model.TripCost[] = trip?.trip_cost ?? [];
|
||||||
|
const tongCong = data.reduce((sum, item) => sum + item.total_cost, 0);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const toValue = collapsed ? contentHeight : 0;
|
||||||
|
Animated.timing(animatedHeight, {
|
||||||
|
toValue,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
setCollapsed((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDetail = () => {
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={handleToggle}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
// marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>{t("trip.costTable.title")}</Text>
|
||||||
|
{collapsed && (
|
||||||
|
<Text style={[styles.totalCollapsed]}>
|
||||||
|
{tongCong.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<IconSymbol
|
||||||
|
name={collapsed ? "chevron.down" : "chevron.up"}
|
||||||
|
size={15}
|
||||||
|
color={colors.icon}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Nội dung ẩn để đo chiều cao */}
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
|
||||||
|
onLayout={(event) => {
|
||||||
|
const height = event.nativeEvent.layout.height;
|
||||||
|
if (height > 0 && contentHeight === 0) {
|
||||||
|
setContentHeight(height);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.row, styles.header]}>
|
||||||
|
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||||
|
{t("trip.costTable.typeHeader")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.costTable.totalCostHeader")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<View key={index} style={styles.row}>
|
||||||
|
<Text style={[styles.cell, styles.left]}>{item.type}</Text>
|
||||||
|
<Text style={[styles.cell, styles.right]}>
|
||||||
|
{item.total_cost.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={[styles.row]}>
|
||||||
|
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||||
|
{t("trip.costTable.totalLabel")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.total]}>
|
||||||
|
{tongCong.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* View Detail Button */}
|
||||||
|
{data.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.viewDetailButton}
|
||||||
|
onPress={handleViewDetail}
|
||||||
|
>
|
||||||
|
<Text style={styles.viewDetailText}>
|
||||||
|
{t("trip.costTable.viewDetail")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.row, styles.header]}>
|
||||||
|
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||||
|
{t("trip.costTable.typeHeader")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.headerText]}>
|
||||||
|
{t("trip.costTable.totalCostHeader")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<View key={index} style={styles.row}>
|
||||||
|
<Text style={[styles.cell, styles.left]}>{item.type}</Text>
|
||||||
|
<Text style={[styles.cell, styles.right]}>
|
||||||
|
{item.total_cost.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={[styles.row]}>
|
||||||
|
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||||
|
{t("trip.costTable.totalLabel")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cell, styles.total]}>
|
||||||
|
{tongCong.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* View Detail Button */}
|
||||||
|
{data.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.viewDetailButton}
|
||||||
|
onPress={handleViewDetail}
|
||||||
|
>
|
||||||
|
<Text style={styles.viewDetailText}>
|
||||||
|
{t("trip.costTable.viewDetail")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<TripCostDetailModal
|
||||||
|
visible={modalVisible}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripCostTable;
|
||||||
587
components/tripInfo/modal/CreateOrUpdateHaulModal.tsx
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
import Select from "@/components/Select";
|
||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { queryGpsData } from "@/controller/DeviceController";
|
||||||
|
import { queryUpdateFishingLogs } from "@/controller/TripController";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { showErrorToast, showSuccessToast } from "@/services/toast_service";
|
||||||
|
import { useFishes } from "@/state/use-fish";
|
||||||
|
import { useTrip } from "@/state/use-trip";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import React from "react";
|
||||||
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { InfoSection } from "./components/InfoSection";
|
||||||
|
import { createStyles } from "./style/CreateOrUpdateHaulModal.styles";
|
||||||
|
|
||||||
|
interface CreateOrUpdateHaulModalProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
fishingLog?: Model.FishingLog | null;
|
||||||
|
fishingLogIndex?: number;
|
||||||
|
}
|
||||||
|
const UNITS = ["con", "kg", "tấn"] as const;
|
||||||
|
type Unit = (typeof UNITS)[number];
|
||||||
|
|
||||||
|
const UNITS_OPTIONS = UNITS.map((unit) => ({
|
||||||
|
label: unit,
|
||||||
|
value: unit.toString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SIZE_UNITS = ["cm", "m"] as const;
|
||||||
|
type SizeUnit = (typeof SIZE_UNITS)[number];
|
||||||
|
|
||||||
|
const SIZE_UNITS_OPTIONS = SIZE_UNITS.map((unit) => ({
|
||||||
|
label: unit,
|
||||||
|
value: unit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Zod schema cho 1 dòng cá
|
||||||
|
const fishItemSchema = z.object({
|
||||||
|
id: z.number().min(1, ""),
|
||||||
|
quantity: z.number({ invalid_type_error: "" }).positive(""),
|
||||||
|
unit: z.enum(UNITS, { required_error: "" }),
|
||||||
|
size: z.number({ invalid_type_error: "" }).positive("").optional(),
|
||||||
|
sizeUnit: z.enum(SIZE_UNITS),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema tổng: mảng các item
|
||||||
|
const formSchema = z.object({
|
||||||
|
fish: z.array(fishItemSchema).min(1, ""),
|
||||||
|
});
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const defaultItem = (): FormValues["fish"][number] => ({
|
||||||
|
id: -1,
|
||||||
|
quantity: 1,
|
||||||
|
unit: "con",
|
||||||
|
size: undefined,
|
||||||
|
sizeUnit: "cm",
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateOrUpdateHaulModal: React.FC<CreateOrUpdateHaulModalProps> = ({
|
||||||
|
isVisible,
|
||||||
|
onClose,
|
||||||
|
fishingLog,
|
||||||
|
fishingLogIndex,
|
||||||
|
}) => {
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [isCreateMode, setIsCreateMode] = React.useState(!fishingLog?.info);
|
||||||
|
const [isEditing, setIsEditing] = React.useState(false);
|
||||||
|
const [expandedFishIndices, setExpandedFishIndices] = React.useState<
|
||||||
|
number[]
|
||||||
|
>([]);
|
||||||
|
const { trip, getTrip } = useTrip();
|
||||||
|
const { control, handleSubmit, formState, watch, reset } =
|
||||||
|
useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
fish: [defaultItem()],
|
||||||
|
},
|
||||||
|
mode: "onSubmit",
|
||||||
|
});
|
||||||
|
const { fishSpecies, getFishSpecies } = useFishes();
|
||||||
|
const { errors } = formState;
|
||||||
|
if (!fishSpecies) {
|
||||||
|
getFishSpecies();
|
||||||
|
}
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: "fish",
|
||||||
|
keyName: "_id", // tránh đụng key
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggleExpanded = (index: number) => {
|
||||||
|
setExpandedFishIndices((prev) =>
|
||||||
|
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
// Ensure species list is available so we can populate name/rarity
|
||||||
|
if (!fishSpecies || fishSpecies.length === 0) {
|
||||||
|
showErrorToast(t("trip.createHaulModal.fishListNotReady"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Helper to map form rows -> API info entries (single place)
|
||||||
|
const buildInfo = (rows: FormValues["fish"]) =>
|
||||||
|
rows.map((item) => {
|
||||||
|
const meta = fishSpecies.find((f) => f.id === item.id);
|
||||||
|
return {
|
||||||
|
fish_species_id: item.id,
|
||||||
|
fish_name: meta?.name ?? "",
|
||||||
|
catch_number: item.quantity,
|
||||||
|
catch_unit: item.unit,
|
||||||
|
fish_size: item.size,
|
||||||
|
fish_rarity: meta?.rarity_level ?? null,
|
||||||
|
fish_condition: "",
|
||||||
|
gear_usage: "",
|
||||||
|
} as unknown;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gpsResp = await queryGpsData();
|
||||||
|
if (!gpsResp.data) {
|
||||||
|
showErrorToast(t("trip.createHaulModal.gpsError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gpsData = gpsResp.data;
|
||||||
|
|
||||||
|
const info = buildInfo(values.fish) as any;
|
||||||
|
|
||||||
|
// Base payload fields shared between create and update
|
||||||
|
const base: Partial<Model.FishingLog> = {
|
||||||
|
fishing_log_id: fishingLog?.fishing_log_id || "",
|
||||||
|
trip_id: trip?.id || "",
|
||||||
|
start_at: fishingLog?.start_at!,
|
||||||
|
start_lat: fishingLog?.start_lat!,
|
||||||
|
start_lon: fishingLog?.start_lon!,
|
||||||
|
weather_description:
|
||||||
|
fishingLog?.weather_description || "Nắng đẹp, Trời nhiều mây",
|
||||||
|
info,
|
||||||
|
sync: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build final payload depending on create vs update
|
||||||
|
const body: Model.FishingLog =
|
||||||
|
fishingLog?.status == 0
|
||||||
|
? ({
|
||||||
|
...base,
|
||||||
|
haul_lat: gpsData.lat,
|
||||||
|
haul_lon: gpsData.lon,
|
||||||
|
end_at: new Date(),
|
||||||
|
status: 1,
|
||||||
|
} as Model.FishingLog)
|
||||||
|
: ({
|
||||||
|
...base,
|
||||||
|
haul_lat: fishingLog?.haul_lat,
|
||||||
|
haul_lon: fishingLog?.haul_lon,
|
||||||
|
end_at: fishingLog?.end_at,
|
||||||
|
status: fishingLog?.status,
|
||||||
|
} as Model.FishingLog);
|
||||||
|
// console.log("Body: ", body);
|
||||||
|
|
||||||
|
const resp = await queryUpdateFishingLogs(body);
|
||||||
|
if (resp?.status === 200) {
|
||||||
|
showSuccessToast(
|
||||||
|
fishingLog?.fishing_log_id == null
|
||||||
|
? t("trip.createHaulModal.addSuccess")
|
||||||
|
: t("trip.createHaulModal.updateSuccess")
|
||||||
|
);
|
||||||
|
getTrip();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showErrorToast(
|
||||||
|
fishingLog?.fishing_log_id == null
|
||||||
|
? t("trip.createHaulModal.addError")
|
||||||
|
: t("trip.createHaulModal.updateError")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("onSubmit error:", err);
|
||||||
|
showErrorToast(t("trip.createHaulModal.validationError"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize / reset form when modal visibility or haulData changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isVisible) {
|
||||||
|
// when modal closed, clear form to default
|
||||||
|
reset({ fish: [defaultItem()] });
|
||||||
|
setIsCreateMode(true);
|
||||||
|
setIsEditing(false);
|
||||||
|
setExpandedFishIndices([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// when modal opened, populate based on fishingLog
|
||||||
|
if (fishingLog?.info === null) {
|
||||||
|
// explicit null -> start with a single default item
|
||||||
|
reset({ fish: [defaultItem()] });
|
||||||
|
setIsCreateMode(true);
|
||||||
|
setIsEditing(true); // allow editing for new haul
|
||||||
|
setExpandedFishIndices([0]); // expand first item
|
||||||
|
} else if (Array.isArray(fishingLog?.info) && fishingLog?.info.length > 0) {
|
||||||
|
// map FishingLogInfo -> form rows
|
||||||
|
const mapped = fishingLog.info.map((h) => ({
|
||||||
|
id: h.fish_species_id ?? -1,
|
||||||
|
quantity: (h.catch_number as number) ?? 1,
|
||||||
|
unit: (h.catch_unit as Unit) ?? (defaultItem().unit as Unit),
|
||||||
|
size: (h.fish_size as number) ?? undefined,
|
||||||
|
sizeUnit: "cm" as SizeUnit,
|
||||||
|
}));
|
||||||
|
reset({ fish: mapped as any });
|
||||||
|
setIsCreateMode(false);
|
||||||
|
setIsEditing(false); // view mode by default
|
||||||
|
setExpandedFishIndices([]); // all collapsed
|
||||||
|
} else {
|
||||||
|
// undefined or empty array -> default
|
||||||
|
reset({ fish: [defaultItem()] });
|
||||||
|
setIsCreateMode(true);
|
||||||
|
setIsEditing(true); // allow editing for new haul
|
||||||
|
setExpandedFishIndices([0]); // expand first item
|
||||||
|
}
|
||||||
|
}, [isVisible, fishingLog?.info, reset]);
|
||||||
|
const renderRow = (item: any, index: number) => {
|
||||||
|
const isExpanded = expandedFishIndices.includes(index);
|
||||||
|
// Give expanded card highest zIndex, others get decreasing zIndex based on position
|
||||||
|
const cardZIndex = isExpanded ? 1000 : 100 - index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={item._id} style={[styles.fishCard, { zIndex: cardZIndex }]}>
|
||||||
|
{/* Delete + Chevron buttons - top right corner */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 8,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
{isEditing && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => remove(index)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
borderRadius: 8,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 2,
|
||||||
|
}}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<IconSymbol name="trash" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleToggleExpanded(index)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 2,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 2,
|
||||||
|
}}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<IconSymbol
|
||||||
|
name={isExpanded ? "chevron.up" : "chevron.down"}
|
||||||
|
size={24}
|
||||||
|
color="#fff"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Header - visible when collapsed */}
|
||||||
|
{!isExpanded && (
|
||||||
|
<View style={{ paddingRight: 100 }}>
|
||||||
|
{(() => {
|
||||||
|
const fishId = watch(`fish.${index}.id`);
|
||||||
|
const fishName = fishSpecies?.find((f) => f.id === fishId)?.name;
|
||||||
|
const quantity = watch(`fish.${index}.quantity`);
|
||||||
|
const unit = watch(`fish.${index}.unit`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.fishCardHeaderContent}>
|
||||||
|
<Text style={styles.fishCardTitle}>
|
||||||
|
{fishName || t("trip.createHaulModal.selectFish")}:
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.fishCardSubtitle}>
|
||||||
|
{fishName ? `${quantity} ${unit}` : "---"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form - visible when expanded */}
|
||||||
|
{isExpanded && (
|
||||||
|
<View style={{ paddingRight: 10 }}>
|
||||||
|
{/* Species dropdown */}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`fish.${index}.id`}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<View style={[styles.fieldGroup, { marginTop: 20 }]}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.createHaulModal.fishName")}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
options={fishSpecies!.map((fish) => ({
|
||||||
|
label: fish.name,
|
||||||
|
value: fish.id,
|
||||||
|
}))}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={t("trip.createHaulModal.selectFish")}
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
{errors.fish?.[index]?.id && (
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("trip.createHaulModal.selectFish")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Số lượng & Đơn vị cùng hàng */}
|
||||||
|
<View style={{ flexDirection: "row", gap: 12 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`fish.${index}.quantity`}
|
||||||
|
render={({ field: { value, onChange, onBlur } }) => (
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.createHaulModal.quantity")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChangeText={(t) =>
|
||||||
|
onChange(Number(t.replace(/,/g, ".")) || 0)
|
||||||
|
}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
!isEditing && styles.inputDisabled,
|
||||||
|
]}
|
||||||
|
editable={isEditing}
|
||||||
|
/>
|
||||||
|
{errors.fish?.[index]?.quantity && (
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("trip.createHaulModal.quantity")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`fish.${index}.unit`}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.createHaulModal.unit")}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
options={UNITS_OPTIONS.map((unit) => ({
|
||||||
|
label: unit.label,
|
||||||
|
value: unit.value,
|
||||||
|
}))}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={t("trip.createHaulModal.unit")}
|
||||||
|
disabled={!isEditing}
|
||||||
|
listStyle={{ maxHeight: 100 }}
|
||||||
|
/>
|
||||||
|
{errors.fish?.[index]?.unit && (
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("trip.createHaulModal.unit")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Size (optional) + Unit dropdown */}
|
||||||
|
<View style={{ flexDirection: "row", gap: 12 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`fish.${index}.size`}
|
||||||
|
render={({ field: { value, onChange, onBlur } }) => (
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.createHaulModal.size")} (
|
||||||
|
{t("trip.createHaulModal.optional")})
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={value ? String(value) : ""}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChangeText={(t) =>
|
||||||
|
onChange(t ? Number(t.replace(/,/g, ".")) : undefined)
|
||||||
|
}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
!isEditing && styles.inputDisabled,
|
||||||
|
]}
|
||||||
|
editable={isEditing}
|
||||||
|
/>
|
||||||
|
{errors.fish?.[index]?.size && (
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("trip.createHaulModal.size")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`fish.${index}.sizeUnit`}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.createHaulModal.unit")}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
options={SIZE_UNITS_OPTIONS}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={t("trip.createHaulModal.unit")}
|
||||||
|
disabled={!isEditing}
|
||||||
|
listStyle={{ maxHeight: 80 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={isVisible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
keyboardVerticalOffset={60}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{isCreateMode
|
||||||
|
? t("trip.createHaulModal.addFish")
|
||||||
|
: t("trip.createHaulModal.edit")}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.headerButtons}>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
{!isCreateMode && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
reset(); // reset to previous values
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
styles.saveButton,
|
||||||
|
{ backgroundColor: "#6c757d" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{t("trip.createHaulModal.cancel")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSubmit(onSubmit)}
|
||||||
|
style={styles.saveButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{t("trip.createHaulModal.save")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!isCreateMode && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setIsEditing(true)}
|
||||||
|
style={[styles.saveButton, { backgroundColor: "#17a2b8" }]}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{t("trip.createHaulModal.edit")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<View style={styles.closeIconButton}>
|
||||||
|
<IconSymbol name="xmark" size={24} color="#fff" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
{/* Info Section */}
|
||||||
|
<InfoSection fishingLog={fishingLog!} stt={fishingLogIndex} />
|
||||||
|
|
||||||
|
{/* Fish List */}
|
||||||
|
{fields.map((item, index) => renderRow(item, index))}
|
||||||
|
|
||||||
|
{/* Add Button - only show when editing */}
|
||||||
|
{isEditing && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => append(defaultItem())}
|
||||||
|
style={styles.addButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.addButtonText}>
|
||||||
|
+ {t("trip.createHaulModal.addFish")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{errors.fish && (
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{t("trip.createHaulModal.validationError")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateOrUpdateHaulModal;
|
||||||
105
components/tripInfo/modal/CrewDetailModal.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import React from "react";
|
||||||
|
import { Modal, ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import { createStyles } from "./style/CrewDetailModal.styles";
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 🧩 Interface
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
interface CrewDetailModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
crewData: Model.TripCrews | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 👤 Component Modal
|
||||||
|
// ---------------------------
|
||||||
|
const CrewDetailModal: React.FC<CrewDetailModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
crewData,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||||
|
|
||||||
|
if (!crewData) return null;
|
||||||
|
|
||||||
|
const infoItems = [
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.personalId"),
|
||||||
|
value: crewData.Person.personal_id,
|
||||||
|
},
|
||||||
|
{ label: t("trip.crewDetailModal.fullName"), value: crewData.Person.name },
|
||||||
|
{ label: t("trip.crewDetailModal.role"), value: crewData.role },
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.birthDate"),
|
||||||
|
value: crewData.Person.birth_date
|
||||||
|
? new Date(crewData.Person.birth_date).toLocaleDateString()
|
||||||
|
: t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.phone"),
|
||||||
|
value: crewData.Person.phone || t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.address"),
|
||||||
|
value: crewData.Person.address || t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.joinedDate"),
|
||||||
|
value: crewData.joined_at
|
||||||
|
? new Date(crewData.joined_at).toLocaleDateString()
|
||||||
|
: t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.note"),
|
||||||
|
value: crewData.note || t("trip.crewDetailModal.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.crewDetailModal.status"),
|
||||||
|
value: crewData.left_at
|
||||||
|
? t("trip.crewDetailModal.resigned")
|
||||||
|
: t("trip.crewDetailModal.working"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("trip.crewDetailModal.title")}</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<View style={styles.closeIconButton}>
|
||||||
|
<IconSymbol name="xmark" size={28} color="#fff" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
{infoItems.map((item, index) => (
|
||||||
|
<View key={index} style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>{item.label}</Text>
|
||||||
|
<Text style={styles.infoValue}>{item.value}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CrewDetailModal;
|
||||||
245
components/tripInfo/modal/TripCostDetailModal.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { createStyles } from "./style/TripCostDetailModal.styles";
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 🧩 Interface
|
||||||
|
// ---------------------------
|
||||||
|
interface TripCostDetailModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
data: Model.TripCost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// 💰 Component Modal
|
||||||
|
// ---------------------------
|
||||||
|
const TripCostDetailModal: React.FC<TripCostDetailModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
data,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editableData, setEditableData] = useState<Model.TripCost[]>(data);
|
||||||
|
|
||||||
|
// Cập nhật editableData khi props data thay đổi (API fetch xong)
|
||||||
|
useEffect(() => {
|
||||||
|
setEditableData(data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const tongCong = editableData.reduce((sum, item) => sum + item.total_cost, 0);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setIsEditing(!isEditing);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
// TODO: Save data to backend
|
||||||
|
console.log("Saved data:", editableData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditableData(data); // Reset to original data
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (
|
||||||
|
index: number,
|
||||||
|
field: keyof Model.TripCost,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
setEditableData((prev) =>
|
||||||
|
prev.map((item, idx) => {
|
||||||
|
if (idx === index) {
|
||||||
|
const updated = { ...item, [field]: value };
|
||||||
|
// Recalculate total_cost
|
||||||
|
if (field === "amount" || field === "cost_per_unit") {
|
||||||
|
const amount =
|
||||||
|
Number(field === "amount" ? value : item.amount) || 0;
|
||||||
|
const costPerUnit =
|
||||||
|
Number(field === "cost_per_unit" ? value : item.cost_per_unit) ||
|
||||||
|
0;
|
||||||
|
updated.total_cost = amount * costPerUnit;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
keyboardVerticalOffset={60}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("trip.costDetailModal.title")}</Text>
|
||||||
|
<View style={styles.headerButtons}>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleCancel}
|
||||||
|
style={styles.cancelButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>
|
||||||
|
{t("trip.costDetailModal.cancel")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSave}
|
||||||
|
style={styles.saveButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{t("trip.costDetailModal.save")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleEdit}
|
||||||
|
style={styles.editButton}
|
||||||
|
>
|
||||||
|
<View style={styles.editIconButton}>
|
||||||
|
<IconSymbol
|
||||||
|
name="pencil"
|
||||||
|
size={28}
|
||||||
|
color="#fff"
|
||||||
|
weight="heavy"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<View style={styles.closeIconButton}>
|
||||||
|
<IconSymbol name="xmark" size={28} color="#fff" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
{editableData.map((item, index) => (
|
||||||
|
<View key={index} style={styles.itemCard}>
|
||||||
|
{/* Loại */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.costType")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
|
value={item.type}
|
||||||
|
onChangeText={(value) => updateItem(index, "type", value)}
|
||||||
|
editable={isEditing}
|
||||||
|
placeholder={t("trip.costDetailModal.enterCostType")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Số lượng & Đơn vị */}
|
||||||
|
<View style={styles.rowGroup}>
|
||||||
|
<View
|
||||||
|
style={[styles.fieldGroup, { flex: 1, marginRight: 8 }]}
|
||||||
|
>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.quantity")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
|
value={String(item.amount ?? "")}
|
||||||
|
onChangeText={(value) =>
|
||||||
|
updateItem(index, "amount", value)
|
||||||
|
}
|
||||||
|
editable={isEditing}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.fieldGroup, { flex: 1, marginLeft: 8 }]}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.unit")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
|
value={item.unit}
|
||||||
|
onChangeText={(value) => updateItem(index, "unit", value)}
|
||||||
|
editable={isEditing}
|
||||||
|
placeholder={t("trip.costDetailModal.placeholder")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Chi phí/đơn vị */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.costPerUnit")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, !isEditing && styles.inputDisabled]}
|
||||||
|
value={String(item.cost_per_unit ?? "")}
|
||||||
|
onChangeText={(value) =>
|
||||||
|
updateItem(index, "cost_per_unit", value)
|
||||||
|
}
|
||||||
|
editable={isEditing}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tổng chi phí */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("trip.costDetailModal.totalCost")}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.totalContainer}>
|
||||||
|
<Text style={styles.totalText}>
|
||||||
|
{item.total_cost.toLocaleString()}{" "}
|
||||||
|
{t("trip.costDetailModal.vnd")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Footer Total */}
|
||||||
|
<View style={styles.footerTotal}>
|
||||||
|
<Text style={styles.footerLabel}>
|
||||||
|
{t("trip.costDetailModal.total")}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.footerAmount}>
|
||||||
|
{tongCong.toLocaleString()} {t("trip.costDetailModal.vnd")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripCostDetailModal;
|
||||||
119
components/tripInfo/modal/components/InfoSection.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
interface InfoSectionProps {
|
||||||
|
fishingLog?: Model.FishingLog;
|
||||||
|
stt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InfoSection: React.FC<InfoSectionProps> = ({
|
||||||
|
fishingLog,
|
||||||
|
stt,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||||
|
|
||||||
|
if (!fishingLog) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const infoItems = [
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.sttLabel"),
|
||||||
|
value: `${t("trip.infoSection.haulPrefix")} ${stt}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.statusLabel"),
|
||||||
|
value:
|
||||||
|
fishingLog.status === 1
|
||||||
|
? t("trip.infoSection.statusCompleted")
|
||||||
|
: t("trip.infoSection.statusPending"),
|
||||||
|
isStatus: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.startTimeLabel"),
|
||||||
|
value: fishingLog.start_at
|
||||||
|
? new Date(fishingLog.start_at).toLocaleString()
|
||||||
|
: t("trip.infoSection.notUpdated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("trip.infoSection.endTimeLabel"),
|
||||||
|
value:
|
||||||
|
fishingLog.end_at.toString() !== "0001-01-01T00:00:00Z"
|
||||||
|
? new Date(fishingLog.end_at).toLocaleString()
|
||||||
|
: "-",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
{infoItems.map((item, index) => (
|
||||||
|
<View key={index} style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>{item.label}</Text>
|
||||||
|
{item.isStatus ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
item.value === t("trip.infoSection.statusCompleted")
|
||||||
|
? styles.statusBadgeCompleted
|
||||||
|
: styles.statusBadgeInProgress,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.statusBadgeText}>{item.value}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.infoValue}>{item.value}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
infoCard: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
backgroundColor: colors.surfaceSecondary,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
statusBadgeCompleted: {
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
},
|
||||||
|
statusBadgeInProgress: {
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
},
|
||||||
|
statusBadgeText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export const createStyles = (colors: typeof Colors.light) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
closeIconButton: {
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
fishCard: {
|
||||||
|
backgroundColor: colors.surfaceSecondary,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
fishCardHeaderContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
fishCardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
fishCardSubtitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
fieldGroup: {
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
inputDisabled: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: colors.error,
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
removeButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
addButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
footerSection: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.separator,
|
||||||
|
},
|
||||||
|
saveButtonLarge: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
saveButtonLargeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
emptyStateText: {
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
69
components/tripInfo/modal/style/CrewDetailModal.styles.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export const createStyles = (colors: typeof Colors.light) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
closeIconButton: {
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
infoCard: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 35,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
});
|
||||||
293
components/tripInfo/modal/style/NetDetailModal.styles.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export const createStyles = (colors: typeof Colors.light) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
closeIconButton: {
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 15,
|
||||||
|
},
|
||||||
|
infoCard: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 35,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
statusBadgeCompleted: {
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
},
|
||||||
|
statusBadgeInProgress: {
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
},
|
||||||
|
statusBadgeText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
statusBadgeTextCompleted: {
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
statusBadgeTextInProgress: {
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
headerButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
editButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
editIconButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
totalCatchText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
fishCard: {
|
||||||
|
position: "relative",
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
fieldGroup: {
|
||||||
|
marginBottom: 12,
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
|
rowGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
selectButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
selectButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
optionsList: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 46,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
maxHeight: 100,
|
||||||
|
zIndex: 1000,
|
||||||
|
elevation: 5,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
},
|
||||||
|
optionItem: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
optionsStatusFishList: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
maxHeight: 120,
|
||||||
|
zIndex: 1000,
|
||||||
|
elevation: 5,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
},
|
||||||
|
fishNameDropdown: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 46,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
maxHeight: 180,
|
||||||
|
zIndex: 1000,
|
||||||
|
elevation: 5,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
},
|
||||||
|
fishCardHeaderContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 5,
|
||||||
|
},
|
||||||
|
fishCardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
fishCardSubtitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.warning,
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
|
addFishButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
addFishButtonContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
addFishButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
});
|
||||||
153
components/tripInfo/modal/style/TripCostDetailModal.styles.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export const createStyles = (colors: typeof Colors.light) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
closeIconButton: {
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
editButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
editIconButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
itemCard: {
|
||||||
|
backgroundColor: colors.surfaceSecondary,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
fieldGroup: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
rowGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
inputDisabled: {
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
totalContainer: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
totalText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.warning,
|
||||||
|
},
|
||||||
|
footerTotal: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 50,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
footerLabel: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
footerAmount: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.warning,
|
||||||
|
},
|
||||||
|
});
|
||||||
175
components/tripInfo/style/createTableStyles.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
|
||||||
|
export type ColorScheme = "light" | "dark";
|
||||||
|
|
||||||
|
export function createTableStyles(colorScheme: ColorScheme) {
|
||||||
|
const colors = Colors[colorScheme];
|
||||||
|
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginVertical: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
shadowColor: colors.text,
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
totalCollapsed: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 0.5,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
rowHorizontal: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 0.5,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
paddingLeft: 15,
|
||||||
|
},
|
||||||
|
tableHeader: {
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
headerCell: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
headerCellLeft: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
cellLeft: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
cellRight: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
cellWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
headerText: {
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
footerTotal: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
sttCell: {
|
||||||
|
flex: 0.3,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
paddingLeft: 10,
|
||||||
|
},
|
||||||
|
statusContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
statusDotPending: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.primary,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: colors.primary,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
viewDetailButton: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
viewDetailText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
footerRow: {
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TableStyles = ReturnType<typeof createTableStyles>;
|
||||||
45
components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { PropsWithChildren, useState } from 'react';
|
||||||
|
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||||
|
import { Colors } from '@/constants/theme';
|
||||||
|
import { useColorScheme } from '@/hooks/use-theme-context';
|
||||||
|
|
||||||
|
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const theme = useColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.heading}
|
||||||
|
onPress={() => setIsOpen((value) => !value)}
|
||||||
|
activeOpacity={0.8}>
|
||||||
|
<IconSymbol
|
||||||
|
name="chevron.right"
|
||||||
|
size={18}
|
||||||
|
weight="medium"
|
||||||
|
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||||
|
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
heading: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
marginTop: 6,
|
||||||
|
marginLeft: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
32
components/ui/icon-symbol.ios.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||||
|
import { StyleProp, ViewStyle } from 'react-native';
|
||||||
|
|
||||||
|
export function IconSymbol({
|
||||||
|
name,
|
||||||
|
size = 24,
|
||||||
|
color,
|
||||||
|
style,
|
||||||
|
weight = 'regular',
|
||||||
|
}: {
|
||||||
|
name: SymbolViewProps['name'];
|
||||||
|
size?: number;
|
||||||
|
color: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
weight?: SymbolWeight;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SymbolView
|
||||||
|
weight={weight}
|
||||||
|
tintColor={color}
|
||||||
|
resizeMode="scaleAspectFit"
|
||||||
|
name={name}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/ui/icon-symbol.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Fallback for using MaterialIcons on Android and web.
|
||||||
|
|
||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import { SymbolViewProps, SymbolWeight } from "expo-symbols";
|
||||||
|
import { ComponentProps } from "react";
|
||||||
|
import { OpaqueColorValue, type StyleProp, type TextStyle } from "react-native";
|
||||||
|
|
||||||
|
type IconMapping = Record<
|
||||||
|
SymbolViewProps["name"],
|
||||||
|
ComponentProps<typeof MaterialIcons>["name"]
|
||||||
|
>;
|
||||||
|
type IconSymbolName = keyof typeof MAPPING;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add your SF Symbols to Material Icons mappings here.
|
||||||
|
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||||
|
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||||
|
*/
|
||||||
|
const MAPPING = {
|
||||||
|
gear: "settings",
|
||||||
|
"paperplane.fill": "send",
|
||||||
|
"chevron.left.forwardslash.chevron.right": "code",
|
||||||
|
"chevron.right": "chevron-right",
|
||||||
|
"ferry.fill": "directions-boat",
|
||||||
|
"map.fill": "map",
|
||||||
|
"chevron.down": "arrow-drop-down",
|
||||||
|
"chevron.up": "arrow-drop-up",
|
||||||
|
"exclamationmark.triangle.fill": "warning",
|
||||||
|
"book.closed.fill": "book",
|
||||||
|
"dot.radiowaves.left.and.right": "sensors",
|
||||||
|
xmark: "close",
|
||||||
|
pencil: "edit",
|
||||||
|
trash: "delete",
|
||||||
|
} as IconMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||||
|
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||||
|
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||||
|
*/
|
||||||
|
export function IconSymbol({
|
||||||
|
name,
|
||||||
|
size = 24,
|
||||||
|
color,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
name: IconSymbolName;
|
||||||
|
size?: number;
|
||||||
|
color: string | OpaqueColorValue;
|
||||||
|
style?: StyleProp<TextStyle>;
|
||||||
|
weight?: SymbolWeight;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MaterialIcons
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
name={MAPPING[name]}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
578
components/ui/modal.tsx
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { ReactNode, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
Pressable,
|
||||||
|
Modal as RNModal,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface ModalProps {
|
||||||
|
/** Whether the modal dialog is visible or not */
|
||||||
|
open?: boolean;
|
||||||
|
/** The modal dialog's title */
|
||||||
|
title?: ReactNode;
|
||||||
|
/** Whether a close (x) button is visible on top right or not */
|
||||||
|
closable?: boolean;
|
||||||
|
/** Custom close icon */
|
||||||
|
closeIcon?: ReactNode;
|
||||||
|
/** Whether to close the modal dialog when the mask (area outside the modal) is clicked */
|
||||||
|
maskClosable?: boolean;
|
||||||
|
/** Centered Modal */
|
||||||
|
centered?: boolean;
|
||||||
|
/** Width of the modal dialog */
|
||||||
|
width?: number | string;
|
||||||
|
/** Whether to apply loading visual effect for OK button or not */
|
||||||
|
confirmLoading?: boolean;
|
||||||
|
/** Text of the OK button */
|
||||||
|
okText?: string;
|
||||||
|
/** Text of the Cancel button */
|
||||||
|
cancelText?: string;
|
||||||
|
/** Button type of the OK button */
|
||||||
|
okType?: "primary" | "default" | "dashed" | "text" | "link";
|
||||||
|
/** Footer content, set as footer={null} when you don't need default buttons */
|
||||||
|
footer?: ReactNode | null;
|
||||||
|
/** Whether show mask or not */
|
||||||
|
mask?: boolean;
|
||||||
|
/** The z-index of the Modal */
|
||||||
|
zIndex?: number;
|
||||||
|
/** Specify a function that will be called when a user clicks the OK button */
|
||||||
|
onOk?: (e?: any) => void | Promise<void>;
|
||||||
|
/** Specify a function that will be called when a user clicks mask, close button on top right or Cancel button */
|
||||||
|
onCancel?: (e?: any) => void;
|
||||||
|
/** Callback when the animation ends when Modal is turned on and off */
|
||||||
|
afterOpenChange?: (open: boolean) => void;
|
||||||
|
/** Specify a function that will be called when modal is closed completely */
|
||||||
|
afterClose?: () => void;
|
||||||
|
/** Custom className */
|
||||||
|
className?: string;
|
||||||
|
/** Modal body content */
|
||||||
|
children?: ReactNode;
|
||||||
|
/** Whether to unmount child components on close */
|
||||||
|
destroyOnClose?: boolean;
|
||||||
|
/** The ok button props */
|
||||||
|
okButtonProps?: any;
|
||||||
|
/** The cancel button props */
|
||||||
|
cancelButtonProps?: any;
|
||||||
|
/** Whether support press esc to close */
|
||||||
|
keyboard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmModalProps extends Omit<ModalProps, "open"> {
|
||||||
|
/** Type of the confirm modal */
|
||||||
|
type?: "info" | "success" | "error" | "warning" | "confirm";
|
||||||
|
/** Content */
|
||||||
|
content?: ReactNode;
|
||||||
|
/** Custom icon */
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal Component
|
||||||
|
const Modal: React.FC<ModalProps> & {
|
||||||
|
info: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
success: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
error: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
warning: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
confirm: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
useModal: () => [
|
||||||
|
{
|
||||||
|
info: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
success: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
error: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
warning: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
confirm: (props: ConfirmModalProps) => ModalInstance;
|
||||||
|
},
|
||||||
|
ReactNode
|
||||||
|
];
|
||||||
|
} = ({
|
||||||
|
open = false,
|
||||||
|
title,
|
||||||
|
closable = true,
|
||||||
|
closeIcon,
|
||||||
|
maskClosable = true,
|
||||||
|
centered = false,
|
||||||
|
width = 520,
|
||||||
|
confirmLoading = false,
|
||||||
|
okText = "OK",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
okType = "primary",
|
||||||
|
footer,
|
||||||
|
mask = true,
|
||||||
|
zIndex = 1000,
|
||||||
|
onOk,
|
||||||
|
onCancel,
|
||||||
|
afterOpenChange,
|
||||||
|
afterClose,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
destroyOnClose = false,
|
||||||
|
okButtonProps,
|
||||||
|
cancelButtonProps,
|
||||||
|
keyboard = true,
|
||||||
|
}) => {
|
||||||
|
const [visible, setVisible] = useState(open);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisible(open);
|
||||||
|
if (afterOpenChange) {
|
||||||
|
afterOpenChange(open);
|
||||||
|
}
|
||||||
|
}, [open, afterOpenChange]);
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
if (onOk) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onOk();
|
||||||
|
// Không tự động đóng modal - để parent component quyết định
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Modal onOk error:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
// Nếu không có onCancel, tự động đóng modal
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaskPress = () => {
|
||||||
|
if (maskClosable) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestClose = () => {
|
||||||
|
if (keyboard) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible && afterClose) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
afterClose();
|
||||||
|
}, 300); // Wait for animation to complete
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visible, afterClose]);
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
if (footer === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footer !== undefined) {
|
||||||
|
return <View style={styles.footer}>{footer}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.cancelButton]}
|
||||||
|
onPress={handleCancel}
|
||||||
|
disabled={loading || confirmLoading}
|
||||||
|
{...cancelButtonProps}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>{cancelText}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
styles.okButton,
|
||||||
|
okType === "primary" && styles.primaryButton,
|
||||||
|
(loading || confirmLoading) && styles.disabledButton,
|
||||||
|
]}
|
||||||
|
onPress={handleOk}
|
||||||
|
disabled={loading || confirmLoading}
|
||||||
|
{...okButtonProps}
|
||||||
|
>
|
||||||
|
{loading || confirmLoading ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.okButtonText}>{okText}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalWidth =
|
||||||
|
typeof width === "number" ? width : Dimensions.get("window").width * 0.9;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RNModal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleRequestClose}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.overlay,
|
||||||
|
centered && styles.centered,
|
||||||
|
{ zIndex },
|
||||||
|
!mask && styles.noMask,
|
||||||
|
]}
|
||||||
|
onPress={handleMaskPress}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.modal, { width: modalWidth, maxWidth: "90%" }]}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{(title || closable) && (
|
||||||
|
<View style={styles.header}>
|
||||||
|
{title && (
|
||||||
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{closable && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={handleCancel}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
{closeIcon || (
|
||||||
|
<Ionicons name="close" size={24} color="#666" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<View style={styles.body}>
|
||||||
|
{(!destroyOnClose || visible) && children}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{renderFooter()}
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</RNModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Confirm Modal Component
|
||||||
|
const ConfirmModal: React.FC<
|
||||||
|
ConfirmModalProps & { visible: boolean; onClose: () => void }
|
||||||
|
> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
type = "confirm",
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
icon,
|
||||||
|
okText = "OK",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
onOk,
|
||||||
|
onCancel,
|
||||||
|
...restProps
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
if (icon !== undefined) return icon;
|
||||||
|
|
||||||
|
const iconProps = { size: 24, style: { marginRight: 12 } };
|
||||||
|
switch (type) {
|
||||||
|
case "info":
|
||||||
|
return (
|
||||||
|
<Ionicons name="information-circle" color="#1890ff" {...iconProps} />
|
||||||
|
);
|
||||||
|
case "success":
|
||||||
|
return (
|
||||||
|
<Ionicons name="checkmark-circle" color="#52c41a" {...iconProps} />
|
||||||
|
);
|
||||||
|
case "error":
|
||||||
|
return <Ionicons name="close-circle" color="#ff4d4f" {...iconProps} />;
|
||||||
|
case "warning":
|
||||||
|
return <Ionicons name="warning" color="#faad14" {...iconProps} />;
|
||||||
|
default:
|
||||||
|
return <Ionicons name="help-circle" color="#1890ff" {...iconProps} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
if (onOk) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onOk();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Confirm modal onOk error:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={visible}
|
||||||
|
title={title}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={type === "confirm" ? handleCancel : undefined}
|
||||||
|
okText={okText}
|
||||||
|
cancelText={cancelText}
|
||||||
|
confirmLoading={loading}
|
||||||
|
footer={
|
||||||
|
type === "confirm" ? undefined : (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.okButton, styles.primaryButton]}
|
||||||
|
onPress={handleOk}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.okButtonText}>{okText}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<View style={styles.confirmContent}>
|
||||||
|
{getIcon()}
|
||||||
|
<Text style={styles.confirmText}>{content}</Text>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal Instance
|
||||||
|
export interface ModalInstance {
|
||||||
|
destroy: () => void;
|
||||||
|
update: (config: ConfirmModalProps) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container for imperatively created modals - Not used in React Native
|
||||||
|
// Static methods will return instance but won't render imperatively
|
||||||
|
// Use Modal.useModal() hook for proper context support
|
||||||
|
|
||||||
|
const createConfirmModal = (config: ConfirmModalProps): ModalInstance => {
|
||||||
|
console.warn(
|
||||||
|
"Modal static methods are not fully supported in React Native. Please use Modal.useModal() hook for better context support."
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy: () => {
|
||||||
|
console.warn(
|
||||||
|
"Modal.destroy() called but static modals are not supported in React Native"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
update: (newConfig: ConfirmModalProps) => {
|
||||||
|
console.warn(
|
||||||
|
"Modal.update() called but static modals are not supported in React Native"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static methods
|
||||||
|
Modal.info = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "info" });
|
||||||
|
Modal.success = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "success" });
|
||||||
|
Modal.error = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "error" });
|
||||||
|
Modal.warning = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "warning" });
|
||||||
|
Modal.confirm = (props: ConfirmModalProps) =>
|
||||||
|
createConfirmModal({ ...props, type: "confirm" });
|
||||||
|
|
||||||
|
// useModal hook
|
||||||
|
Modal.useModal = () => {
|
||||||
|
const [modals, setModals] = useState<ReactNode[]>([]);
|
||||||
|
|
||||||
|
const createModal = (
|
||||||
|
config: ConfirmModalProps,
|
||||||
|
type: ConfirmModalProps["type"]
|
||||||
|
) => {
|
||||||
|
const id = `modal-${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
setModals((prev) => prev.filter((modal: any) => modal.key !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (newConfig: ConfirmModalProps) => {
|
||||||
|
setModals((prev) =>
|
||||||
|
prev.map((modal: any) =>
|
||||||
|
modal.key === id ? (
|
||||||
|
<ConfirmModal
|
||||||
|
key={id}
|
||||||
|
visible={true}
|
||||||
|
onClose={destroy}
|
||||||
|
{...config}
|
||||||
|
{...newConfig}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
modal
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalElement = (
|
||||||
|
<ConfirmModal
|
||||||
|
key={id}
|
||||||
|
visible={true}
|
||||||
|
onClose={destroy}
|
||||||
|
{...config}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
setModals((prev) => [...prev, modalElement]);
|
||||||
|
|
||||||
|
return { destroy, update };
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalMethods = {
|
||||||
|
info: (props: ConfirmModalProps) => createModal(props, "info"),
|
||||||
|
success: (props: ConfirmModalProps) => createModal(props, "success"),
|
||||||
|
error: (props: ConfirmModalProps) => createModal(props, "error"),
|
||||||
|
warning: (props: ConfirmModalProps) => createModal(props, "warning"),
|
||||||
|
confirm: (props: ConfirmModalProps) => createModal(props, "confirm"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextHolder = <>{modals}</>;
|
||||||
|
|
||||||
|
return [modalMethods, contextHolder];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.45)",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
paddingTop: 100,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: 0,
|
||||||
|
},
|
||||||
|
noMask: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 8,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
maxHeight: "90%",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#000",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 6,
|
||||||
|
minWidth: 70,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: 36,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#d9d9d9",
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: "#000",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
okButton: {
|
||||||
|
backgroundColor: "#1890ff",
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
backgroundColor: "#1890ff",
|
||||||
|
},
|
||||||
|
okButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
confirmContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
confirmText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#000",
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
262
components/ui/slice-switch.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
OpaqueColorValue,
|
||||||
|
Pressable,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
const SIZE_PRESETS = {
|
||||||
|
sm: { width: 64, height: 32 },
|
||||||
|
md: { width: 80, height: 40 },
|
||||||
|
lg: { width: 96, height: 48 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SwitchSize = keyof typeof SIZE_PRESETS;
|
||||||
|
|
||||||
|
const DEFAULT_TOGGLE_DURATION = 400;
|
||||||
|
|
||||||
|
// Default both backgrounds to a grey tone when not provided
|
||||||
|
const DEFAULT_INACTIVE_BG = "#D3DAD9";
|
||||||
|
const DEFAULT_ACTIVE_BG = "#D3DAD9";
|
||||||
|
const PRESSED_SCALE = 0.96;
|
||||||
|
const PRESS_FEEDBACK_DURATION = 120;
|
||||||
|
|
||||||
|
type IoniconName = ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
|
type SliceSwitchProps = {
|
||||||
|
size?: SwitchSize;
|
||||||
|
leftIcon?: IoniconName;
|
||||||
|
leftIconColor?: string | OpaqueColorValue | undefined;
|
||||||
|
rightIconColor?: string | OpaqueColorValue | undefined;
|
||||||
|
rightIcon?: IoniconName;
|
||||||
|
duration?: number;
|
||||||
|
activeBackgroundColor?: string;
|
||||||
|
inactiveBackgroundColor?: string;
|
||||||
|
inactiveOverlayColor?: string;
|
||||||
|
activeOverlayColor?: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
onChange?: (value: boolean) => void;
|
||||||
|
value?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SliceSwitch = ({
|
||||||
|
size = "md",
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
duration,
|
||||||
|
activeBackgroundColor = DEFAULT_ACTIVE_BG,
|
||||||
|
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
|
||||||
|
leftIconColor = "#fff",
|
||||||
|
rightIconColor = "#fff",
|
||||||
|
inactiveOverlayColor = "#000",
|
||||||
|
activeOverlayColor = "#000",
|
||||||
|
style,
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
}: SliceSwitchProps) => {
|
||||||
|
const { width: containerWidth, height: containerHeight } =
|
||||||
|
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
|
||||||
|
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
|
||||||
|
const [isOn, setIsOn] = useState(value ?? false);
|
||||||
|
const [bgOn, setBgOn] = useState(value ?? false);
|
||||||
|
const progress = useRef(new Animated.Value(value ? 1 : 0)).current;
|
||||||
|
const pressScale = useRef(new Animated.Value(1)).current;
|
||||||
|
const overlayTranslateX = useRef(
|
||||||
|
new Animated.Value(value ? containerWidth / 2 : 0)
|
||||||
|
).current;
|
||||||
|
const listenerIdRef = useRef<string | number | null>(null);
|
||||||
|
|
||||||
|
// Sync with external value prop if provided
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== undefined && value !== isOn) {
|
||||||
|
animateToValue(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const animateToValue = (next: boolean) => {
|
||||||
|
const targetValue = next ? 1 : 0;
|
||||||
|
const overlayTarget = next ? containerWidth / 2 : 0;
|
||||||
|
|
||||||
|
progress.stopAnimation();
|
||||||
|
overlayTranslateX.stopAnimation();
|
||||||
|
|
||||||
|
if (animationDuration <= 0) {
|
||||||
|
progress.setValue(targetValue);
|
||||||
|
overlayTranslateX.setValue(overlayTarget);
|
||||||
|
setIsOn(next);
|
||||||
|
setBgOn(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOn(next);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(progress, {
|
||||||
|
toValue: targetValue,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(overlayTranslateX, {
|
||||||
|
toValue: overlayTarget,
|
||||||
|
duration: animationDuration,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
setBgOn(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove any previous listener
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap image & background exactly at 50% progress
|
||||||
|
let swapped = false;
|
||||||
|
listenerIdRef.current = progress.addListener(({ value }) => {
|
||||||
|
if (swapped) return;
|
||||||
|
if (next && value >= 0.5) {
|
||||||
|
swapped = true;
|
||||||
|
setBgOn(next);
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!next && value <= 0.5) {
|
||||||
|
swapped = true;
|
||||||
|
setBgOn(next);
|
||||||
|
if (listenerIdRef.current != null) {
|
||||||
|
progress.removeListener(listenerIdRef.current as string);
|
||||||
|
listenerIdRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
const next = !isOn;
|
||||||
|
if (value === undefined) {
|
||||||
|
animateToValue(next);
|
||||||
|
}
|
||||||
|
onChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
pressScale.stopAnimation();
|
||||||
|
Animated.timing(pressScale, {
|
||||||
|
toValue: PRESSED_SCALE,
|
||||||
|
duration: PRESS_FEEDBACK_DURATION,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressOut = () => {
|
||||||
|
pressScale.stopAnimation();
|
||||||
|
Animated.timing(pressScale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: PRESS_FEEDBACK_DURATION,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggle}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
|
accessibilityRole="switch"
|
||||||
|
accessibilityState={{ checked: isOn }}
|
||||||
|
style={[styles.pressable, style]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.shadowWrapper,
|
||||||
|
{
|
||||||
|
transform: [{ scale: pressScale }],
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderRadius: containerHeight / 2,
|
||||||
|
backgroundColor: bgOn
|
||||||
|
? activeBackgroundColor
|
||||||
|
: inactiveBackgroundColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: containerWidth / 2,
|
||||||
|
height: containerHeight * 0.95,
|
||||||
|
top: containerHeight * 0.01,
|
||||||
|
left: 0,
|
||||||
|
borderRadius: containerHeight * 0.95 / 2,
|
||||||
|
zIndex: 10,
|
||||||
|
backgroundColor: bgOn ? activeOverlayColor : inactiveOverlayColor,
|
||||||
|
transform: [{ translateX: overlayTranslateX }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className="h-full w-1/2 items-center justify-center ">
|
||||||
|
<Ionicons
|
||||||
|
name={leftIcon ?? "sunny"}
|
||||||
|
size={20}
|
||||||
|
color={leftIconColor ?? "#fff"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="h-full w-1/2 items-center justify-center ">
|
||||||
|
<Ionicons
|
||||||
|
name={rightIcon ?? "moon"}
|
||||||
|
size={20}
|
||||||
|
color={rightIconColor ?? "#fff"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pressable: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
shadowWrapper: {
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 6,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
knob: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SliceSwitch;
|
||||||
35
config/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { DOMAIN, TOKEN } from "@/constants";
|
||||||
|
import { removeStorageItem } from "@/utils/storage";
|
||||||
|
import { Router } from "expo-router";
|
||||||
|
|
||||||
|
let routerInstance: Router | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set router instance để dùng trong non-component context
|
||||||
|
*/
|
||||||
|
export const setRouterInstance = (router: Router) => {
|
||||||
|
routerInstance = router;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle 401 error - redirect to login
|
||||||
|
*/
|
||||||
|
export const handle401 = () => {
|
||||||
|
if (routerInstance) {
|
||||||
|
removeStorageItem(TOKEN);
|
||||||
|
// Cancel all pending requests to prevent further API calls
|
||||||
|
if (typeof window !== "undefined" && (window as any).axiosAbortController) {
|
||||||
|
(window as any).axiosAbortController.abort();
|
||||||
|
}
|
||||||
|
routerInstance.navigate("/auth/login");
|
||||||
|
} else {
|
||||||
|
console.warn("Router instance not set, cannot redirect to login");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear router instance (optional, for cleanup)
|
||||||
|
*/
|
||||||
|
export const clearRouterInstance = () => {
|
||||||
|
routerInstance = null;
|
||||||
|
};
|
||||||
83
config/axios.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { DOMAIN, TOKEN } from "@/constants";
|
||||||
|
import { showErrorToast } from "@/services/toast_service";
|
||||||
|
import { getStorageItem } from "@/utils/storage";
|
||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
import { handle401 } from "./auth";
|
||||||
|
|
||||||
|
const codeMessage = {
|
||||||
|
200: "The server successfully returned the requested data。",
|
||||||
|
201: "New or modified data succeeded。",
|
||||||
|
202: "A request has been queued in the background (asynchronous task)。",
|
||||||
|
204: "Data deleted successfully。",
|
||||||
|
400: "There is an error in the request sent, the server did not perform the operation of creating or modifying data。",
|
||||||
|
401: "The user does not have permission (token, username, password is wrong) 。",
|
||||||
|
403: "User is authorized, but access is prohibited。",
|
||||||
|
404: "The request issued was for a non-existent record, the server did not operate。",
|
||||||
|
406: "The requested format is not available。",
|
||||||
|
410: "The requested resource is permanently deleted and will no longer be available。",
|
||||||
|
422: "When creating an object, a validation error occurred。",
|
||||||
|
500: "Server error, please check the server。",
|
||||||
|
502: "Gateway error。",
|
||||||
|
503: "Service unavailable, server temporarily overloaded or maintained。",
|
||||||
|
504: "Gateway timeout。",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tạo instance axios với cấu hình cơ bản
|
||||||
|
const api: AxiosInstance = axios.create({
|
||||||
|
baseURL: "https://sgw.gms.vn",
|
||||||
|
timeout: 20000, // Timeout 20 giây
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🚀 Axios baseURL:", api.defaults.baseURL);
|
||||||
|
|
||||||
|
// Interceptor cho request (thêm token nếu cần)
|
||||||
|
api.interceptors.request.use(
|
||||||
|
async (config) => {
|
||||||
|
// Thêm auth token nếu có
|
||||||
|
const token = await getStorageItem(TOKEN);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Interceptor cho response (xử lý lỗi chung)
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (!error.response) {
|
||||||
|
const networkErrorMsg =
|
||||||
|
error.message || "Network error - please check connection";
|
||||||
|
showErrorToast("Lỗi kết nối");
|
||||||
|
console.error("Response Network Error: ", networkErrorMsg);
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, statusText, data } = error.response;
|
||||||
|
|
||||||
|
// Ưu tiên: codeMessage → backend message → statusText
|
||||||
|
const errMsg =
|
||||||
|
codeMessage[status as keyof typeof codeMessage] ||
|
||||||
|
data?.message ||
|
||||||
|
statusText ||
|
||||||
|
"Unknown error";
|
||||||
|
|
||||||
|
showErrorToast(`Lỗi ${status}: ${errMsg}`);
|
||||||
|
if (status === 401) {
|
||||||
|
handle401();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
3
config/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./auth";
|
||||||
|
export { default as api } from "./axios";
|
||||||
|
export * from "./toast";
|
||||||
2
config/localization.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { useI18n } from "@/hooks/use-i18n";
|
||||||
|
export { default as i18n } from "./localization/i18n";
|
||||||
27
config/localization/i18n.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import en from "@/locales/en.json";
|
||||||
|
import vi from "@/locales/vi.json";
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
|
import { I18n } from "i18n-js";
|
||||||
|
|
||||||
|
// Set the key-value pairs for the different languages you want to support
|
||||||
|
const translations = {
|
||||||
|
en,
|
||||||
|
vi,
|
||||||
|
};
|
||||||
|
|
||||||
|
const i18n = new I18n(translations);
|
||||||
|
|
||||||
|
// Set the locale once at the beginning of your app
|
||||||
|
// This will be set from storage in the useI18n hook, default to device language or 'en'
|
||||||
|
i18n.locale = getLocales()[0].languageCode ?? "vi";
|
||||||
|
|
||||||
|
// Enable fallback mechanism - if a key is missing in the current language, it will use the key from English
|
||||||
|
i18n.enableFallback = true;
|
||||||
|
|
||||||
|
// Set default locale to English if no locale is available
|
||||||
|
i18n.defaultLocale = "vi";
|
||||||
|
|
||||||
|
// Storage key for locale preference
|
||||||
|
export const LOCALE_STORAGE_KEY = "app_locale_preference";
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
214
config/toast.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import {
|
||||||
|
BaseToast,
|
||||||
|
BaseToastProps,
|
||||||
|
SuccessToast,
|
||||||
|
} from "react-native-toast-message";
|
||||||
|
|
||||||
|
export const Colors: any = {
|
||||||
|
light: {
|
||||||
|
text: "#000",
|
||||||
|
back: "#ffffff",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: "#ffffff",
|
||||||
|
back: "#2B2D2E",
|
||||||
|
},
|
||||||
|
default: "#3498db",
|
||||||
|
info: "#3498db",
|
||||||
|
success: "#07bc0c",
|
||||||
|
warn: {
|
||||||
|
background: "#ffffff",
|
||||||
|
text: "black",
|
||||||
|
iconColor: "#f1c40f",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
background: "#ffffff",
|
||||||
|
text: "black",
|
||||||
|
iconColor: "#e74c3c",
|
||||||
|
},
|
||||||
|
textDefault: "#4c4c4c",
|
||||||
|
textDark: "black",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toastConfig = {
|
||||||
|
success: (props: BaseToastProps) => (
|
||||||
|
<SuccessToast
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: Colors.success,
|
||||||
|
backgroundColor: Colors.success,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
text1Style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
color: "#f0f0f0",
|
||||||
|
}}
|
||||||
|
renderLeadingIcon={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 40,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="check-circle" size={30} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
default: (props: BaseToastProps) => (
|
||||||
|
<BaseToast
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: Colors.default,
|
||||||
|
backgroundColor: Colors.default,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
text1Style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
color: "#f0f0f0",
|
||||||
|
}}
|
||||||
|
renderLeadingIcon={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 50,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="info" size={30} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
info: (props: BaseToastProps) => (
|
||||||
|
<BaseToast
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: Colors.info,
|
||||||
|
backgroundColor: Colors.info,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
text1Style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
color: "#f0f0f0",
|
||||||
|
}}
|
||||||
|
renderLeadingIcon={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 50,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="info-outline" size={30} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
warn: (props: BaseToastProps) => (
|
||||||
|
<BaseToast
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: Colors.warn.background,
|
||||||
|
backgroundColor: Colors.warn.background,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
text1Style={{
|
||||||
|
color: Colors.warn.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
color: "#333",
|
||||||
|
}}
|
||||||
|
renderLeadingIcon={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 50,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="warning"
|
||||||
|
size={30}
|
||||||
|
color={Colors.warn.iconColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
error: (props: BaseToastProps) => (
|
||||||
|
<BaseToast
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: Colors.error.background,
|
||||||
|
backgroundColor: Colors.error.background,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
text1Style={{
|
||||||
|
color: Colors.error.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
color: "#f0f0f0",
|
||||||
|
}}
|
||||||
|
renderLeadingIcon={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 50,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="error"
|
||||||
|
size={30}
|
||||||
|
color={Colors.error.iconColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
46
constants/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export const TOKEN = "token";
|
||||||
|
export const DOMAIN = "domain";
|
||||||
|
export const MAP_TRACKPOINTS_ID = "ship-trackpoints";
|
||||||
|
export const MAP_POLYLINE_BAN = "ban-polyline";
|
||||||
|
export const MAP_POLYGON_BAN = "ban-polygon";
|
||||||
|
|
||||||
|
// Global Constants
|
||||||
|
export const IOS_PLATFORM = "ios";
|
||||||
|
export const ANDROID_PLATFORM = "android";
|
||||||
|
export const WEB_PLATFORM = "web";
|
||||||
|
export const AUTO_REFRESH_INTERVAL = 5000; // in milliseconds
|
||||||
|
export const LIGHT_THEME = "light";
|
||||||
|
export const DARK_THEME = "dark";
|
||||||
|
// Route Constants
|
||||||
|
export const ROUTE_LOGIN = "/login";
|
||||||
|
export const ROUTE_HOME = "/map";
|
||||||
|
export const ROUTE_TRIP = "/trip";
|
||||||
|
// Event Emitters
|
||||||
|
export const EVENT_GPS_DATA = "GPS_DATA_EVENT";
|
||||||
|
export const EVENT_ALARM_DATA = "ALARM_DATA_EVENT";
|
||||||
|
export const EVENT_ENTITY_DATA = "ENTITY_DATA_EVENT";
|
||||||
|
export const EVENT_BANZONE_DATA = "BANZONE_DATA_EVENT";
|
||||||
|
export const EVENT_TRACK_POINTS_DATA = "TRACK_POINTS_DATA_EVENT";
|
||||||
|
|
||||||
|
// Entity Contants
|
||||||
|
export const ENTITY = {
|
||||||
|
ZONE_ALARM_LIST: "50:2",
|
||||||
|
GPS: "50:1",
|
||||||
|
};
|
||||||
|
|
||||||
|
// API Path Constants
|
||||||
|
export const API_PATH_LOGIN = "/api/tokens";
|
||||||
|
export const API_PATH_ENTITIES = "/api/io/entities";
|
||||||
|
export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo";
|
||||||
|
export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist";
|
||||||
|
export const API_GET_LAYER_INFO = "/api/sgw/geojson";
|
||||||
|
export const API_GET_TRIP = "/api/sgw/trip";
|
||||||
|
export const API_GET_ALARMS = "/api/io/alarms";
|
||||||
|
export const API_UPDATE_TRIP_STATUS = "/api/sgw/tripState";
|
||||||
|
export const API_HAUL_HANDLE = "/api/sgw/fishingLog";
|
||||||
|
export const API_GET_GPS = "/api/sgw/gps";
|
||||||
|
export const API_GET_FISH = "/api/sgw/fishspecies";
|
||||||
|
export const API_UPDATE_FISHING_LOGS = "/api/sgw/fishingLog";
|
||||||
|
export const API_SOS = "/api/sgw/sos";
|
||||||
|
export const API_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints";
|
||||||
|
export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
|
||||||
84
constants/theme.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
const tintColorLight = "#0a7ea4";
|
||||||
|
const tintColorDark = "#fff";
|
||||||
|
|
||||||
|
export const Colors = {
|
||||||
|
light: {
|
||||||
|
text: "#11181C",
|
||||||
|
textSecondary: "#687076",
|
||||||
|
background: "#fff",
|
||||||
|
backgroundSecondary: "#f5f5f5",
|
||||||
|
surface: "#ffffff",
|
||||||
|
surfaceSecondary: "#f8f9fa",
|
||||||
|
tint: tintColorLight,
|
||||||
|
primary: "#007AFF",
|
||||||
|
secondary: "#5AC8FA",
|
||||||
|
success: "#34C759",
|
||||||
|
warning: "#ff6600",
|
||||||
|
error: "#FF3B30",
|
||||||
|
icon: "#687076",
|
||||||
|
iconSecondary: "#8E8E93",
|
||||||
|
border: "#C6C6C8",
|
||||||
|
separator: "#E5E5E7",
|
||||||
|
tabIconDefault: "#687076",
|
||||||
|
tabIconSelected: tintColorLight,
|
||||||
|
card: "#ffffff",
|
||||||
|
notification: "#FF3B30",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: "#ECEDEE",
|
||||||
|
textSecondary: "#8E8E93",
|
||||||
|
background: "#000000",
|
||||||
|
backgroundSecondary: "#1C1C1E",
|
||||||
|
surface: "#1C1C1E",
|
||||||
|
surfaceSecondary: "#2C2C2E",
|
||||||
|
tint: tintColorDark,
|
||||||
|
primary: "#0A84FF",
|
||||||
|
secondary: "#64D2FF",
|
||||||
|
success: "#30D158",
|
||||||
|
warning: "#ff6600",
|
||||||
|
error: "#FF453A",
|
||||||
|
icon: "#8E8E93",
|
||||||
|
iconSecondary: "#636366",
|
||||||
|
border: "#38383A",
|
||||||
|
separator: "#38383A",
|
||||||
|
tabIconDefault: "#8E8E93",
|
||||||
|
tabIconSelected: tintColorDark,
|
||||||
|
card: "#1C1C1E",
|
||||||
|
notification: "#FF453A",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorName = keyof typeof Colors.light;
|
||||||
|
|
||||||
|
export const Fonts = Platform.select({
|
||||||
|
ios: {
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||||
|
sans: "system-ui",
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||||
|
serif: "ui-serif",
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||||
|
rounded: "ui-rounded",
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||||
|
mono: "ui-monospace",
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
sans: "normal",
|
||||||
|
serif: "serif",
|
||||||
|
rounded: "normal",
|
||||||
|
mono: "monospace",
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||||
|
serif: "Georgia, 'Times New Roman', serif",
|
||||||
|
rounded:
|
||||||
|
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||||
|
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||||
|
},
|
||||||
|
});
|
||||||
6
controller/AuthController.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import { API_PATH_LOGIN } from "@/constants";
|
||||||
|
|
||||||
|
export async function queryLogin(body: Model.LoginRequestBody) {
|
||||||
|
return api.post<Model.LoginResponse>(API_PATH_LOGIN, body);
|
||||||
|
}
|
||||||
37
controller/DeviceController.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import {
|
||||||
|
API_GET_ALARMS,
|
||||||
|
API_GET_GPS,
|
||||||
|
API_PATH_ENTITIES,
|
||||||
|
API_PATH_SHIP_TRACK_POINTS,
|
||||||
|
API_SOS,
|
||||||
|
} from "@/constants";
|
||||||
|
import { transformEntityResponse } from "@/utils/tranform";
|
||||||
|
|
||||||
|
export async function queryGpsData() {
|
||||||
|
return api.get<Model.GPSResponse>(API_GET_GPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryAlarm() {
|
||||||
|
return api.get<Model.AlarmResponse>(API_GET_ALARMS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryTrackPoints() {
|
||||||
|
return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryEntities(): Promise<Model.TransformedEntity[]> {
|
||||||
|
const response = await api.get<Model.EntityResponse[]>(API_PATH_ENTITIES);
|
||||||
|
return response.data.map(transformEntityResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryGetSos() {
|
||||||
|
return await api.get<Model.SosResponse>(API_SOS);
|
||||||
|
}
|
||||||
|
export async function queryDeleteSos() {
|
||||||
|
return await api.delete<Model.SosResponse>(API_SOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function querySendSosMessage(message: string) {
|
||||||
|
return await api.put<Model.SosRequest>(API_SOS, { message });
|
||||||
|
}
|
||||||
6
controller/FishController.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import { API_GET_FISH } from "@/constants";
|
||||||
|
|
||||||
|
export async function queryFish() {
|
||||||
|
return api.get<Model.FishSpeciesResponse[]>(API_GET_FISH);
|
||||||
|
}
|
||||||
6
controller/MapController.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import { API_GET_ALL_BANZONES } from "@/constants";
|
||||||
|
|
||||||
|
export async function queryBanzones() {
|
||||||
|
return api.get<Model.Zone[]>(API_GET_ALL_BANZONES);
|
||||||
|
}
|
||||||
23
controller/TripController.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import {
|
||||||
|
API_GET_TRIP,
|
||||||
|
API_HAUL_HANDLE,
|
||||||
|
API_UPDATE_FISHING_LOGS,
|
||||||
|
API_UPDATE_TRIP_STATUS,
|
||||||
|
} from "@/constants";
|
||||||
|
|
||||||
|
export async function queryTrip() {
|
||||||
|
return api.get<Model.Trip>(API_GET_TRIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryUpdateTripState(body: Model.TripUpdateStateRequest) {
|
||||||
|
return api.put(API_UPDATE_TRIP_STATUS, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryStartNewHaul(body: Model.NewFishingLogRequest) {
|
||||||
|
return api.put(API_HAUL_HANDLE, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryUpdateFishingLogs(body: Model.FishingLog) {
|
||||||
|
return api.put(API_UPDATE_FISHING_LOGS, body);
|
||||||
|
}
|
||||||
5
controller/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import * as AuthController from "./AuthController";
|
||||||
|
import * as DeviceController from "./DeviceController";
|
||||||
|
import * as MapController from "./MapController";
|
||||||
|
import * as TripController from "./TripController";
|
||||||
|
export { AuthController, DeviceController, MapController, TripController };
|
||||||
214
controller/typings.d.ts
vendored
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
declare namespace Model {
|
||||||
|
interface LoginRequestBody {
|
||||||
|
guid: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
interface LoginResponse {
|
||||||
|
enabled2fa: boolean;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GPSResponse {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
s: number;
|
||||||
|
h: number;
|
||||||
|
fishing: boolean;
|
||||||
|
}
|
||||||
|
interface Alarm {
|
||||||
|
name: string;
|
||||||
|
t: number; // timestamp (epoch seconds)
|
||||||
|
level: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlarmResponse {
|
||||||
|
alarms: Alarm[];
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipTrackPoint {
|
||||||
|
time: number;
|
||||||
|
lon: number;
|
||||||
|
lat: number;
|
||||||
|
s: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
interface EntityResponse {
|
||||||
|
id: string;
|
||||||
|
v: number;
|
||||||
|
vs: string;
|
||||||
|
t: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
interface TransformedEntity {
|
||||||
|
id: string;
|
||||||
|
value: number;
|
||||||
|
valueString: string;
|
||||||
|
time: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banzones
|
||||||
|
// Banzone
|
||||||
|
export interface Zone {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: number;
|
||||||
|
conditions?: Condition[];
|
||||||
|
enabled?: boolean;
|
||||||
|
updated_at?: Date;
|
||||||
|
geom?: Geom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Condition {
|
||||||
|
max?: number;
|
||||||
|
min?: number;
|
||||||
|
type?: Type;
|
||||||
|
to?: number;
|
||||||
|
from?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Type {
|
||||||
|
LengthLimit = "length_limit",
|
||||||
|
MonthRange = "month_range",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Geom {
|
||||||
|
geom_type?: number;
|
||||||
|
geom_poly?: string;
|
||||||
|
geom_lines?: string;
|
||||||
|
geom_point?: string;
|
||||||
|
geom_radius?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SosRequest {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SosResponse {
|
||||||
|
active: boolean;
|
||||||
|
message?: string;
|
||||||
|
started_at?: number;
|
||||||
|
}
|
||||||
|
// Trip
|
||||||
|
interface Trip {
|
||||||
|
id: string;
|
||||||
|
ship_id: string;
|
||||||
|
ship_length: number;
|
||||||
|
vms_id: string;
|
||||||
|
name: string;
|
||||||
|
fishing_gears: FishingGear[]; // Dụng cụ đánh cá
|
||||||
|
crews?: TripCrews[]; // Thuyền viên
|
||||||
|
departure_time: string; // ISO datetime string
|
||||||
|
departure_port_id: number;
|
||||||
|
arrival_time: string; // ISO datetime string
|
||||||
|
arrival_port_id: number;
|
||||||
|
fishing_ground_codes: number[];
|
||||||
|
total_catch_weight: number | null;
|
||||||
|
total_species_caught: number | null;
|
||||||
|
trip_cost: TripCost[]; // Chi phí chuyến đi
|
||||||
|
trip_status: number;
|
||||||
|
approved_by: string;
|
||||||
|
notes: string | null;
|
||||||
|
fishing_logs: FishingLog[] | null; // tuỳ dữ liệu chi tiết có thể định nghĩa thêm
|
||||||
|
sync: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dụng cụ đánh cá
|
||||||
|
interface FishingGear {
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
// Thuyền viên
|
||||||
|
interface TripCrews {
|
||||||
|
TripID: string;
|
||||||
|
PersonalID: string;
|
||||||
|
role: string;
|
||||||
|
joined_at: Date;
|
||||||
|
left_at: Date | null;
|
||||||
|
note: string | null;
|
||||||
|
Person: TripCrewPerson;
|
||||||
|
}
|
||||||
|
interface TripCrewPerson {
|
||||||
|
personal_id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
birth_date: Date; // ISO string (có thể chuyển sang Date nếu parse trước)
|
||||||
|
note: string;
|
||||||
|
address: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
// Chi phí chuyến đi
|
||||||
|
interface TripCost {
|
||||||
|
type: string;
|
||||||
|
unit: string;
|
||||||
|
amount: number;
|
||||||
|
total_cost: number;
|
||||||
|
cost_per_unit: number;
|
||||||
|
}
|
||||||
|
// Thông tin mẻ lưới
|
||||||
|
interface FishingLog {
|
||||||
|
fishing_log_id: string;
|
||||||
|
trip_id: string;
|
||||||
|
start_at: Date; // ISO datetime
|
||||||
|
end_at: Date; // ISO datetime
|
||||||
|
start_lat: number;
|
||||||
|
start_lon: number;
|
||||||
|
haul_lat: number;
|
||||||
|
haul_lon: number;
|
||||||
|
status: number;
|
||||||
|
weather_description: string;
|
||||||
|
info?: FishingLogInfo[]; // Thông tin cá
|
||||||
|
sync: boolean;
|
||||||
|
}
|
||||||
|
// Thông tin cá
|
||||||
|
interface FishingLogInfo {
|
||||||
|
fish_species_id?: number;
|
||||||
|
fish_name?: string;
|
||||||
|
catch_number?: number;
|
||||||
|
catch_unit?: string;
|
||||||
|
fish_size?: number;
|
||||||
|
fish_rarity?: number;
|
||||||
|
fish_condition?: string;
|
||||||
|
gear_usage?: string;
|
||||||
|
}
|
||||||
|
interface NewFishingLogRequest {
|
||||||
|
trip_id: string;
|
||||||
|
start_at: Date; // ISO datetime
|
||||||
|
start_lat: number;
|
||||||
|
start_lon: number;
|
||||||
|
weather_description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripUpdateStateRequest {
|
||||||
|
status: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
//Fish
|
||||||
|
interface FishSpeciesResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
scientific_name: string;
|
||||||
|
group_name: string;
|
||||||
|
species_code: string;
|
||||||
|
note: string;
|
||||||
|
default_unit: string;
|
||||||
|
rarity_level: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
|
interface FishRarity {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
iucn_code: any;
|
||||||
|
cites_appendix: any;
|
||||||
|
vn_law: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
eslint.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
import expoConfig from "eslint-config-expo/flat";
|
||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ["dist/*"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
91
git-auto-push.sh
Executable file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Git Auto Push Script with Safe Pull and Error Handling ==="
|
||||||
|
|
||||||
|
# Hàm kiểm tra lỗi và thoát
|
||||||
|
handle_error() {
|
||||||
|
echo "❌ Lỗi: $1"
|
||||||
|
# Nếu có stash, hiển thị thông báo để người dùng biết cách xử lý
|
||||||
|
if [ "$stashed" = true ]; then
|
||||||
|
echo "⚠️ Có stash được lưu, hãy kiểm tra bằng 'git stash list' và xử lý thủ công nếu cần."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kiểm tra xem có trong Git repository không
|
||||||
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
handle_error "Thư mục hiện tại không phải là Git repository!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lấy nhánh hiện tại
|
||||||
|
current_branch=$(git branch --show-current)
|
||||||
|
if [ -z "$current_branch" ]; then
|
||||||
|
handle_error "Không thể xác định nhánh hiện tại!"
|
||||||
|
fi
|
||||||
|
echo "👉 Bạn đang ở nhánh: $current_branch"
|
||||||
|
|
||||||
|
# Hỏi nhánh chính, mặc định là 'master' nếu người dùng không nhập
|
||||||
|
read -p "Nhập tên nhánh chính (mặc định: master): " target_branch
|
||||||
|
target_branch=${target_branch:-master}
|
||||||
|
|
||||||
|
# Kiểm tra xem nhánh chính có tồn tại trên remote không
|
||||||
|
if ! git ls-remote --heads origin "$target_branch" >/dev/null 2>&1; then
|
||||||
|
handle_error "Nhánh $target_branch không tồn tại trên remote!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Bước 1: Stash nếu có thay đổi chưa commit ---
|
||||||
|
stashed=false
|
||||||
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
|
echo "💾 Có thay đổi chưa commit -> stash lại..."
|
||||||
|
git stash push -m "auto-stash-before-pull-$(date +%s)" || handle_error "Lỗi khi stash code!"
|
||||||
|
stashed=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Bước 2: Đồng bộ code từ nhánh chính về nhánh hiện tại ---
|
||||||
|
echo "🔄 Đồng bộ code từ $target_branch về $current_branch..."
|
||||||
|
git fetch origin "$target_branch" || handle_error "Lỗi khi fetch $target_branch!"
|
||||||
|
git merge origin/"$target_branch" --no-edit || {
|
||||||
|
handle_error "Merge từ $target_branch về $current_branch bị conflict, hãy xử lý thủ công rồi chạy lại."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Bước 3: Nếu có stash thì pop lại ---
|
||||||
|
if [ "$stashed" = true ]; then
|
||||||
|
echo "📥 Pop lại code đã stash..."
|
||||||
|
git stash pop || handle_error "Stash pop bị conflict, hãy xử lý thủ công bằng 'git stash list' và 'git stash apply'!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Bước 4: Commit & Push nhánh hiện tại ---
|
||||||
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
|
git add . || handle_error "Lỗi khi add files!"
|
||||||
|
read -p "Nhập commit message (mặc định: 'Update from $current_branch'): " commit_message
|
||||||
|
commit_message=${commit_message:-"Update from $current_branch"}
|
||||||
|
git commit -m "$commit_message" || handle_error "Lỗi khi commit!"
|
||||||
|
else
|
||||||
|
echo "⚠️ Không có thay đổi để commit."
|
||||||
|
fi
|
||||||
|
|
||||||
|
git push origin "$current_branch" || handle_error "Push nhánh $current_branch thất bại!"
|
||||||
|
|
||||||
|
# --- Bước 5: Checkout sang nhánh chính ---
|
||||||
|
echo "🔄 Chuyển sang nhánh $target_branch..."
|
||||||
|
git checkout "$target_branch" || handle_error "Checkout sang $target_branch thất bại!"
|
||||||
|
|
||||||
|
# --- Bước 6: Pull nhánh chính ---
|
||||||
|
echo "🔄 Pull code mới nhất từ remote $target_branch..."
|
||||||
|
git pull origin "$target_branch" --no-rebase || handle_error "Pull $target_branch thất bại!"
|
||||||
|
|
||||||
|
# --- Bước 7: Merge nhánh hiện tại vào nhánh chính ---
|
||||||
|
echo "🔀 Merge $current_branch vào $target_branch..."
|
||||||
|
git merge "$current_branch" --no-edit || {
|
||||||
|
handle_error "Merge từ $current_branch vào $target_branch bị conflict, hãy xử lý thủ công rồi chạy lại."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Bước 8: Push nhánh chính ---
|
||||||
|
git push origin "$target_branch" || handle_error "Push $target_branch thất bại!"
|
||||||
|
|
||||||
|
# --- Quay lại nhánh hiện tại ---
|
||||||
|
echo "🔄 Quay lại nhánh $current_branch..."
|
||||||
|
git checkout "$current_branch" || handle_error "Checkout về $current_branch thất bại!"
|
||||||
|
|
||||||
|
echo "✅ Hoàn tất! Code từ $current_branch đã được merge vào $target_branch và đẩy lên remote."
|
||||||