mojmajstor/components/theme.tsx
2026-06-06 03:54:22 +02:00

270 lines
7.7 KiB
TypeScript

import { createContext, useContext, useState, useEffect, useCallback } from "react";
import { useColorScheme } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
type ThemeColors = {
primary: string;
primaryLight: string;
primaryDark: string;
primaryMuted: string;
secondary: string;
secondaryLight: string;
background: string;
surface: string;
surfaceAlt: string;
surfaceElevated: string;
card: string;
border: string;
borderLight: string;
text: string;
textSecondary: string;
textTertiary: string;
textInverse: string;
error: string;
errorLight: string;
success: string;
successLight: string;
star: string;
starLight: string;
tabActive: string;
tabInactive: string;
divider: string;
overlay: string;
shadow: string;
};
type Shadow = {
shadowColor: string;
shadowOffset: { width: number; height: number };
shadowOpacity: number;
shadowRadius: number;
elevation: number;
};
type ThemeShadows = {
xs: Shadow;
sm: Shadow;
md: Shadow;
lg: Shadow;
xl: Shadow;
};
type Typography = {
h1: { fontSize: number; fontWeight: "700" | "600" | "500" | "400"; lineHeight: number };
h2: { fontSize: number; fontWeight: "700" | "600" | "500" | "400"; lineHeight: number };
h3: { fontSize: number; fontWeight: "700" | "600" | "500" | "400"; lineHeight: number };
h4: { fontSize: number; fontWeight: "700" | "600" | "500" | "400"; lineHeight: number };
body: { fontSize: number; fontWeight: "700" | "600" | "500" | "400"; lineHeight: number };
bodyBold: { fontSize: number; fontWeight: "700" | "600" | "500" | "400"; lineHeight: number };
caption: { fontSize: number; fontWeight: "700" | "600" | "500" | "400"; lineHeight: number };
captionBold: { fontSize: number; fontWeight: "700" | "600" | "500" | "400"; lineHeight: number };
label: { fontSize: number; fontWeight: "700" | "600" | "500" | "400"; lineHeight: number };
};
type Theme = {
isDarkMode: boolean;
colors: ThemeColors;
spacing: {
xs: number;
sm: number;
md: number;
lg: number;
xl: number;
xxl: number;
};
radius: {
xs: number;
sm: number;
md: number;
lg: number;
xl: number;
full: number;
};
shadows: ThemeShadows;
typography: Typography;
};
const makeShadow = (elevation: number, color: string): Shadow => {
const shadows: Record<number, Shadow> = {
1: { shadowColor: color, shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 2, elevation: 1 },
2: { shadowColor: color, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 6, elevation: 2 },
4: { shadowColor: color, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.08, shadowRadius: 12, elevation: 4 },
8: { shadowColor: color, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.1, shadowRadius: 20, elevation: 8 },
16: { shadowColor: color, shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.12, shadowRadius: 32, elevation: 16 },
};
return shadows[elevation] || shadows[2];
};
const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48 };
const radius = { xs: 4, sm: 10, md: 14, lg: 20, xl: 28, full: 9999 };
const typography: Typography = {
h1: { fontSize: 34, fontWeight: "700", lineHeight: 42 },
h2: { fontSize: 26, fontWeight: "700", lineHeight: 34 },
h3: { fontSize: 20, fontWeight: "600", lineHeight: 28 },
h4: { fontSize: 17, fontWeight: "600", lineHeight: 24 },
body: { fontSize: 15, fontWeight: "400", lineHeight: 22 },
bodyBold: { fontSize: 15, fontWeight: "600", lineHeight: 22 },
caption: { fontSize: 13, fontWeight: "400", lineHeight: 18 },
captionBold: { fontSize: 13, fontWeight: "600", lineHeight: 18 },
label: { fontSize: 11, fontWeight: "500", lineHeight: 14 },
};
const shadowColorLight = "rgba(26, 42, 58, 0.12)";
const shadowColorDark = "rgba(0, 0, 0, 0.35)";
const LightTheme: Theme = {
isDarkMode: false,
colors: {
primary: "#D4764A",
primaryLight: "#FBF0EB",
primaryDark: "#B85E34",
primaryMuted: "#E8CFC3",
secondary: "#1A2A3A",
secondaryLight: "#E8ECF0",
background: "#FDF8F3",
surface: "#FFFFFF",
surfaceAlt: "#FAF6F2",
surfaceElevated: "#FFFFFF",
card: "#FFFFFF",
border: "#EDE8E3",
borderLight: "#F3EFEB",
text: "#1A1A1A",
textSecondary: "#6B6560",
textTertiary: "#9E9A95",
textInverse: "#FFFFFF",
error: "#C23A3A",
errorLight: "#FEECEC",
success: "#2E8B57",
successLight: "#E8F5EC",
star: "#D4953A",
starLight: "#FFF5E1",
tabActive: "#D4764A",
tabInactive: "#9E9A95",
divider: "#F0EBE6",
overlay: "rgba(26, 42, 58, 0.45)",
shadow: shadowColorLight,
},
spacing,
radius,
shadows: {
xs: makeShadow(1, shadowColorLight),
sm: makeShadow(2, shadowColorLight),
md: makeShadow(4, shadowColorLight),
lg: makeShadow(8, shadowColorLight),
xl: makeShadow(16, shadowColorLight),
},
typography,
};
const DarkTheme: Theme = {
isDarkMode: true,
colors: {
primary: "#E8956F",
primaryLight: "#2D2018",
primaryDark: "#E8956F",
primaryMuted: "#4A3A2E",
secondary: "#E8ECF0",
secondaryLight: "#2A3040",
background: "#121212",
surface: "#1E1E1E",
surfaceAlt: "#252525",
surfaceElevated: "#2A2A2A",
card: "#1E1E1E",
border: "#333333",
borderLight: "#2A2A2A",
text: "#F5F5F5",
textSecondary: "#AAAAAA",
textTertiary: "#777777",
textInverse: "#1A1A1A",
error: "#EF5350",
errorLight: "#2A1A1A",
success: "#66BB6A",
successLight: "#1A2A1E",
star: "#FFB74D",
starLight: "#2A2510",
tabActive: "#E8956F",
tabInactive: "#777777",
divider: "#2A2A2A",
overlay: "rgba(0,0,0,0.7)",
shadow: shadowColorDark,
},
spacing,
radius,
shadows: {
xs: makeShadow(1, shadowColorDark),
sm: makeShadow(2, shadowColorDark),
md: makeShadow(4, shadowColorDark),
lg: makeShadow(8, shadowColorDark),
xl: makeShadow(16, shadowColorDark),
},
typography,
};
export type { Theme, ThemeColors, ThemeShadows, Typography };
export { LightTheme, DarkTheme };
const THEME_KEY = "mojmajstor_theme_preference";
const ThemeContext = createContext<Theme>(LightTheme);
export type ThemeContextType = {
theme: Theme;
isDarkMode: boolean;
toggleTheme: () => void;
setDarkMode: (dark: boolean) => void;
};
const ThemeCtx = createContext<ThemeContextType>({
theme: LightTheme,
isDarkMode: false,
toggleTheme: () => {},
setDarkMode: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemScheme = useColorScheme();
const [preference, setPreference] = useState<"system" | "light" | "dark">("system");
const [loaded, setLoaded] = useState(false);
useEffect(() => {
AsyncStorage.getItem(THEME_KEY).then((val) => {
if (val === "light" || val === "dark" || val === "system") {
setPreference(val);
}
setLoaded(true);
});
}, []);
const isDarkMode = preference === "dark" || (preference === "system" && systemScheme === "dark");
const theme = isDarkMode ? DarkTheme : LightTheme;
const setDarkMode = useCallback((dark: boolean) => {
const val = dark ? "dark" : "light";
setPreference(val);
AsyncStorage.setItem(THEME_KEY, val);
}, []);
const toggleTheme = useCallback(() => {
setDarkMode(!isDarkMode);
}, [isDarkMode, setDarkMode]);
if (!loaded) {
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}
return (
<ThemeCtx.Provider value={{ theme, isDarkMode, toggleTheme, setDarkMode }}>
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
</ThemeCtx.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
export function useThemeToggle() {
return useContext(ThemeCtx);
}