update theme dark, light mode

This commit is contained in:
2025-11-17 17:01:42 +07:00
parent e725819c01
commit 862c4e42a4
25 changed files with 1274 additions and 442 deletions

234
THEME_GUIDE.md Normal file
View File

@@ -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<void>;
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 (
<ThemedView>
<ThemedText type="title">Title Text</ThemedText>
<ThemedText type="default">Regular Text</ThemedText>
</ThemedView>
);
}
```
### 2. Sử dụng Theme Hook
```tsx
import { useThemeContext } from "@/hooks/use-theme-context";
function MyComponent() {
const { colors, colorScheme, setThemeMode } = useThemeContext();
return (
<View style={{ backgroundColor: colors.background }}>
<Text style={{ color: colors.text }}>Current theme: {colorScheme}</Text>
</View>
);
}
```
### 3. Sử dụng App Theme Hook (Recommended)
```tsx
import { useAppTheme } from "@/hooks/use-app-theme";
function MyComponent() {
const { colors, styles, utils } = useAppTheme();
return (
<View style={styles.container}>
<TouchableOpacity style={styles.primaryButton}>
<Text style={styles.primaryButtonText}>Button</Text>
</TouchableOpacity>
<View
style={[
styles.surface,
{
backgroundColor: utils.getOpacityColor("primary", 0.1),
},
]}
>
<Text style={{ color: colors.text }}>
Theme is {utils.isDark ? "Dark" : "Light"}
</Text>
</View>
</View>
);
}
```
### 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 (
<View style={{ backgroundColor }}>
<Text style={{ color: textColor }}>Text</Text>
</View>
);
}
```
## 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 (
<View>
<ThemeToggle />
</View>
);
}
```
## 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.

View File

@@ -9,7 +9,16 @@
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"infoPlist": {
"CFBundleLocalizations": [
"en",
"vi",
"en",
"vi"
]
},
"bundleIdentifier": "com.minhnn86.sgwapp"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
@@ -19,7 +28,12 @@
"monochromeImage": "./assets/images/android-icon-monochrome.png" "monochromeImage": "./assets/images/android-icon-monochrome.png"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO"
],
"package": "com.minhnn86.sgwapp"
}, },
"web": { "web": {
"output": "static", "output": "static",
@@ -28,6 +42,7 @@
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
"expo-system-ui",
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
@@ -50,8 +65,14 @@
"expo-localization", "expo-localization",
{ {
"supportedLocales": { "supportedLocales": {
"ios": ["en", "vi"], "ios": [
"android": ["en", "vi"] "en",
"vi"
],
"android": [
"en",
"vi"
]
} }
} }
] ]

View File

@@ -16,6 +16,7 @@ import {
} from "@/constants"; } from "@/constants";
import { useColorScheme } from "@/hooks/use-color-scheme.web"; import { useColorScheme } from "@/hooks/use-color-scheme.web";
import { usePlatform } from "@/hooks/use-platform"; import { usePlatform } from "@/hooks/use-platform";
import { useThemeContext } from "@/hooks/use-theme-context";
import { import {
getAlarmEventBus, getAlarmEventBus,
getBanzonesEventBus, getBanzonesEventBus,
@@ -53,7 +54,7 @@ export default function HomeScreen() {
PolygonWithLabelProps[] PolygonWithLabelProps[]
>([]); >([]);
const platform = usePlatform(); const platform = usePlatform();
const theme = useColorScheme(); const theme = useThemeContext().colorScheme;
const scale = useRef(new Animated.Value(0)).current; const scale = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current; const opacity = useRef(new Animated.Value(1)).current;

View File

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

View File

@@ -5,19 +5,18 @@ import CrewListTable from "@/components/tripInfo/CrewListTable";
import FishingToolsTable from "@/components/tripInfo/FishingToolsList"; import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
import NetListTable from "@/components/tripInfo/NetListTable"; import NetListTable from "@/components/tripInfo/NetListTable";
import TripCostTable from "@/components/tripInfo/TripCostTable"; import TripCostTable from "@/components/tripInfo/TripCostTable";
import { useThemeContext } from "@/hooks/use-theme-context";
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
export default function TripInfoScreen() { export default function TripInfoScreen() {
// const { trip, getTrip } = useTrip(); const { colors } = useThemeContext();
// useEffect(() => {
// getTrip();
// }, []);
return ( return (
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}> <SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.titleText}>Thông Tin Chuyến Đi</Text> <Text style={[styles.titleText, { color: colors.text }]}>
Thông Tin Chuyến Đi
</Text>
<View style={styles.buttonWrapper}> <View style={styles.buttonWrapper}>
<ButtonCreateNewHaulOrTrip /> <ButtonCreateNewHaulOrTrip />
</View> </View>

View File

@@ -1,7 +1,7 @@
import { import {
DarkTheme, DarkTheme,
DefaultTheme, DefaultTheme,
ThemeProvider, ThemeProvider as NavigationThemeProvider,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
@@ -13,52 +13,61 @@ import { GluestackUIProvider } from "@/components/ui/gluestack-ui-provider/glues
import { toastConfig } from "@/config"; import { toastConfig } from "@/config";
import { setRouterInstance } from "@/config/auth"; import { setRouterInstance } from "@/config/auth";
import "@/global.css"; import "@/global.css";
import { useColorScheme } from "@/hooks/use-color-scheme"; import { ThemeProvider, useThemeContext } from "@/hooks/use-theme-context";
import { I18nProvider } from "@/hooks/use-i18n"; import { I18nProvider } from "@/hooks/use-i18n";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import "../global.css"; import "../global.css";
export default function RootLayout() { function AppContent() {
const colorScheme = useColorScheme(); const { colorScheme } = useThemeContext();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
setRouterInstance(router); setRouterInstance(router);
}, [router]); }, [router]);
return (
<GluestackUIProvider mode={colorScheme}>
<NavigationThemeProvider
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<Stack
screenOptions={{ headerShown: false }}
initialRouteName="auth/login"
>
<Stack.Screen
name="auth/login"
options={{
title: "Login",
headerShown: false,
}}
/>
<Stack.Screen
name="(tabs)"
options={{
title: "Home",
headerShown: false,
}}
/>
<Stack.Screen
name="modal"
options={{ presentation: "formSheet", title: "Modal" }}
/>
</Stack>
<StatusBar style="auto" />
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
</NavigationThemeProvider>
</GluestackUIProvider>
);
}
export default function RootLayout() {
return ( return (
<I18nProvider> <I18nProvider>
<GluestackUIProvider> <ThemeProvider>
<ThemeProvider <AppContent />
value={colorScheme === "dark" ? DarkTheme : DefaultTheme} </ThemeProvider>
>
<Stack
screenOptions={{ headerShown: false }}
initialRouteName="auth/login"
>
<Stack.Screen
name="auth/login"
options={{
title: "Login",
headerShown: false,
}}
/>
<Stack.Screen
name="(tabs)"
options={{
title: "Home",
headerShown: false,
}}
/>
<Stack.Screen
name="modal"
options={{ presentation: "formSheet", title: "Modal" }}
/>
</Stack>
<StatusBar style="auto" />
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
</ThemeProvider>
</GluestackUIProvider>
</I18nProvider> </I18nProvider>
); );
} }

View File

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

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

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

View File

@@ -1,10 +1,12 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useTrip } from "@/state/use-trip"; import { useTrip } from "@/state/use-trip";
import React, { useRef, useState } from "react"; import React, { useMemo, useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native"; import { Animated, Text, TouchableOpacity, View } from "react-native";
import CrewDetailModal from "./modal/CrewDetailModal"; import CrewDetailModal from "./modal/CrewDetailModal";
import styles from "./style/CrewListTable.styles"; import { useAppTheme } from "@/hooks/use-app-theme";
import { useThemeContext } from "@/hooks/use-theme-context";
import { createTableStyles } from "./ThemedTable";
const CrewListTable: React.FC = () => { const CrewListTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
@@ -15,6 +17,9 @@ const CrewListTable: React.FC = () => {
null null
); );
const { t } = useI18n(); const { t } = useI18n();
const { colorScheme } = useAppTheme();
const { colors } = useThemeContext();
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
const { trip } = useTrip(); const { trip } = useTrip();
@@ -60,7 +65,7 @@ const CrewListTable: React.FC = () => {
<IconSymbol <IconSymbol
name={collapsed ? "chevron.down" : "chevron.up"} name={collapsed ? "chevron.down" : "chevron.up"}
size={16} size={16}
color="#000" color={colors.icon}
/> />
</TouchableOpacity> </TouchableOpacity>

View File

@@ -1,15 +1,20 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useTrip } from "@/state/use-trip"; import { useTrip } from "@/state/use-trip";
import React, { useRef, useState } from "react"; import React, { useMemo, useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native"; import { Animated, Text, TouchableOpacity, View } from "react-native";
import styles from "./style/FishingToolsTable.styles"; import { useAppTheme } from "@/hooks/use-app-theme";
import { useThemeContext } from "@/hooks/use-theme-context";
import { createTableStyles } from "./ThemedTable";
const FishingToolsTable: React.FC = () => { const FishingToolsTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const [contentHeight, setContentHeight] = useState<number>(0); const [contentHeight, setContentHeight] = useState<number>(0);
const animatedHeight = useRef(new Animated.Value(0)).current; const animatedHeight = useRef(new Animated.Value(0)).current;
const { t } = useI18n(); const { t } = useI18n();
const { colorScheme } = useAppTheme();
const { colors } = useThemeContext();
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
const { trip } = useTrip(); const { trip } = useTrip();
const data: Model.FishingGear[] = trip?.fishing_gears ?? []; const data: Model.FishingGear[] = trip?.fishing_gears ?? [];
@@ -38,7 +43,7 @@ const FishingToolsTable: React.FC = () => {
<IconSymbol <IconSymbol
name={collapsed ? "chevron.down" : "chevron.up"} name={collapsed ? "chevron.down" : "chevron.up"}
size={16} size={16}
color="#000" color={colors.icon}
/> />
</TouchableOpacity> </TouchableOpacity>

View File

@@ -2,10 +2,12 @@ import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useFishes } from "@/state/use-fish"; import { useFishes } from "@/state/use-fish";
import { useTrip } from "@/state/use-trip"; import { useTrip } from "@/state/use-trip";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native"; import { Animated, Text, TouchableOpacity, View } from "react-native";
import CreateOrUpdateHaulModal from "./modal/CreateOrUpdateHaulModal"; import CreateOrUpdateHaulModal from "./modal/CreateOrUpdateHaulModal";
import styles from "./style/NetListTable.styles"; import { useAppTheme } from "@/hooks/use-app-theme";
import { useThemeContext } from "@/hooks/use-theme-context";
import { createTableStyles } from "./ThemedTable";
const NetListTable: React.FC = () => { const NetListTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
@@ -14,17 +16,16 @@ const NetListTable: React.FC = () => {
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [selectedNet, setSelectedNet] = useState<Model.FishingLog | null>(null); const [selectedNet, setSelectedNet] = useState<Model.FishingLog | null>(null);
const { t } = useI18n(); const { t } = useI18n();
const { colorScheme } = useAppTheme();
const { colors } = useThemeContext();
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
const { trip } = useTrip(); const { trip } = useTrip();
const { fishSpecies, getFishSpecies } = useFishes(); const { fishSpecies, getFishSpecies } = useFishes();
useEffect(() => { useEffect(() => {
getFishSpecies(); getFishSpecies();
}, []); }, []);
// useEffect(() => {
// console.log("Trip thay đổi: ", trip?.fishing_logs?.length);
// }, [trip]);
// const data: Model.FishingLog[] = trip?.fishing_logs ?? [];
const handleToggle = () => { const handleToggle = () => {
const toValue = collapsed ? contentHeight : 0; const toValue = collapsed ? contentHeight : 0;
Animated.timing(animatedHeight, { Animated.timing(animatedHeight, {
@@ -60,7 +61,7 @@ const NetListTable: React.FC = () => {
<IconSymbol <IconSymbol
name={collapsed ? "chevron.down" : "chevron.up"} name={collapsed ? "chevron.down" : "chevron.up"}
size={16} size={16}
color="#000" color={colors.icon}
/> />
</TouchableOpacity> </TouchableOpacity>

View File

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

View File

@@ -1,14 +1,12 @@
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useAppTheme } from "@/hooks/use-app-theme";
import { useTrip } from "@/state/use-trip"; import { useTrip } from "@/state/use-trip";
import React, { useRef, useState } from "react"; import { createTableStyles } from "./style/createTableStyles";
import { Animated, Text, TouchableOpacity, View } from "react-native";
import TripCostDetailModal from "./modal/TripCostDetailModal"; import TripCostDetailModal from "./modal/TripCostDetailModal";
import styles from "./style/TripCostTable.styles"; import React, { useRef, useState, useMemo } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native";
// --------------------------- import { useThemeContext } from "@/hooks/use-theme-context";
// 💰 Component chính
// ---------------------------
const TripCostTable: React.FC = () => { const TripCostTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
@@ -16,9 +14,13 @@ const TripCostTable: React.FC = () => {
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const animatedHeight = useRef(new Animated.Value(0)).current; const animatedHeight = useRef(new Animated.Value(0)).current;
const { t } = useI18n(); const { t } = useI18n();
const { colorScheme } = useAppTheme();
const { colors } = useThemeContext();
const { trip } = useTrip(); const { trip } = useTrip();
const styles = useMemo(() => createTableStyles(colorScheme), [colorScheme]);
const data: Model.TripCost[] = trip?.trip_cost ?? []; const data: Model.TripCost[] = trip?.trip_cost ?? [];
const tongCong = data.reduce((sum, item) => sum + item.total_cost, 0); const tongCong = data.reduce((sum, item) => sum + item.total_cost, 0);
@@ -54,19 +56,14 @@ const TripCostTable: React.FC = () => {
> >
<Text style={styles.title}>{t("trip.costTable.title")}</Text> <Text style={styles.title}>{t("trip.costTable.title")}</Text>
{collapsed && ( {collapsed && (
<Text <Text style={[styles.totalCollapsed]}>
style={[
styles.title,
{ color: "#ff6600", fontWeight: "bold", marginLeft: 8 },
]}
>
{tongCong.toLocaleString()} {tongCong.toLocaleString()}
</Text> </Text>
)} )}
<IconSymbol <IconSymbol
name={collapsed ? "chevron.down" : "chevron.up"} name={collapsed ? "chevron.down" : "chevron.up"}
size={15} size={15}
color="#000000" color={colors.icon}
/> />
</TouchableOpacity> </TouchableOpacity>

View File

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

View File

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

View File

@@ -1,78 +0,0 @@
import { StyleSheet } from "react-native";
export default StyleSheet.create({
container: {
width: "100%",
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginVertical: 10,
borderWidth: 1,
borderColor: "#eee",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 1,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
title: {
fontSize: 18,
fontWeight: "700",
},
totalCollapsed: {
color: "#ff6600",
fontSize: 18,
fontWeight: "700",
textAlign: "center",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 8,
borderBottomWidth: 0.5,
borderBottomColor: "#eee",
},
tableHeader: {
backgroundColor: "#fafafa",
borderRadius: 6,
marginTop: 10,
},
cell: {
flex: 1,
fontSize: 15,
color: "#111",
textAlign: "center",
},
sttCell: {
flex: 0.3,
fontSize: 15,
color: "#111",
textAlign: "center",
paddingLeft: 10,
},
headerText: {
fontWeight: "600",
},
statusContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: "#2ecc71",
marginRight: 6,
},
statusText: {
fontSize: 15,
color: "#4a90e2",
textDecorationLine: "underline",
},
});

View File

@@ -1,72 +0,0 @@
import { StyleSheet } from "react-native";
const styles = StyleSheet.create({
container: {
width: "100%",
margin: 16,
padding: 16,
borderRadius: 12,
backgroundColor: "#fff",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
title: {
fontSize: 18,
fontWeight: "700",
textAlign: "center",
},
row: {
flexDirection: "row",
borderBottomWidth: 0.5,
borderColor: "#ddd",
paddingVertical: 8,
paddingLeft: 15,
},
cell: {
flex: 1,
textAlign: "center",
fontSize: 15,
},
left: {
textAlign: "left",
},
right: {
color: "#ff6600",
fontWeight: "600",
},
header: {
backgroundColor: "#f8f8f8",
borderTopWidth: 1,
borderBottomWidth: 1,
marginTop: 10,
},
headerText: {
fontWeight: "600",
},
footer: {
marginTop: 6,
},
footerText: {
fontWeight: "600",
color: "#007bff",
},
total: {
color: "#ff6600",
fontWeight: "700",
},
viewDetailButton: {
marginTop: 12,
paddingVertical: 8,
alignItems: "center",
},
viewDetailText: {
color: "#007AFF",
fontSize: 15,
fontWeight: "600",
textDecorationLine: "underline",
},
});
export default styles;

View File

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

View File

@@ -3,51 +3,82 @@
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/ */
import { Platform } from 'react-native'; import { Platform } from "react-native";
const tintColorLight = '#0a7ea4'; const tintColorLight = "#0a7ea4";
const tintColorDark = '#fff'; const tintColorDark = "#fff";
export const Colors = { export const Colors = {
light: { light: {
text: '#11181C', text: "#11181C",
background: '#fff', textSecondary: "#687076",
background: "#fff",
backgroundSecondary: "#f5f5f5",
surface: "#ffffff",
surfaceSecondary: "#f8f9fa",
tint: tintColorLight, tint: tintColorLight,
icon: '#687076', primary: "#007AFF",
tabIconDefault: '#687076', secondary: "#5AC8FA",
success: "#34C759",
warning: "#ff6600",
error: "#FF3B30",
icon: "#687076",
iconSecondary: "#8E8E93",
border: "#C6C6C8",
separator: "#E5E5E7",
tabIconDefault: "#687076",
tabIconSelected: tintColorLight, tabIconSelected: tintColorLight,
card: "#ffffff",
notification: "#FF3B30",
}, },
dark: { dark: {
text: '#ECEDEE', text: "#ECEDEE",
background: '#151718', textSecondary: "#8E8E93",
background: "#000000",
backgroundSecondary: "#1C1C1E",
surface: "#1C1C1E",
surfaceSecondary: "#2C2C2E",
tint: tintColorDark, tint: tintColorDark,
icon: '#9BA1A6', primary: "#0A84FF",
tabIconDefault: '#9BA1A6', secondary: "#64D2FF",
success: "#30D158",
warning: "#ff6600",
error: "#FF453A",
icon: "#8E8E93",
iconSecondary: "#636366",
border: "#38383A",
separator: "#38383A",
tabIconDefault: "#8E8E93",
tabIconSelected: tintColorDark, tabIconSelected: tintColorDark,
card: "#1C1C1E",
notification: "#FF453A",
}, },
}; };
export type ColorName = keyof typeof Colors.light;
export const Fonts = Platform.select({ export const Fonts = Platform.select({
ios: { ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */ /** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui', sans: "system-ui",
/** iOS `UIFontDescriptorSystemDesignSerif` */ /** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif', serif: "ui-serif",
/** iOS `UIFontDescriptorSystemDesignRounded` */ /** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded', rounded: "ui-rounded",
/** iOS `UIFontDescriptorSystemDesignMonospaced` */ /** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace', mono: "ui-monospace",
}, },
default: { default: {
sans: 'normal', sans: "normal",
serif: 'serif', serif: "serif",
rounded: 'normal', rounded: "normal",
mono: 'monospace', mono: "monospace",
}, },
web: { web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif", serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif", rounded:
"'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
}, },
}); });

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

@@ -0,0 +1,163 @@
/**
* Custom hook for easy theme access throughout the app
* Provides styled components and theme utilities
*/
import { 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<typeof useAppTheme>;

View File

@@ -3,19 +3,19 @@
* https://docs.expo.dev/guides/color-schemes/ * https://docs.expo.dev/guides/color-schemes/
*/ */
import { Colors } from '@/constants/theme'; import { ColorName } from "@/constants/theme";
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useThemeContext } from "@/hooks/use-theme-context";
export function useThemeColor( export function useThemeColor(
props: { light?: string; dark?: string }, props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark colorName: ColorName
) { ) {
const theme = useColorScheme() ?? 'light'; const { colorScheme, getColor } = useThemeContext();
const colorFromProps = props[theme]; const colorFromProps = props[colorScheme];
if (colorFromProps) { if (colorFromProps) {
return colorFromProps; return colorFromProps;
} else { } else {
return Colors[theme][colorName]; return getColor(colorName);
} }
} }

131
hooks/use-theme-context.tsx Normal file
View File

@@ -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<void>;
getColor: (colorName: ColorName) => string;
}
const ThemeContext = createContext<ThemeContextType | undefined>(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<ThemeMode>("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 (
<ThemeContext.Provider
value={{
themeMode: "system",
colorScheme: defaultScheme,
colors: Colors[defaultScheme],
setThemeMode: async () => {},
getColor: (colorName: ColorName) =>
Colors[defaultScheme][colorName] || Colors[defaultScheme].text,
}}
>
{children}
</ThemeContext.Provider>
);
}
const value: ThemeContextType = {
themeMode,
colorScheme,
colors,
setThemeMode,
getColor,
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
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;
}

View File

@@ -18,7 +18,11 @@
"warning": "Warning", "warning": "Warning",
"language": "Language", "language": "Language",
"language_vi": "Vietnamese", "language_vi": "Vietnamese",
"language_en": "English" "language_en": "English",
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System"
}, },
"navigation": { "navigation": {
"home": "Monitor", "home": "Monitor",

View File

@@ -18,7 +18,11 @@
"warning": "Cảnh báo", "warning": "Cảnh báo",
"language": "Ngôn ngữ", "language": "Ngôn ngữ",
"language_vi": "Tiếng Việt", "language_vi": "Tiếng Việt",
"language_en": "Tiếng Anh" "language_en": "Tiếng Anh",
"theme": "Giao diện",
"theme_light": "Sáng",
"theme_dark": "Tối",
"theme_system": "Hệ thống"
}, },
"navigation": { "navigation": {
"home": "Giám sát", "home": "Giám sát",

View File

@@ -5,8 +5,8 @@
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint" "lint": "expo lint"
}, },