Khởi tạo ban đầu
This commit is contained in:
163
hooks/use-app-theme.ts
Normal file
163
hooks/use-app-theme.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Custom hook for easy theme access throughout the app
|
||||
* Provides styled components and theme utilities
|
||||
*/
|
||||
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { useMemo } from "react";
|
||||
import { StyleSheet, TextStyle, ViewStyle } from "react-native";
|
||||
|
||||
export function useAppTheme() {
|
||||
const { colors, colorScheme, themeMode, setThemeMode, getColor } =
|
||||
useThemeContext();
|
||||
|
||||
// Common styled components
|
||||
const styles = useMemo(
|
||||
() =>
|
||||
StyleSheet.create({
|
||||
// Container styles
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
} as ViewStyle,
|
||||
|
||||
surface: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
} as ViewStyle,
|
||||
|
||||
card: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
shadowColor: colors.text,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
} as ViewStyle,
|
||||
|
||||
// Button styles
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
} as ViewStyle,
|
||||
|
||||
secondaryButton: {
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
} as ViewStyle,
|
||||
|
||||
// Text styles
|
||||
primaryButtonText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
} as TextStyle,
|
||||
|
||||
secondaryButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
} as TextStyle,
|
||||
|
||||
// Input styles
|
||||
textInput: {
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
} as ViewStyle & TextStyle,
|
||||
|
||||
// Separator
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.separator,
|
||||
} as ViewStyle,
|
||||
|
||||
// Status styles
|
||||
successContainer: {
|
||||
backgroundColor: `${colors.success}20`,
|
||||
borderColor: colors.success,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
} as ViewStyle,
|
||||
|
||||
warningContainer: {
|
||||
backgroundColor: `${colors.warning}20`,
|
||||
borderColor: colors.warning,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
} as ViewStyle,
|
||||
|
||||
errorContainer: {
|
||||
backgroundColor: `${colors.error}20`,
|
||||
borderColor: colors.error,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
} as ViewStyle,
|
||||
}),
|
||||
[colors]
|
||||
);
|
||||
|
||||
// Theme utilities
|
||||
const utils = useMemo(
|
||||
() => ({
|
||||
// Get opacity color
|
||||
getOpacityColor: (
|
||||
colorName: keyof typeof colors,
|
||||
opacity: number = 0.1
|
||||
) => {
|
||||
const color = colors[colorName];
|
||||
const hex = color.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
},
|
||||
|
||||
// Check if current theme is dark
|
||||
isDark: colorScheme === "dark",
|
||||
|
||||
// Check if current theme is light
|
||||
isLight: colorScheme === "light",
|
||||
|
||||
// Toggle between light and dark (ignoring system)
|
||||
toggleTheme: () => {
|
||||
const newMode = colorScheme === "dark" ? "light" : "dark";
|
||||
setThemeMode(newMode);
|
||||
},
|
||||
}),
|
||||
[colors, colorScheme, setThemeMode]
|
||||
);
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles,
|
||||
utils,
|
||||
colorScheme,
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
getColor,
|
||||
};
|
||||
}
|
||||
|
||||
export type AppTheme = ReturnType<typeof useAppTheme>;
|
||||
1
hooks/use-color-scheme.ts
Normal file
1
hooks/use-color-scheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
21
hooks/use-color-scheme.web.ts
Normal file
21
hooks/use-color-scheme.web.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
62
hooks/use-fixed-circle-radius.ts
Normal file
62
hooks/use-fixed-circle-radius.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
/**
|
||||
* Hook để tính radius cố định cho Circle trên MapView
|
||||
* Radius sẽ được điều chỉnh dựa trên zoom level để giữ kích thước pixel cố định
|
||||
*/
|
||||
export const useFixedCircleRadius = (pixelRadius: number = 30) => {
|
||||
const [radius, setRadius] = useState(100); // Giá trị default
|
||||
|
||||
const calculateRadiusFromZoom = useCallback((zoomLevel: number) => {
|
||||
// Công thức: radius (meters) = pixelRadius * 156543.04 * cos(latitude) / 2^(zoomLevel + 8)
|
||||
// Đơn giản hơn: radius tỉ lệ với 2^(maxZoom - currentZoom)
|
||||
// Khi zoom = 14, dùng radius = 100 làm reference
|
||||
const baseZoom = 14;
|
||||
const baseRadius = 100;
|
||||
|
||||
// Mỗi level zoom tương ứng với 2x sự khác biệt
|
||||
const zoomDifference = baseZoom - zoomLevel;
|
||||
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
|
||||
|
||||
return Math.max(calculatedRadius, 10); // Minimum 10 meters
|
||||
}, []);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(zoomLevel: number) => {
|
||||
const newRadius = calculateRadiusFromZoom(zoomLevel);
|
||||
setRadius(newRadius);
|
||||
},
|
||||
[calculateRadiusFromZoom]
|
||||
);
|
||||
|
||||
return {
|
||||
radius,
|
||||
handleZoomChange,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Alternative: Sử dụng Polygon thay vì Circle để có kích thước cố định theo pixel
|
||||
* Tạo một hình tròn bằng Polygon với điểm tâm là coordinate
|
||||
*/
|
||||
export const createCircleCoordinates = (
|
||||
center: { latitude: number; longitude: number },
|
||||
radiusInMeters: number,
|
||||
points: number = 36
|
||||
) => {
|
||||
const coordinates = [];
|
||||
const latDelta = radiusInMeters / 111000; // 1 degree ~ 111km
|
||||
|
||||
for (let i = 0; i < points; i++) {
|
||||
const angle = (i / points) * (2 * Math.PI);
|
||||
const longitude =
|
||||
center.longitude +
|
||||
(latDelta * Math.cos(angle)) /
|
||||
Math.cos((center.latitude * Math.PI) / 180);
|
||||
const latitude = center.latitude + latDelta * Math.sin(angle);
|
||||
|
||||
coordinates.push({ latitude, longitude });
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
};
|
||||
119
hooks/use-i18n.ts
Normal file
119
hooks/use-i18n.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import i18n, { LOCALE_STORAGE_KEY } from "@/config/localization/i18n";
|
||||
import { getStorageItem, setStorageItem } from "@/utils/storage";
|
||||
import { getLocales } from "expo-localization";
|
||||
import {
|
||||
PropsWithChildren,
|
||||
createContext,
|
||||
createElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type SupportedLocale = "en" | "vi";
|
||||
|
||||
type I18nContextValue = {
|
||||
t: typeof i18n.t;
|
||||
locale: SupportedLocale;
|
||||
setLocale: (locale: SupportedLocale) => Promise<void>;
|
||||
isLoaded: boolean;
|
||||
};
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | undefined>(undefined);
|
||||
|
||||
const SUPPORTED_LOCALES: SupportedLocale[] = ["en", "vi"];
|
||||
|
||||
const resolveSupportedLocale = (
|
||||
locale: string | null | undefined
|
||||
): SupportedLocale => {
|
||||
if (!locale) {
|
||||
return "en";
|
||||
}
|
||||
|
||||
const normalized = locale.split("-")[0]?.toLowerCase() as SupportedLocale;
|
||||
if (normalized && SUPPORTED_LOCALES.includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return "en";
|
||||
};
|
||||
|
||||
export const I18nProvider = ({ children }: PropsWithChildren<unknown>) => {
|
||||
const [locale, setLocaleState] = useState<SupportedLocale>(
|
||||
resolveSupportedLocale(i18n.locale)
|
||||
);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLocale = async () => {
|
||||
try {
|
||||
const savedLocale = await getStorageItem(LOCALE_STORAGE_KEY);
|
||||
const deviceLocale = getLocales()[0]?.languageCode;
|
||||
const localeToUse = resolveSupportedLocale(savedLocale ?? deviceLocale);
|
||||
|
||||
if (localeToUse !== i18n.locale) {
|
||||
i18n.locale = localeToUse;
|
||||
}
|
||||
setLocaleState(localeToUse);
|
||||
} catch (error) {
|
||||
console.error("Error loading locale preference:", error);
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
void loadLocale();
|
||||
}, []);
|
||||
|
||||
const updateLocale = useCallback((nextLocale: SupportedLocale) => {
|
||||
if (i18n.locale !== nextLocale) {
|
||||
i18n.locale = nextLocale;
|
||||
}
|
||||
setLocaleState(nextLocale);
|
||||
}, []);
|
||||
|
||||
const setLocale = useCallback(
|
||||
async (nextLocale: SupportedLocale) => {
|
||||
if (!SUPPORTED_LOCALES.includes(nextLocale)) {
|
||||
console.warn(`Unsupported locale: ${nextLocale}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateLocale(nextLocale);
|
||||
await setStorageItem(LOCALE_STORAGE_KEY, nextLocale);
|
||||
} catch (error) {
|
||||
console.error("Error setting locale:", error);
|
||||
}
|
||||
},
|
||||
[updateLocale]
|
||||
);
|
||||
|
||||
const translate = useCallback(
|
||||
(...args: Parameters<typeof i18n.t>) => i18n.t(...args),
|
||||
[locale]
|
||||
);
|
||||
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
t: translate,
|
||||
locale,
|
||||
setLocale,
|
||||
isLoaded,
|
||||
}),
|
||||
[locale, setLocale, translate, isLoaded]
|
||||
);
|
||||
|
||||
return createElement(I18nContext.Provider, { value }, children);
|
||||
};
|
||||
|
||||
export const useI18n = () => {
|
||||
const context = useContext(I18nContext);
|
||||
if (!context) {
|
||||
throw new Error("useI18n must be used within an I18nProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
23
hooks/use-platform.ts
Normal file
23
hooks/use-platform.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export type PlatformType = "ios" | "android" | "web";
|
||||
|
||||
export const usePlatform = (): PlatformType => {
|
||||
return Platform.OS as PlatformType;
|
||||
};
|
||||
|
||||
export const useIsIOS = (): boolean => {
|
||||
return Platform.OS === "ios";
|
||||
};
|
||||
|
||||
export const useIsAndroid = (): boolean => {
|
||||
return Platform.OS === "android";
|
||||
};
|
||||
|
||||
export const useIsWeb = (): boolean => {
|
||||
return Platform.OS === "web";
|
||||
};
|
||||
|
||||
export const getPlatform = (): PlatformType => {
|
||||
return Platform.OS as PlatformType;
|
||||
};
|
||||
21
hooks/use-theme-color.ts
Normal file
21
hooks/use-theme-color.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { useColorScheme } from "@/hooks/use-theme-context";
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme();
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
193
hooks/use-theme-context.tsx
Normal file
193
hooks/use-theme-context.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Theme Context Hook for managing app-wide theme state.
|
||||
* Supports Light, Dark, and System (automatic) modes across Expo platforms.
|
||||
*
|
||||
* IMPORTANT: Requires expo-system-ui plugin in app.json for Android status bar support.
|
||||
*/
|
||||
|
||||
import { ColorName, Colors } from "@/constants/theme";
|
||||
import { getStorageItem, setStorageItem } from "@/utils/storage";
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Appearance,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
useColorScheme as useRNColorScheme,
|
||||
} from "react-native";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
export type ColorScheme = "light" | "dark";
|
||||
|
||||
interface ThemeContextType {
|
||||
themeMode: ThemeMode;
|
||||
colorScheme: ColorScheme;
|
||||
colors: typeof Colors.light;
|
||||
setThemeMode: (mode: ThemeMode) => Promise<void>;
|
||||
getColor: (colorName: ColorName) => string;
|
||||
isHydrated: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
const THEME_STORAGE_KEY = "theme_mode";
|
||||
|
||||
const getSystemScheme = (): ColorScheme => {
|
||||
const scheme = Appearance.getColorScheme();
|
||||
// console.log("[Theme] Appearance.getColorScheme():", scheme);
|
||||
return scheme === "dark" ? "dark" : "light";
|
||||
};
|
||||
|
||||
const isThemeMode = (value: unknown): value is ThemeMode => {
|
||||
return value === "light" || value === "dark" || value === "system";
|
||||
};
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [systemScheme, setSystemScheme] =
|
||||
useState<ColorScheme>(getSystemScheme);
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>("system");
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
const syncSystemScheme = useCallback(() => {
|
||||
const next = getSystemScheme();
|
||||
// console.log("[Theme] syncSystemScheme computed:", next);
|
||||
setSystemScheme((current) => (current === next ? current : next));
|
||||
}, []);
|
||||
|
||||
const rnScheme = useRNColorScheme();
|
||||
useEffect(() => {
|
||||
if (!rnScheme) return;
|
||||
const next = rnScheme === "dark" ? "dark" : "light";
|
||||
// console.log("[Theme] useColorScheme hook emitted:", rnScheme);
|
||||
setSystemScheme((current) => (current === next ? current : next));
|
||||
}, [rnScheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
|
||||
const next = colorScheme === "dark" ? "dark" : "light";
|
||||
// console.log("[Theme] Appearance listener fired with:", colorScheme);
|
||||
setSystemScheme((current) => (current === next ? current : next));
|
||||
});
|
||||
|
||||
syncSystemScheme();
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [syncSystemScheme]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log("[Theme] System scheme detected:", systemScheme);
|
||||
}, [systemScheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextState: AppStateStatus) => {
|
||||
if (nextState === "active") {
|
||||
// console.log("[Theme] AppState active → scheduling system scheme sync");
|
||||
setTimeout(() => {
|
||||
// console.log("[Theme] AppState sync callback running");
|
||||
syncSystemScheme();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
handleAppStateChange
|
||||
);
|
||||
return () => subscription.remove();
|
||||
}, [syncSystemScheme]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const hydrateThemeMode = async () => {
|
||||
try {
|
||||
const savedThemeMode = await getStorageItem(THEME_STORAGE_KEY);
|
||||
if (isMounted && isThemeMode(savedThemeMode)) {
|
||||
setThemeModeState(savedThemeMode);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Theme] Failed to load theme mode:", error);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsHydrated(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
hydrateThemeMode();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const colorScheme: ColorScheme =
|
||||
themeMode === "system" ? systemScheme : themeMode;
|
||||
|
||||
const colors = useMemo(() => Colors[colorScheme], [colorScheme]);
|
||||
|
||||
const setThemeMode = useCallback(async (mode: ThemeMode) => {
|
||||
setThemeModeState(mode);
|
||||
try {
|
||||
await setStorageItem(THEME_STORAGE_KEY, mode);
|
||||
} catch (error) {
|
||||
console.warn("[Theme] Failed to save theme mode:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log("[Theme] window defined:", typeof window !== "undefined");
|
||||
}, []);
|
||||
|
||||
const getColor = useCallback(
|
||||
(colorName: ColorName) => colors[colorName] ?? colors.text,
|
||||
[colors]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log("[Theme] Mode:", themeMode);
|
||||
}, [themeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log("[Theme] Derived colorScheme:", colorScheme);
|
||||
}, [colorScheme]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
themeMode,
|
||||
colorScheme,
|
||||
colors,
|
||||
setThemeMode,
|
||||
getColor,
|
||||
isHydrated,
|
||||
}),
|
||||
[themeMode, colorScheme, colors, setThemeMode, getColor, isHydrated]
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextType {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export const useThemeContext = useTheme;
|
||||
|
||||
export function useColorScheme(): ColorScheme {
|
||||
return useTheme().colorScheme;
|
||||
}
|
||||
Reference in New Issue
Block a user