diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbe0d7d --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/THEME_GUIDE.md b/THEME_GUIDE.md new file mode 100644 index 0000000..6ff6ed0 --- /dev/null +++ b/THEME_GUIDE.md @@ -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; + 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 ( + + + + + + ); +} + +function AppContent() { + const { colorScheme } = useThemeContext(); + + return ( + + {/* ... routes */} + + + ); +} +``` + +## 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 ( + + + Mode: {themeMode}, Scheme: {colorScheme} + + + ); +} +``` + +### 2. useColorScheme Hook + +Alias để lấy colorScheme nhanh: + +```tsx +import { useColorScheme } from "@/hooks/use-theme-context"; + +function MyComponent() { + const colorScheme = useColorScheme(); // 'light' | 'dark' + + return Current theme: {colorScheme}; +} +``` + +**⚠️ 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 ( + + Text + + ); +} +``` + +**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 ( + + + Primary Button + + + + + Theme is {utils.isDark ? "Dark" : "Light"} + + + + + Transparent background + + + ); +} +``` + +### 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 ( + + Title Text + Subtitle + Regular Text + Link Text + + {/* Override với custom colors */} + + Custom colored text + + + ); +} +``` + +**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 ( + + + + ); +} +``` + +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 ✅ + + Hello + ; + + // Also good ✅ + const { colors } = useAppTheme(); + + Hello + ; + ``` + +3. **Tận dụng pre-built styles:** + + ```tsx + // Good ✅ + const { styles } = useAppTheme(); + + + // Less good ❌ + + ``` + +4. **Sử dụng opacity colors:** + + ```tsx + const { utils } = useAppTheme(); + ; + ``` + +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 `` +- 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 +``` diff --git a/app.json b/app.json new file mode 100644 index 0000000..32ddcca --- /dev/null +++ b/app.json @@ -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" + } + } + } +} diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..c158cc0 --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -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(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 ( + + ( + + ), + }} + /> + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/app/(tabs)/diary.tsx b/app/(tabs)/diary.tsx new file mode 100644 index 0000000..93d63d1 --- /dev/null +++ b/app/(tabs)/diary.tsx @@ -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 ( + + + + ); +} + +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", + }, +}); + diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..6086393 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -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(null); + const [alarmData, setAlarmData] = useState(null); + const [entityData, setEntityData] = useState< + Model.TransformedEntity[] | null + >(null); + const [banzoneData, setBanzoneData] = useState(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 ( + + + {/* {trackPointsData && + trackPointsData.length > 0 && + trackPointsData.map((point, index) => { + // console.log(`Rendering circle ${index}:`, point); + return ( + + ); + })} + {polylineCoordinates.length > 0 && ( + <> + {polylineCoordinates.map((polyline, index) => ( + + ))} + + )} + {polygonCoordinates.length > 0 && ( + <> + {polygonCoordinates.map((polygon, index) => { + return ( + + ); + })} + + )} */} + {/* {gpsData !== null && ( + + + + {alarmData?.level === 3 && ( + + )} + { + 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`, + }, + ], + }} + /> + + + + )} */} + + + {/* + + + */} + + ); +} + +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", + }, +}); diff --git a/app/(tabs)/sensor.tsx b/app/(tabs)/sensor.tsx new file mode 100644 index 0000000..dcb363b --- /dev/null +++ b/app/(tabs)/sensor.tsx @@ -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(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 ( + + + + Cảm biến trên tàu + { + 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 && ( + {errors.sosMessage} + )} + + + {/* Input Custom Message nếu chọn "Khác" */} + {selectedSosMessage === 999 && ( + + {t("home.sos.statusInput")} + { + setCustomMessage(text); + if (text.trim() !== "") { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors.customMessage; + return newErrors; + }); + } + }} + multiline + numberOfLines={4} + /> + {errors.customMessage && ( + {errors.customMessage} + )} + + )} + + + ); +}; + +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; diff --git a/components/parallax-scroll-view.tsx b/components/parallax-scroll-view.tsx new file mode 100644 index 0000000..a044380 --- /dev/null +++ b/components/parallax-scroll-view.tsx @@ -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(); + 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 ( + + + {headerImage} + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + height: HEADER_HEIGHT, + overflow: 'hidden', + }, + content: { + flex: 1, + padding: 32, + gap: 16, + overflow: 'hidden', + }, +}); diff --git a/components/rotate-switch.tsx b/components/rotate-switch.tsx new file mode 100644 index 0000000..63b65a2 --- /dev/null +++ b/components/rotate-switch.tsx @@ -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; + 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( + initialValue ? resolvedOnImage : resolvedOffImage + ); + + const progress = useRef(new Animated.Value(initialValue ? 1 : 0)).current; + const pressScale = useRef(new Animated.Value(1)).current; + const listenerIdRef = useRef(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 ( + + + + + + + + ); +}; + +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; diff --git a/components/theme-example.tsx b/components/theme-example.tsx new file mode 100644 index 0000000..d1d7323 --- /dev/null +++ b/components/theme-example.tsx @@ -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 ( + + + Theme Examples + + {/* Using themed components */} + Themed Components + + This is a themed text + + This is bold themed text + + + + {/* Using theme colors directly */} + Direct Color Usage + + + Using colors.text directly + + + Primary color text + + + + {/* Using pre-styled components */} + Pre-styled Components + + + Primary Button + + + + Secondary Button + + + + {/* Status containers */} + Status Indicators + + + + Success Message + + + + + + Warning Message + + + + + + Error Message + + + + + {/* Using opacity colors */} + Opacity Colors + + + + Primary with 10% opacity background + + + + + + Error with 20% opacity background + + + + + {/* Theme utilities */} + Theme Utilities + + + Is Dark Mode: {utils.isDark ? "Yes" : "No"} + + + Is Light Mode: {utils.isLight ? "Yes" : "No"} + + + + + Toggle Theme (Light/Dark) + + + + + {/* Custom themed component example */} + Custom Component + + + Custom component using useThemeColor + + + + + ); +} diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx new file mode 100644 index 0000000..069f863 --- /dev/null +++ b/components/theme-toggle.tsx @@ -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 ( + + {t("common.theme")} + + {themeOptions.map((option) => ( + setThemeMode(option.mode)} + > + + + {option.label} + + + ))} + + + ); +} + +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", + }, +}); diff --git a/components/themed-text.tsx b/components/themed-text.tsx new file mode 100644 index 0000000..fac7c04 --- /dev/null +++ b/components/themed-text.tsx @@ -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 ( + + ); +} + +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", + }, +}); diff --git a/components/themed-view.tsx b/components/themed-view.tsx new file mode 100644 index 0000000..6560de3 --- /dev/null +++ b/components/themed-view.tsx @@ -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 ( + + ); +} diff --git a/components/tripInfo/CrewListTable.tsx b/components/tripInfo/CrewListTable.tsx new file mode 100644 index 0000000..831894a --- /dev/null +++ b/components/tripInfo/CrewListTable.tsx @@ -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(0); + const animatedHeight = useRef(new Animated.Value(0)).current; + const [modalVisible, setModalVisible] = useState(false); + const [selectedCrew, setSelectedCrew] = useState( + 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 ( + + {/* Header toggle */} + + {t("trip.crewList.title")} + {collapsed && ( + {tongThanhVien} + )} + + + + {/* Nội dung ẩn để đo chiều cao */} + { + const height = event.nativeEvent.layout.height; + if (height > 0 && contentHeight === 0) { + setContentHeight(height); + } + }} + > + {/* Header */} + + + + {t("trip.crewList.nameHeader")} + + + + {t("trip.crewList.roleHeader")} + + + + {/* Body */} + {data.map((item) => ( + + handleCrewPress(item.Person.personal_id)} + > + + {item.Person.name} + + + {item.role} + + ))} + + {/* Footer */} + + + {t("trip.crewList.totalLabel")} + + {tongThanhVien} + + + + {/* Bảng hiển thị với animation */} + + {/* Header */} + + + + {t("trip.crewList.nameHeader")} + + + + {t("trip.crewList.roleHeader")} + + + + {/* Body */} + {data.map((item) => ( + + handleCrewPress(item.Person.personal_id)} + > + + {item.Person.name} + + + {item.role} + + ))} + + {/* Footer */} + + + {t("trip.crewList.totalLabel")} + + {tongThanhVien} + + + + {/* Modal chi tiết thuyền viên */} + + + ); +}; + +export default CrewListTable; diff --git a/components/tripInfo/FishingToolsList.tsx b/components/tripInfo/FishingToolsList.tsx new file mode 100644 index 0000000..e36cd6c --- /dev/null +++ b/components/tripInfo/FishingToolsList.tsx @@ -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(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 ( + + {/* Header / Toggle */} + + {t("trip.fishingTools.title")} + {collapsed && {tongSoLuong}} + + + + {/* Nội dung ẩn để đo chiều cao */} + { + const height = event.nativeEvent.layout.height; + if (height > 0 && contentHeight === 0) { + setContentHeight(height); + } + }} + > + {/* Table Header */} + + + {t("trip.fishingTools.nameHeader")} + + + {t("trip.fishingTools.quantityHeader")} + + + + {/* Body */} + {data.map((item, index) => ( + + {item.name} + {item.number} + + ))} + + {/* Footer */} + + + {t("trip.fishingTools.totalLabel")} + + + {tongSoLuong} + + + + + {/* Nội dung mở/đóng */} + + {/* Table Header */} + + + {t("trip.fishingTools.nameHeader")} + + + {t("trip.fishingTools.quantityHeader")} + + + + {/* Body */} + {data.map((item, index) => ( + + {item.name} + {item.number} + + ))} + + {/* Footer */} + + + {t("trip.fishingTools.totalLabel")} + + + {tongSoLuong} + + + + + ); +}; + +export default FishingToolsTable; diff --git a/components/tripInfo/NetListTable.tsx b/components/tripInfo/NetListTable.tsx new file mode 100644 index 0000000..677537c --- /dev/null +++ b/components/tripInfo/NetListTable.tsx @@ -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(0); + const animatedHeight = useRef(new Animated.Value(0)).current; + const [modalVisible, setModalVisible] = useState(false); + const [selectedNet, setSelectedNet] = useState(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 ( + + {/* Header toggle */} + + {t("trip.netList.title")} + {collapsed && ( + + {trip?.fishing_logs?.length} + + )} + + + + {/* Nội dung ẩn để đo chiều cao */} + { + 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 */} + + + {t("trip.netList.sttHeader")} + + + {t("trip.netList.statusHeader")} + + + + {/* Body */} + {trip?.fishing_logs?.map((item, index) => ( + + {/* Cột STT */} + + {t("trip.netList.haulPrefix")} {index + 1} + + + {/* Cột Trạng thái */} + + + handleStatusPress(item.fishing_log_id)} + > + + {item.status + ? t("trip.netList.completed") + : t("trip.netList.pending")} + + + + + ))} + + + {/* Bảng hiển thị với animation */} + + {/* Header */} + + + {t("trip.netList.sttHeader")} + + + {t("trip.netList.statusHeader")} + + + + {/* Body */} + {trip?.fishing_logs?.map((item, index) => ( + + {/* Cột STT */} + + {t("trip.netList.haulPrefix")} {index + 1} + + + {/* Cột Trạng thái */} + + + handleStatusPress(item.fishing_log_id)} + > + + {item.status + ? t("trip.netList.completed") + : t("trip.netList.pending")} + + + + + ))} + + { + 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 */} + {/* { + console.log("OnCLose"); + setModalVisible(false); + }} + netData={selectedNet} + /> */} + + ); +}; + +export default NetListTable; diff --git a/components/tripInfo/ThemedTable.tsx b/components/tripInfo/ThemedTable.tsx new file mode 100644 index 0000000..9d55ad2 --- /dev/null +++ b/components/tripInfo/ThemedTable.tsx @@ -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 ( + + {children} + + ); +} + +export { createTableStyles }; +export type { TableStyles } from "./style/createTableStyles"; diff --git a/components/tripInfo/TripCostTable.tsx b/components/tripInfo/TripCostTable.tsx new file mode 100644 index 0000000..6e6eb83 --- /dev/null +++ b/components/tripInfo/TripCostTable.tsx @@ -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(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 ( + + + {t("trip.costTable.title")} + {collapsed && ( + + {tongCong.toLocaleString()} + + )} + + + + {/* Nội dung ẩn để đo chiều cao */} + { + const height = event.nativeEvent.layout.height; + if (height > 0 && contentHeight === 0) { + setContentHeight(height); + } + }} + > + {/* Header */} + + + {t("trip.costTable.typeHeader")} + + + {t("trip.costTable.totalCostHeader")} + + + + {/* Body */} + {data.map((item, index) => ( + + {item.type} + + {item.total_cost.toLocaleString()} + + + ))} + + {/* Footer */} + + + {t("trip.costTable.totalLabel")} + + + {tongCong.toLocaleString()} + + + + {/* View Detail Button */} + {data.length > 0 && ( + + + {t("trip.costTable.viewDetail")} + + + )} + + + + {/* Header */} + + + {t("trip.costTable.typeHeader")} + + + {t("trip.costTable.totalCostHeader")} + + + + {/* Body */} + {data.map((item, index) => ( + + {item.type} + + {item.total_cost.toLocaleString()} + + + ))} + + {/* Footer */} + + + {t("trip.costTable.totalLabel")} + + + {tongCong.toLocaleString()} + + + + {/* View Detail Button */} + {data.length > 0 && ( + + + {t("trip.costTable.viewDetail")} + + + )} + + + {/* Modal */} + + + ); +}; + +export default TripCostTable; diff --git a/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx b/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx new file mode 100644 index 0000000..b941b89 --- /dev/null +++ b/components/tripInfo/modal/CreateOrUpdateHaulModal.tsx @@ -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; + +const defaultItem = (): FormValues["fish"][number] => ({ + id: -1, + quantity: 1, + unit: "con", + size: undefined, + sizeUnit: "cm", +}); + +const CreateOrUpdateHaulModal: React.FC = ({ + 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({ + 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 = { + 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 ( + + {/* Delete + Chevron buttons - top right corner */} + + {isEditing && ( + 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} + > + + + )} + 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} + > + + + + + {/* Header - visible when collapsed */} + {!isExpanded && ( + + {(() => { + 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 ( + + + {fishName || t("trip.createHaulModal.selectFish")}: + + + {fishName ? `${quantity} ${unit}` : "---"} + + + ); + })()} + + )} + + {/* Form - visible when expanded */} + {isExpanded && ( + + {/* Species dropdown */} + ( + + + {t("trip.createHaulModal.fishName")} + + ({ + label: unit.label, + value: unit.value, + }))} + value={value} + onChange={onChange} + placeholder={t("trip.createHaulModal.unit")} + disabled={!isEditing} + listStyle={{ maxHeight: 100 }} + /> + {errors.fish?.[index]?.unit && ( + + {t("trip.createHaulModal.unit")} + + )} + + )} + /> + + + + {/* Size (optional) + Unit dropdown */} + + + ( + + + {t("trip.createHaulModal.size")} ( + {t("trip.createHaulModal.optional")}) + + + onChange(t ? Number(t.replace(/,/g, ".")) : undefined) + } + style={[ + styles.input, + !isEditing && styles.inputDisabled, + ]} + editable={isEditing} + /> + {errors.fish?.[index]?.size && ( + + {t("trip.createHaulModal.size")} + + )} + + )} + /> + + + ( + + + {t("trip.createHaulModal.unit")} + +