194 lines
5.2 KiB
TypeScript
194 lines
5.2 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|