diff --git a/THEME_GUIDE.md b/THEME_GUIDE.md new file mode 100644 index 0000000..c58c42f --- /dev/null +++ b/THEME_GUIDE.md @@ -0,0 +1,234 @@ +# Theme System Documentation + +## Tổng quan + +Hệ thống theme đã được cấu hình để hỗ trợ Light Mode, Dark Mode và System Mode (tự động theo hệ thống). Theme được lưu trữ trong AsyncStorage và sẽ được khôi phục khi khởi động lại ứng dụng. + +## Cấu trúc Theme + +### 1. Colors Configuration (`constants/theme.ts`) + +```typescript +export const Colors = { + light: { + text: "#11181C", + textSecondary: "#687076", + background: "#fff", + backgroundSecondary: "#f5f5f5", + surface: "#ffffff", + surfaceSecondary: "#f8f9fa", + primary: "#007AFF", + secondary: "#5AC8FA", + success: "#34C759", + warning: "#FF9500", + error: "#FF3B30", + // ... more colors + }, + dark: { + text: "#ECEDEE", + textSecondary: "#8E8E93", + background: "#000000", + backgroundSecondary: "#1C1C1E", + surface: "#1C1C1E", + surfaceSecondary: "#2C2C2E", + primary: "#0A84FF", + secondary: "#64D2FF", + success: "#30D158", + warning: "#FF9F0A", + error: "#FF453A", + // ... more colors + }, +}; +``` + +### 2. Theme Context (`hooks/use-theme-context.tsx`) + +Cung cấp theme state và functions cho toàn bộ app: + +```typescript +interface ThemeContextType { + themeMode: ThemeMode; // 'light' | 'dark' | 'system' + colorScheme: ColorScheme; // 'light' | 'dark' + colors: typeof Colors.light; + setThemeMode: (mode: ThemeMode) => Promise; + getColor: (colorName: ColorName) => string; +} +``` + +## Cách sử dụng Theme + +### 1. Sử dụng Themed Components + +```tsx +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; + +function MyComponent() { + return ( + + Title Text + Regular Text + + ); +} +``` + +### 2. Sử dụng Theme Hook + +```tsx +import { useThemeContext } from "@/hooks/use-theme-context"; + +function MyComponent() { + const { colors, colorScheme, setThemeMode } = useThemeContext(); + + return ( + + Current theme: {colorScheme} + + ); +} +``` + +### 3. Sử dụng App Theme Hook (Recommended) + +```tsx +import { useAppTheme } from "@/hooks/use-app-theme"; + +function MyComponent() { + const { colors, styles, utils } = useAppTheme(); + + return ( + + + Button + + + + + Theme is {utils.isDark ? "Dark" : "Light"} + + + + ); +} +``` + +### 4. Sử dụng useThemeColor Hook + +```tsx +import { useThemeColor } from "@/hooks/use-theme-color"; + +function MyComponent() { + // Override colors for specific themes + const backgroundColor = useThemeColor( + { light: "#ffffff", dark: "#1C1C1E" }, + "surface" + ); + + const textColor = useThemeColor({}, "text"); + + return ( + + Text + + ); +} +``` + +## Theme Toggle Component + +Sử dụng `ThemeToggle` component để cho phép user chọn theme: + +```tsx +import { ThemeToggle } from "@/components/theme-toggle"; + +function SettingsScreen() { + return ( + + + + ); +} +``` + +## Available Styles từ useAppTheme + +```typescript +const { styles } = useAppTheme(); + +// Container styles +styles.container; // Flex 1 container với background +styles.surface; // Card surface với padding +styles.card; // Card với shadow và border radius + +// Button styles +styles.primaryButton; // Primary button style +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 và padding + +// Status styles +styles.successContainer; // Success status container +styles.warningContainer; // Warning status container +styles.errorContainer; // Error status container + +// Utility +styles.separator; // Line separator +``` + +## Theme Utilities + +```typescript +const { utils } = useAppTheme(); + +utils.isDark; // boolean - kiểm tra dark mode +utils.isLight; // boolean - kiểm tra light mode +utils.toggleTheme(); // function - toggle giữa light/dark +utils.getOpacityColor(colorName, opacity); // Tạo màu với opacity +``` + +## Lưu trữ Theme Preference + +Theme preference được tự động lưu trong AsyncStorage với key `'theme_mode'`. Khi app khởi động, theme sẽ được khôi phục từ storage. + +## Best Practices + +1. **Sử dụng `useAppTheme`** thay vì access colors trực tiếp +2. **Sử dụng pre-defined styles** từ `useAppTheme().styles` +3. **Kiểm tra theme** bằng `utils.isDark` thay vì check colorScheme +4. **Sử dụng opacity colors** cho backgrounds: `utils.getOpacityColor('primary', 0.1)` +5. **Tận dụng ThemedText và ThemedView** cho các component đơn giản + +## Migration từ theme cũ + +Nếu bạn đang sử dụng theme cũ: + +```tsx +// Cũ +const colorScheme = useColorScheme(); +const backgroundColor = colorScheme === "dark" ? "#000" : "#fff"; + +// Mới +const { colors } = useAppTheme(); +const backgroundColor = colors.background; +``` + +## Troubleshooting + +1. **Theme không được lưu**: Kiểm tra AsyncStorage permissions +2. **Flash khi khởi động**: ThemeProvider sẽ chờ load theme trước khi render +3. **Colors không đúng**: Đảm bảo component được wrap trong ThemeProvider + +## Examples + +Xem `components/theme-example.tsx` để biết các cách sử dụng theme khác nhau. diff --git a/app.json b/app.json index 23d0263..595c6f2 100644 --- a/app.json +++ b/app.json @@ -9,7 +9,16 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "infoPlist": { + "CFBundleLocalizations": [ + "en", + "vi", + "en", + "vi" + ] + }, + "bundleIdentifier": "com.minhnn86.sgwapp" }, "android": { "adaptiveIcon": { @@ -19,7 +28,12 @@ "monochromeImage": "./assets/images/android-icon-monochrome.png" }, "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "permissions": [ + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO" + ], + "package": "com.minhnn86.sgwapp" }, "web": { "output": "static", @@ -28,6 +42,7 @@ }, "plugins": [ "expo-router", + "expo-system-ui", [ "expo-splash-screen", { @@ -50,8 +65,14 @@ "expo-localization", { "supportedLocales": { - "ios": ["en", "vi"], - "android": ["en", "vi"] + "ios": [ + "en", + "vi" + ], + "android": [ + "en", + "vi" + ] } } ] diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 7047998..d508dbd 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -16,6 +16,7 @@ import { } from "@/constants"; import { useColorScheme } from "@/hooks/use-color-scheme.web"; import { usePlatform } from "@/hooks/use-platform"; +import { useThemeContext } from "@/hooks/use-theme-context"; import { getAlarmEventBus, getBanzonesEventBus, @@ -53,7 +54,7 @@ export default function HomeScreen() { PolygonWithLabelProps[] >([]); const platform = usePlatform(); - const theme = useColorScheme(); + const theme = useThemeContext().colorScheme; const scale = useRef(new Animated.Value(0)).current; const opacity = useRef(new Animated.Value(1)).current; diff --git a/app/(tabs)/setting.tsx b/app/(tabs)/setting.tsx index f2b1d18..a535124 100644 --- a/app/(tabs)/setting.tsx +++ b/app/(tabs)/setting.tsx @@ -1,15 +1,18 @@ import { useRouter } from "expo-router"; import { useEffect, useState } from "react"; -import { StyleSheet, View } from "react-native"; +import { StyleSheet, View, ScrollView } 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 { ThemedText } from "@/components/themed-text"; import { ThemedView } from "@/components/themed-view"; +import { ThemeToggle } from "@/components/theme-toggle"; import { DOMAIN, TOKEN } from "@/constants"; import { useI18n } from "@/hooks/use-i18n"; +import { useThemeContext } from "@/hooks/use-theme-context"; import { removeStorageItem } from "@/utils/storage"; +import { SafeAreaView } from "react-native-safe-area-context"; type Todo = { userId: number; id: number; @@ -21,7 +24,9 @@ export default function SettingScreen() { const router = useRouter(); const [data, setData] = useState(null); const { t, locale, setLocale } = useI18n(); + const { colors } = useThemeContext(); const [isEnabled, setIsEnabled] = useState(locale === "vi"); + // Sync isEnabled state khi locale thay đổi useEffect(() => { setIsEnabled(locale === "vi"); @@ -33,72 +38,116 @@ export default function SettingScreen() { }; return ( - - {t("navigation.setting")} + + + + + {t("navigation.setting")} + - - {t("common.language")} - {/* */} - - + {/* Theme Toggle Section */} + - { - await removeStorageItem(TOKEN); - await removeStorageItem(DOMAIN); - router.navigate("/auth/login"); - }} - > - {t("auth.logout")} + {/* Language Section */} + + {t("common.language")} + + + + {/* Logout Button */} + { + await removeStorageItem(TOKEN); + await removeStorageItem(DOMAIN); + router.navigate("/auth/login"); + }} + > + + {t("auth.logout")} + + + + {data && ( + + {data.title} + {data.completed} + {data.id} + + )} + - - {data && ( - - {data.title} - {data.completed} - {data.id} - - )} - + ); } const styles = StyleSheet.create({ + safeArea: { + flex: 1, + paddingBottom: 5, + }, container: { flex: 1, - alignItems: "center", - justifyContent: "center", + }, + scrollView: { + flex: 1, + }, + scrollContent: { padding: 20, gap: 16, }, + title: { + textAlign: "center", + marginBottom: 20, + }, + themeSection: { + marginBottom: 8, + }, settingItem: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", - width: "100%", paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 8, - backgroundColor: "rgba(0, 122, 255, 0.1)", + paddingVertical: 16, + borderRadius: 12, + borderWidth: 1, }, button: { marginTop: 20, - paddingVertical: 12, + paddingVertical: 14, paddingHorizontal: 20, - backgroundColor: "#007AFF", - borderRadius: 8, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + }, + buttonText: { + color: "#fff", + fontSize: 16, + }, + debugSection: { + marginTop: 20, + padding: 16, + borderRadius: 12, + gap: 8, }, }); diff --git a/app/(tabs)/tripInfo.tsx b/app/(tabs)/tripInfo.tsx index c57358b..afc1ddf 100644 --- a/app/(tabs)/tripInfo.tsx +++ b/app/(tabs)/tripInfo.tsx @@ -5,19 +5,18 @@ 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 { 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 { trip, getTrip } = useTrip(); - // useEffect(() => { - // getTrip(); - // }, []); - + const { colors } = useThemeContext(); return ( - Thông Tin Chuyến Đi + + Thông Tin Chuyến Đi + diff --git a/app/_layout.tsx b/app/_layout.tsx index e127d73..57769dd 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,7 +1,7 @@ import { DarkTheme, DefaultTheme, - ThemeProvider, + ThemeProvider as NavigationThemeProvider, } from "@react-navigation/native"; import { Stack, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; @@ -13,52 +13,61 @@ import { GluestackUIProvider } from "@/components/ui/gluestack-ui-provider/glues import { toastConfig } from "@/config"; import { setRouterInstance } from "@/config/auth"; import "@/global.css"; -import { useColorScheme } from "@/hooks/use-color-scheme"; +import { ThemeProvider, useThemeContext } from "@/hooks/use-theme-context"; import { I18nProvider } from "@/hooks/use-i18n"; import Toast from "react-native-toast-message"; import "../global.css"; -export default function RootLayout() { - const colorScheme = useColorScheme(); +function AppContent() { + const { colorScheme } = useThemeContext(); const router = useRouter(); useEffect(() => { setRouterInstance(router); }, [router]); + + return ( + + + + + + + + + + + + + + ); +} + +export default function RootLayout() { return ( - - - - - - - - - - - - - + + + ); } 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/tripInfo/CrewListTable.tsx b/components/tripInfo/CrewListTable.tsx index a8f6375..831894a 100644 --- a/components/tripInfo/CrewListTable.tsx +++ b/components/tripInfo/CrewListTable.tsx @@ -1,10 +1,12 @@ import { IconSymbol } from "@/components/ui/icon-symbol"; import { useI18n } from "@/hooks/use-i18n"; import { useTrip } from "@/state/use-trip"; -import React, { useRef, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { Animated, Text, TouchableOpacity, View } from "react-native"; import CrewDetailModal from "./modal/CrewDetailModal"; -import styles from "./style/CrewListTable.styles"; +import { useAppTheme } from "@/hooks/use-app-theme"; +import { useThemeContext } from "@/hooks/use-theme-context"; +import { createTableStyles } from "./ThemedTable"; const CrewListTable: React.FC = () => { const [collapsed, setCollapsed] = useState(true); @@ -15,6 +17,9 @@ const CrewListTable: React.FC = () => { null ); const { t } = useI18n(); + const { colorScheme } = useAppTheme(); + const { colors } = useThemeContext(); + const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]); const { trip } = useTrip(); @@ -60,7 +65,7 @@ const CrewListTable: React.FC = () => { diff --git a/components/tripInfo/FishingToolsList.tsx b/components/tripInfo/FishingToolsList.tsx index ed283dd..e36cd6c 100644 --- a/components/tripInfo/FishingToolsList.tsx +++ b/components/tripInfo/FishingToolsList.tsx @@ -1,15 +1,20 @@ import { IconSymbol } from "@/components/ui/icon-symbol"; import { useI18n } from "@/hooks/use-i18n"; import { useTrip } from "@/state/use-trip"; -import React, { useRef, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { Animated, Text, TouchableOpacity, View } from "react-native"; -import styles from "./style/FishingToolsTable.styles"; +import { useAppTheme } from "@/hooks/use-app-theme"; +import { useThemeContext } from "@/hooks/use-theme-context"; +import { createTableStyles } from "./ThemedTable"; const FishingToolsTable: React.FC = () => { const [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 ?? []; @@ -38,7 +43,7 @@ const FishingToolsTable: React.FC = () => { diff --git a/components/tripInfo/NetListTable.tsx b/components/tripInfo/NetListTable.tsx index 9cd6b70..677537c 100644 --- a/components/tripInfo/NetListTable.tsx +++ b/components/tripInfo/NetListTable.tsx @@ -2,10 +2,12 @@ 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, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Animated, Text, TouchableOpacity, View } from "react-native"; import CreateOrUpdateHaulModal from "./modal/CreateOrUpdateHaulModal"; -import styles from "./style/NetListTable.styles"; +import { useAppTheme } from "@/hooks/use-app-theme"; +import { useThemeContext } from "@/hooks/use-theme-context"; +import { createTableStyles } from "./ThemedTable"; const NetListTable: React.FC = () => { const [collapsed, setCollapsed] = useState(true); @@ -14,17 +16,16 @@ const NetListTable: React.FC = () => { 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(); }, []); - // useEffect(() => { - // console.log("Trip thay đổi: ", trip?.fishing_logs?.length); - // }, [trip]); - // const data: Model.FishingLog[] = trip?.fishing_logs ?? []; - const handleToggle = () => { const toValue = collapsed ? contentHeight : 0; Animated.timing(animatedHeight, { @@ -60,7 +61,7 @@ const NetListTable: React.FC = () => { 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 index 41b2eb4..6e6eb83 100644 --- a/components/tripInfo/TripCostTable.tsx +++ b/components/tripInfo/TripCostTable.tsx @@ -1,14 +1,12 @@ 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 React, { useRef, useState } from "react"; -import { Animated, Text, TouchableOpacity, View } from "react-native"; +import { createTableStyles } from "./style/createTableStyles"; import TripCostDetailModal from "./modal/TripCostDetailModal"; -import styles from "./style/TripCostTable.styles"; - -// --------------------------- -// 💰 Component chính -// --------------------------- +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); @@ -16,9 +14,13 @@ const TripCostTable: React.FC = () => { 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); @@ -54,19 +56,14 @@ const TripCostTable: React.FC = () => { > {t("trip.costTable.title")} {collapsed && ( - + {tongCong.toLocaleString()} )} diff --git a/components/tripInfo/style/CrewListTable.styles.ts b/components/tripInfo/style/CrewListTable.styles.ts deleted file mode 100644 index 725fbc0..0000000 --- a/components/tripInfo/style/CrewListTable.styles.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { StyleSheet } from "react-native"; - -export default StyleSheet.create({ - container: { - width: "100%", - backgroundColor: "#fff", - borderRadius: 12, - padding: 16, - marginVertical: 10, - borderWidth: 1, - borderColor: "#fff", - shadowColor: "#000", - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - headerRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - title: { - fontSize: 18, - fontWeight: "700", - }, - totalCollapsed: { - color: "#ff6600", - fontSize: 18, - fontWeight: "700", - textAlign: "center", - }, - row: { - flexDirection: "row", - justifyContent: "space-between", - paddingVertical: 8, - borderBottomWidth: 0.5, - borderBottomColor: "#eee", - }, - tableHeader: { - backgroundColor: "#fafafa", - borderRadius: 6, - marginTop: 10, - }, - cell: { - flex: 1, - fontSize: 15, - color: "#111", - textAlign: "center", - }, - cellWrapper: { - flex: 1, - justifyContent: "center", - alignItems: "center", - }, - right: { - textAlign: "center", - }, - headerText: { - fontWeight: "600", - }, - footerText: { - color: "#007bff", - fontWeight: "600", - }, - footerTotal: { - color: "#ff6600", - fontWeight: "800", - }, - linkText: { - color: "#007AFF", - textDecorationLine: "underline", - }, -}); diff --git a/components/tripInfo/style/FishingToolsTable.styles.ts b/components/tripInfo/style/FishingToolsTable.styles.ts deleted file mode 100644 index e720ae5..0000000 --- a/components/tripInfo/style/FishingToolsTable.styles.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { StyleSheet } from "react-native"; - -export default StyleSheet.create({ - container: { - width: "100%", - backgroundColor: "#fff", - borderRadius: 12, - padding: 16, - marginVertical: 10, - borderWidth: 1, - borderColor: "#fff", - shadowColor: "#000", - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - headerRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - title: { - fontSize: 18, - fontWeight: "700", - }, - totalCollapsed: { - color: "#ff6600", - fontSize: 18, - fontWeight: "700", - textAlign: "center", - }, - row: { - flexDirection: "row", - paddingVertical: 8, - borderBottomWidth: 0.5, - borderBottomColor: "#eee", - paddingLeft: 15, - }, - cell: { - flex: 1, - fontSize: 15, - color: "#111", - }, - left: { - textAlign: "left", - }, - right: { - textAlign: "center", - }, - tableHeader: { - backgroundColor: "#fafafa", - borderRadius: 6, - marginTop: 10, - }, - headerText: { - fontWeight: "600", - }, - footerText: { - color: "#007bff", - fontWeight: "600", - }, - footerTotal: { - color: "#ff6600", - fontWeight: "800", - }, -}); diff --git a/components/tripInfo/style/NetListTable.styles.ts b/components/tripInfo/style/NetListTable.styles.ts deleted file mode 100644 index 88b2911..0000000 --- a/components/tripInfo/style/NetListTable.styles.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { StyleSheet } from "react-native"; - -export default StyleSheet.create({ - container: { - width: "100%", - backgroundColor: "#fff", - borderRadius: 12, - padding: 16, - marginVertical: 10, - borderWidth: 1, - borderColor: "#eee", - shadowColor: "#000", - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 1, - }, - headerRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - title: { - fontSize: 18, - fontWeight: "700", - }, - totalCollapsed: { - color: "#ff6600", - fontSize: 18, - fontWeight: "700", - textAlign: "center", - }, - row: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 8, - borderBottomWidth: 0.5, - borderBottomColor: "#eee", - }, - tableHeader: { - backgroundColor: "#fafafa", - borderRadius: 6, - marginTop: 10, - }, - cell: { - flex: 1, - fontSize: 15, - color: "#111", - textAlign: "center", - }, - sttCell: { - flex: 0.3, - fontSize: 15, - color: "#111", - textAlign: "center", - paddingLeft: 10, - }, - headerText: { - fontWeight: "600", - }, - statusContainer: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - }, - statusDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: "#2ecc71", - marginRight: 6, - }, - statusText: { - fontSize: 15, - color: "#4a90e2", - textDecorationLine: "underline", - }, -}); diff --git a/components/tripInfo/style/TripCostTable.styles.ts b/components/tripInfo/style/TripCostTable.styles.ts deleted file mode 100644 index 914ec10..0000000 --- a/components/tripInfo/style/TripCostTable.styles.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { StyleSheet } from "react-native"; - -const styles = StyleSheet.create({ - container: { - width: "100%", - margin: 16, - padding: 16, - borderRadius: 12, - backgroundColor: "#fff", - shadowColor: "#000", - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - title: { - fontSize: 18, - fontWeight: "700", - textAlign: "center", - }, - row: { - flexDirection: "row", - borderBottomWidth: 0.5, - borderColor: "#ddd", - paddingVertical: 8, - paddingLeft: 15, - }, - cell: { - flex: 1, - textAlign: "center", - fontSize: 15, - }, - left: { - textAlign: "left", - }, - right: { - color: "#ff6600", - fontWeight: "600", - }, - header: { - backgroundColor: "#f8f8f8", - borderTopWidth: 1, - borderBottomWidth: 1, - marginTop: 10, - }, - headerText: { - fontWeight: "600", - }, - footer: { - marginTop: 6, - }, - footerText: { - fontWeight: "600", - color: "#007bff", - }, - total: { - color: "#ff6600", - fontWeight: "700", - }, - viewDetailButton: { - marginTop: 12, - paddingVertical: 8, - alignItems: "center", - }, - viewDetailText: { - color: "#007AFF", - fontSize: 15, - fontWeight: "600", - textDecorationLine: "underline", - }, -}); - -export default styles; diff --git a/components/tripInfo/style/createTableStyles.ts b/components/tripInfo/style/createTableStyles.ts new file mode 100644 index 0000000..8f2c8eb --- /dev/null +++ b/components/tripInfo/style/createTableStyles.ts @@ -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; diff --git a/constants/theme.ts b/constants/theme.ts index f06facd..7c596ac 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -3,51 +3,82 @@ * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. */ -import { Platform } from 'react-native'; +import { Platform } from "react-native"; -const tintColorLight = '#0a7ea4'; -const tintColorDark = '#fff'; +const tintColorLight = "#0a7ea4"; +const tintColorDark = "#fff"; export const Colors = { light: { - text: '#11181C', - background: '#fff', + text: "#11181C", + textSecondary: "#687076", + background: "#fff", + backgroundSecondary: "#f5f5f5", + surface: "#ffffff", + surfaceSecondary: "#f8f9fa", tint: tintColorLight, - icon: '#687076', - tabIconDefault: '#687076', + primary: "#007AFF", + secondary: "#5AC8FA", + success: "#34C759", + warning: "#ff6600", + error: "#FF3B30", + icon: "#687076", + iconSecondary: "#8E8E93", + border: "#C6C6C8", + separator: "#E5E5E7", + tabIconDefault: "#687076", tabIconSelected: tintColorLight, + card: "#ffffff", + notification: "#FF3B30", }, dark: { - text: '#ECEDEE', - background: '#151718', + text: "#ECEDEE", + textSecondary: "#8E8E93", + background: "#000000", + backgroundSecondary: "#1C1C1E", + surface: "#1C1C1E", + surfaceSecondary: "#2C2C2E", tint: tintColorDark, - icon: '#9BA1A6', - tabIconDefault: '#9BA1A6', + primary: "#0A84FF", + secondary: "#64D2FF", + success: "#30D158", + warning: "#ff6600", + error: "#FF453A", + icon: "#8E8E93", + iconSecondary: "#636366", + border: "#38383A", + separator: "#38383A", + tabIconDefault: "#8E8E93", tabIconSelected: tintColorDark, + card: "#1C1C1E", + notification: "#FF453A", }, }; +export type ColorName = keyof typeof Colors.light; + export const Fonts = Platform.select({ ios: { /** iOS `UIFontDescriptorSystemDesignDefault` */ - sans: 'system-ui', + sans: "system-ui", /** iOS `UIFontDescriptorSystemDesignSerif` */ - serif: 'ui-serif', + serif: "ui-serif", /** iOS `UIFontDescriptorSystemDesignRounded` */ - rounded: 'ui-rounded', + rounded: "ui-rounded", /** iOS `UIFontDescriptorSystemDesignMonospaced` */ - mono: 'ui-monospace', + mono: "ui-monospace", }, default: { - sans: 'normal', - serif: 'serif', - rounded: 'normal', - mono: 'monospace', + sans: "normal", + serif: "serif", + rounded: "normal", + mono: "monospace", }, web: { sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", serif: "Georgia, 'Times New Roman', serif", - rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif", + rounded: + "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif", mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", }, }); diff --git a/hooks/use-app-theme.ts b/hooks/use-app-theme.ts new file mode 100644 index 0000000..a718237 --- /dev/null +++ b/hooks/use-app-theme.ts @@ -0,0 +1,163 @@ +/** + * Custom hook for easy theme access throughout the app + * Provides styled components and theme utilities + */ + +import { useMemo } from "react"; +import { StyleSheet, TextStyle, ViewStyle } from "react-native"; +import { useThemeContext } from "@/hooks/use-theme-context"; + +export function useAppTheme() { + const { colors, colorScheme, themeMode, setThemeMode, getColor } = + useThemeContext(); + + // Common styled components + const styles = useMemo( + () => + StyleSheet.create({ + // Container styles + container: { + flex: 1, + backgroundColor: colors.background, + } as ViewStyle, + + surface: { + backgroundColor: colors.surface, + borderRadius: 12, + padding: 16, + } as ViewStyle, + + card: { + backgroundColor: colors.card, + borderRadius: 12, + padding: 16, + shadowColor: colors.text, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + } as ViewStyle, + + // Button styles + primaryButton: { + backgroundColor: colors.primary, + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + } as ViewStyle, + + secondaryButton: { + backgroundColor: colors.backgroundSecondary, + borderWidth: 1, + borderColor: colors.border, + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + } as ViewStyle, + + // Text styles + primaryButtonText: { + color: "#ffffff", + fontSize: 16, + fontWeight: "600", + } as TextStyle, + + secondaryButtonText: { + color: colors.text, + fontSize: 16, + fontWeight: "600", + } as TextStyle, + + // Input styles + textInput: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 16, + fontSize: 16, + color: colors.text, + } as ViewStyle & TextStyle, + + // Separator + separator: { + height: 1, + backgroundColor: colors.separator, + } as ViewStyle, + + // Status styles + successContainer: { + backgroundColor: `${colors.success}20`, + borderColor: colors.success, + borderWidth: 1, + borderRadius: 8, + padding: 12, + } as ViewStyle, + + warningContainer: { + backgroundColor: `${colors.warning}20`, + borderColor: colors.warning, + borderWidth: 1, + borderRadius: 8, + padding: 12, + } as ViewStyle, + + errorContainer: { + backgroundColor: `${colors.error}20`, + borderColor: colors.error, + borderWidth: 1, + borderRadius: 8, + padding: 12, + } as ViewStyle, + }), + [colors] + ); + + // Theme utilities + const utils = useMemo( + () => ({ + // Get opacity color + getOpacityColor: ( + colorName: keyof typeof colors, + opacity: number = 0.1 + ) => { + const color = colors[colorName]; + const hex = color.replace("#", ""); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; + }, + + // Check if current theme is dark + isDark: colorScheme === "dark", + + // Check if current theme is light + isLight: colorScheme === "light", + + // Toggle between light and dark (ignoring system) + toggleTheme: () => { + const newMode = colorScheme === "dark" ? "light" : "dark"; + setThemeMode(newMode); + }, + }), + [colors, colorScheme, setThemeMode] + ); + + return { + colors, + styles, + utils, + colorScheme, + themeMode, + setThemeMode, + getColor, + }; +} + +export type AppTheme = ReturnType; diff --git a/hooks/use-theme-color.ts b/hooks/use-theme-color.ts index 0cbc3a6..ea9e71d 100644 --- a/hooks/use-theme-color.ts +++ b/hooks/use-theme-color.ts @@ -3,19 +3,19 @@ * https://docs.expo.dev/guides/color-schemes/ */ -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; +import { ColorName } from "@/constants/theme"; +import { useThemeContext } from "@/hooks/use-theme-context"; export function useThemeColor( props: { light?: string; dark?: string }, - colorName: keyof typeof Colors.light & keyof typeof Colors.dark + colorName: ColorName ) { - const theme = useColorScheme() ?? 'light'; - const colorFromProps = props[theme]; + const { colorScheme, getColor } = useThemeContext(); + const colorFromProps = props[colorScheme]; if (colorFromProps) { return colorFromProps; } else { - return Colors[theme][colorName]; + return getColor(colorName); } } diff --git a/hooks/use-theme-context.tsx b/hooks/use-theme-context.tsx new file mode 100644 index 0000000..2b65ee5 --- /dev/null +++ b/hooks/use-theme-context.tsx @@ -0,0 +1,131 @@ +/** + * Theme Context Hook for managing app-wide theme state + * Supports Light, Dark, and System (automatic) modes + * + * IMPORTANT: Requires expo-system-ui plugin in app.json for Android support + */ + +import { Colors, ColorName } from "@/constants/theme"; +import { getStorageItem, setStorageItem } from "@/utils/storage"; +import { + createContext, + useContext, + useEffect, + useState, + ReactNode, +} from "react"; +import { useColorScheme as useSystemColorScheme } from "react-native"; + +type ThemeMode = "light" | "dark" | "system"; +type ColorScheme = "light" | "dark"; + +interface ThemeContextType { + themeMode: ThemeMode; + colorScheme: ColorScheme; + colors: typeof Colors.light; + setThemeMode: (mode: ThemeMode) => Promise; + getColor: (colorName: ColorName) => string; +} + +const ThemeContext = createContext(undefined); + +const THEME_STORAGE_KEY = "theme_mode"; + +export function ThemeProvider({ children }: { children: ReactNode }) { + // Dùng useColorScheme từ react-native để detect theme thiết bị + const deviceColorScheme = useSystemColorScheme(); // "light" | "dark" | null | undefined + + // State lưu user's choice (light/dark/system) + const [themeMode, setThemeModeState] = useState("system"); + const [isLoaded, setIsLoaded] = useState(false); + + // Xác định colorScheme cuối cùng + const colorScheme: ColorScheme = + themeMode === "system" + ? deviceColorScheme === "dark" + ? "dark" + : "light" + : themeMode; + + const colors = Colors[colorScheme]; + + useEffect(() => { + const loadThemeMode = async () => { + try { + const savedThemeMode = await getStorageItem(THEME_STORAGE_KEY); + if ( + savedThemeMode && + ["light", "dark", "system"].includes(savedThemeMode) + ) { + setThemeModeState(savedThemeMode as ThemeMode); + } + } catch (error) { + console.warn("Failed to load theme mode:", error); + } finally { + setIsLoaded(true); + } + }; + loadThemeMode(); + }, []); + + const setThemeMode = async (mode: ThemeMode) => { + try { + setThemeModeState(mode); + await setStorageItem(THEME_STORAGE_KEY, mode); + console.log("[Theme] Changed to:", mode); + } catch (error) { + console.warn("Failed to save theme mode:", error); + } + }; + + const getColor = (colorName: ColorName): string => { + return colors[colorName] || colors.text; + }; + + // Chờ theme load xong trước khi render + if (!isLoaded) { + // Render với default theme (system) khi đang load + const defaultScheme: ColorScheme = + deviceColorScheme === "dark" ? "dark" : "light"; + return ( + {}, + getColor: (colorName: ColorName) => + Colors[defaultScheme][colorName] || Colors[defaultScheme].text, + }} + > + {children} + + ); + } + + const value: ThemeContextType = { + themeMode, + colorScheme, + colors, + setThemeMode, + getColor, + }; + + return ( + {children} + ); +} + +export function useThemeContext(): ThemeContextType { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error("useThemeContext must be used within a ThemeProvider"); + } + return context; +} + +// Legacy hook cho backward compatibility +export function useColorScheme(): ColorScheme { + const { colorScheme } = useThemeContext(); + return colorScheme; +} diff --git a/locales/en.json b/locales/en.json index 7575ae7..02afb06 100644 --- a/locales/en.json +++ b/locales/en.json @@ -18,7 +18,11 @@ "warning": "Warning", "language": "Language", "language_vi": "Vietnamese", - "language_en": "English" + "language_en": "English", + "theme": "Theme", + "theme_light": "Light", + "theme_dark": "Dark", + "theme_system": "System" }, "navigation": { "home": "Monitor", diff --git a/locales/vi.json b/locales/vi.json index 96d3a73..434b264 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -18,7 +18,11 @@ "warning": "Cảnh báo", "language": "Ngôn ngữ", "language_vi": "Tiếng Việt", - "language_en": "Tiếng Anh" + "language_en": "Tiếng Anh", + "theme": "Giao diện", + "theme_light": "Sáng", + "theme_dark": "Tối", + "theme_system": "Hệ thống" }, "navigation": { "home": "Giám sát", diff --git a/package.json b/package.json index 431a61e..6bf610b 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint" },