Live video on the AT Protocol
1import { PortalHost } from "@rn-primitives/portal";
2import {
3 createContext,
4 useContext,
5 useMemo,
6 useState,
7 type ReactNode,
8} from "react";
9import { Platform, useColorScheme } from "react-native";
10import {
11 animations,
12 borderRadius,
13 colors,
14 shadows,
15 spacing,
16 touchTargets,
17 typography,
18} from "./tokens";
19
20import { GestureHandlerRootView } from "react-native-gesture-handler";
21import { ToastProvider } from "../../components/ui";
22
23// Import pairify function for generating theme tokens
24function pairify<T extends Record<string, any>>(
25 obj: T,
26 styleKeyPrefix: string,
27): Record<keyof T, any> {
28 const result: Record<string, any> = {};
29 for (const [key, value] of Object.entries(obj)) {
30 if (typeof value === "object" && value !== null && !Array.isArray(value)) {
31 // For nested objects (like color scales), create another level
32 result[key] = {};
33 for (const [nestedKey, nestedValue] of Object.entries(value)) {
34 result[key][nestedKey] = { [styleKeyPrefix]: nestedValue };
35 }
36 } else {
37 // For simple values, create the style object directly
38 result[key] = { [styleKeyPrefix]: value };
39 }
40 }
41 return result as Record<keyof T, any>;
42}
43
44// Theme interfaces
45export interface Theme {
46 colors: {
47 // Core semantic colors
48 background: string;
49 foreground: string;
50
51 // Card/surface colors
52 card: string;
53 cardForeground: string;
54
55 // Popover colors
56 popover: string;
57 popoverForeground: string;
58
59 // Primary colors
60 primary: string;
61 primaryForeground: string;
62
63 // Secondary colors
64 secondary: string;
65 secondaryForeground: string;
66
67 // Muted colors
68 muted: string;
69 mutedForeground: string;
70
71 // Accent colors
72 accent: string;
73 accentForeground: string;
74
75 // Destructive colors
76 destructive: string;
77 destructiveForeground: string;
78
79 // Success colors
80 success: string;
81 successForeground: string;
82
83 // Warning colors
84 warning: string;
85 warningForeground: string;
86
87 // Border and input colors
88 border: string;
89 input: string;
90 ring: string;
91
92 // Text colors
93 text: string;
94 textMuted: string;
95 textDisabled: string;
96 };
97 spacing: typeof spacing;
98 borderRadius: typeof borderRadius;
99 typography: typeof typography;
100 shadows: typeof shadows;
101 touchTargets: typeof touchTargets;
102 animations: typeof animations;
103}
104
105// Theme-aware zero interface (like atoms but with theme colors)
106export interface ThemeZero {
107 // Colors using pairify
108 bg: Record<string, any>;
109 text: Record<string, any>;
110 border: Record<string, any>;
111
112 // Static design tokens (same as atoms)
113 shadow: {
114 sm: typeof shadows.sm;
115 md: typeof shadows.md;
116 lg: typeof shadows.lg;
117 xl: typeof shadows.xl;
118 };
119
120 // Common button styles
121 button: {
122 primary: object;
123 secondary: object;
124 outline: object;
125 ghost: object;
126 };
127
128 // Input styles
129 input: {
130 base: object;
131 focused: object;
132 error: object;
133 };
134
135 // Card styles
136 card: {
137 base: object;
138 };
139}
140
141// Icon utilities interface
142export interface ThemeIcons {
143 color: {
144 default: string;
145 muted: string;
146 primary: string;
147 secondary: string;
148 destructive: string;
149 success: string;
150 warning: string;
151 };
152 size: {
153 sm: number;
154 md: number;
155 lg: number;
156 xl: number;
157 };
158}
159
160// Create theme colors based on dark mode
161const createThemeColors = (
162 isDark: boolean,
163 lightTheme?: ColorPalette | Theme["colors"],
164 darkTheme?: ColorPalette | Theme["colors"],
165 colorTheme?: Partial<Theme["colors"]>,
166): Theme["colors"] => {
167 let baseColors: Theme["colors"];
168
169 if (isDark && darkTheme) {
170 // Use dark theme
171 baseColors = isColorPalette(darkTheme)
172 ? generateThemeColorsFromPalette(darkTheme, true)
173 : darkTheme;
174 } else if (!isDark && lightTheme) {
175 // Use light theme
176 baseColors = isColorPalette(lightTheme)
177 ? generateThemeColorsFromPalette(lightTheme, false)
178 : lightTheme;
179 } else {
180 // Fall back to default gray theme
181 const defaultPalette = colors.neutral;
182 baseColors = generateThemeColorsFromPalette(defaultPalette, isDark);
183 }
184
185 // Merge with custom color overrides if provided
186 return {
187 ...baseColors,
188 ...colorTheme,
189 };
190};
191
192// Create theme-aware zero tokens using pairify
193const createThemeZero = (themeColors: Theme["colors"]): ThemeZero => ({
194 // Theme-aware colors using pairify
195 bg: pairify(themeColors, "backgroundColor"),
196 text: pairify(themeColors, "color"),
197 border: {
198 ...pairify(themeColors, "borderColor"),
199 default: { borderColor: themeColors.border },
200 },
201
202 // Static design tokens
203 shadow: {
204 sm: shadows.sm,
205 md: shadows.md,
206 lg: shadows.lg,
207 xl: shadows.xl,
208 },
209
210 // Common button styles
211 button: {
212 primary: {
213 backgroundColor: themeColors.primary,
214 borderWidth: 0,
215 ...shadows.sm,
216 },
217 secondary: {
218 backgroundColor: themeColors.secondary,
219 borderWidth: 0,
220 },
221 outline: {
222 backgroundColor: "transparent",
223 borderWidth: 1,
224 borderColor: themeColors.border,
225 },
226 ghost: {
227 backgroundColor: "transparent",
228 borderWidth: 0,
229 },
230 },
231
232 // Input styles
233 input: {
234 base: {
235 backgroundColor: themeColors.background,
236 borderWidth: 1,
237 borderColor: themeColors.border,
238 borderRadius: borderRadius.md,
239 paddingHorizontal: spacing[3],
240 paddingVertical: spacing[3],
241 minHeight: touchTargets.minimum,
242 },
243 focused: {
244 borderColor: themeColors.ring,
245 borderWidth: 2,
246 },
247 error: {
248 borderColor: themeColors.destructive,
249 borderWidth: 2,
250 },
251 },
252
253 // Card styles
254 card: {
255 base: {
256 backgroundColor: themeColors.card,
257 borderRadius: borderRadius.lg,
258 ...shadows.sm,
259 },
260 },
261});
262
263// Create theme icons based on colors
264const createThemeIcons = (themeColors: Theme["colors"]): ThemeIcons => ({
265 color: {
266 default: themeColors.text,
267 muted: themeColors.textMuted,
268 primary: themeColors.primary,
269 secondary: themeColors.secondary,
270 destructive: themeColors.destructive,
271 success: themeColors.success,
272 warning: themeColors.warning,
273 },
274 size: {
275 sm: 16,
276 md: 20,
277 lg: 24,
278 xl: 32,
279 },
280});
281
282// Theme context interface
283interface ThemeContextType {
284 theme: Theme;
285 zero: ThemeZero;
286 icons: ThemeIcons;
287 isDark: boolean;
288 currentTheme: "light" | "dark" | "system";
289 systemTheme: "light" | "dark";
290 setTheme: (theme: "light" | "dark" | "system") => void;
291 toggleTheme: () => void;
292}
293
294// Create the theme context
295const ThemeContext = createContext<ThemeContextType | null>(null);
296
297// Color palette type
298type ColorPalette = {
299 50: string;
300 100: string;
301 200: string;
302 300: string;
303 400: string;
304 500: string;
305 600: string;
306 700: string;
307 800: string;
308 900: string;
309 950: string;
310};
311
312// Helper function to check if input is a ColorPalette or Theme["colors"]
313function isColorPalette(
314 input: ColorPalette | Theme["colors"],
315): input is ColorPalette {
316 return "50" in input && "100" in input && "950" in input;
317}
318
319// Helper function to generate Theme["colors"] from ColorPalette
320function generateThemeColorsFromPalette(
321 palette: ColorPalette,
322 isDark: boolean,
323): Theme["colors"] {
324 return {
325 background: isDark ? palette[950] : colors.white,
326 foreground: isDark ? palette[50] : palette[950],
327
328 card: isDark ? palette[900] : colors.white,
329 cardForeground: isDark ? palette[50] : palette[950],
330
331 popover: isDark ? palette[900] : colors.white,
332 popoverForeground: isDark ? palette[50] : palette[950],
333
334 primary:
335 Platform.OS === "ios" ? colors.ios.systemBlue : colors.primary[500],
336 primaryForeground: colors.white,
337
338 secondary: isDark ? palette[800] : palette[100],
339 secondaryForeground: isDark ? palette[50] : palette[900],
340
341 muted: isDark ? palette[800] : palette[100],
342 mutedForeground: isDark ? palette[400] : palette[500],
343
344 accent: isDark ? palette[800] : palette[100],
345 accentForeground: isDark ? palette[50] : palette[900],
346
347 destructive:
348 Platform.OS === "ios" ? colors.ios.systemRed : colors.destructive[500],
349 destructiveForeground: colors.white,
350
351 success:
352 Platform.OS === "ios" ? colors.ios.systemGreen : colors.success[500],
353 successForeground: colors.white,
354
355 warning:
356 Platform.OS === "ios" ? colors.ios.systemOrange : colors.warning[500],
357 warningForeground: colors.white,
358
359 border: isDark ? palette[500] + "30" : palette[200] + "30",
360 input: isDark ? palette[800] : palette[200],
361 ring: Platform.OS === "ios" ? colors.ios.systemBlue : colors.primary[500],
362
363 text: isDark ? palette[50] : palette[950],
364 textMuted: isDark ? palette[400] : palette[500],
365 textDisabled: isDark ? palette[600] : palette[400],
366 };
367}
368
369// Theme provider props
370interface ThemeProviderProps {
371 children: ReactNode;
372 defaultTheme?: "light" | "dark" | "system";
373 forcedTheme?: "light" | "dark";
374 colorTheme?: Partial<Theme["colors"]>;
375 lightTheme?: ColorPalette | Theme["colors"];
376 darkTheme?: ColorPalette | Theme["colors"];
377}
378
379// Theme provider component
380// Should be surrounded by SafeAreaProvider at the root
381export function ThemeProvider({
382 children,
383 defaultTheme = "system",
384 forcedTheme,
385 colorTheme,
386 lightTheme,
387 darkTheme,
388}: ThemeProviderProps) {
389 const systemColorScheme = useColorScheme();
390 const [currentTheme, setCurrentTheme] = useState<"light" | "dark" | "system">(
391 defaultTheme,
392 );
393
394 // Determine if dark mode should be active
395 const isDark = useMemo(() => {
396 if (forcedTheme === "light") return false;
397 if (forcedTheme === "dark") return true;
398 if (currentTheme === "light") return false;
399 if (currentTheme === "dark") return true;
400 if (currentTheme === "system") return systemColorScheme === "dark";
401 return systemColorScheme === "dark";
402 }, [forcedTheme, currentTheme, systemColorScheme]);
403
404 // Create theme based on dark mode
405 const theme = useMemo<Theme>(() => {
406 const themeColors = createThemeColors(
407 isDark,
408 lightTheme,
409 darkTheme,
410 colorTheme,
411 );
412 return {
413 colors: themeColors,
414 spacing,
415 borderRadius,
416 typography,
417 shadows,
418 touchTargets,
419 animations,
420 };
421 }, [isDark, lightTheme, darkTheme, colorTheme]);
422
423 // Create theme-aware zero tokens
424 const zero = useMemo<ThemeZero>(() => {
425 return createThemeZero(theme.colors);
426 }, [theme.colors]);
427
428 // Create icon utilities
429 const icons = useMemo<ThemeIcons>(() => {
430 return createThemeIcons(theme.colors);
431 }, [theme.colors]);
432
433 // Theme controls
434 const setTheme = (newTheme: "light" | "dark" | "system") => {
435 if (!forcedTheme) {
436 setCurrentTheme(newTheme);
437 }
438 };
439
440 const toggleTheme = () => {
441 if (!forcedTheme) {
442 setCurrentTheme((prev) => {
443 if (prev === "light") return "dark";
444 if (prev === "dark") return "system";
445 return "light";
446 });
447 }
448 };
449
450 const value = useMemo<ThemeContextType>(
451 () => ({
452 theme,
453 zero,
454 icons,
455 isDark,
456 currentTheme: forcedTheme || currentTheme,
457 systemTheme: (systemColorScheme as "light" | "dark") || "light",
458 setTheme,
459 toggleTheme,
460 }),
461 [
462 theme,
463 zero,
464 icons,
465 isDark,
466 forcedTheme,
467 currentTheme,
468 systemColorScheme,
469 setTheme,
470 toggleTheme,
471 ],
472 );
473
474 return (
475 <ThemeContext.Provider value={value}>
476 <GestureHandlerRootView>
477 {children}
478 <PortalHost />
479 <ToastProvider />
480 </GestureHandlerRootView>
481 </ThemeContext.Provider>
482 );
483}
484
485// Hook to use theme
486export function useTheme(): ThemeContextType {
487 const context = useContext(ThemeContext);
488 if (!context) {
489 throw new Error("useTheme must be used within a ThemeProvider");
490 }
491 return context;
492}
493
494// Hook to get current platform's typography
495export function usePlatformTypography() {
496 const { theme } = useTheme();
497
498 return useMemo(() => {
499 if (Platform.OS === "ios") {
500 return theme.typography.ios;
501 } else if (Platform.OS === "android") {
502 return theme.typography.android;
503 }
504 return theme.typography.universal;
505 }, [theme.typography]);
506}
507
508// Utility function to create theme-aware styles
509export function createThemedStyles<T extends Record<string, any>>(
510 styleCreator: (theme: Theme, zero: ThemeZero, icons: ThemeIcons) => T,
511) {
512 return function useThemedStyles() {
513 const { theme, zero, icons } = useTheme();
514 return useMemo(
515 () => styleCreator(theme, zero, icons),
516 [theme, zero, icons],
517 );
518 };
519}
520
521// Create light and dark theme instances for external use
522export const lightTheme: Theme = {
523 colors: createThemeColors(false),
524 spacing,
525 borderRadius,
526 typography,
527 shadows,
528 touchTargets,
529 animations,
530};
531
532export const darkTheme: Theme = {
533 colors: createThemeColors(true),
534 spacing,
535 borderRadius,
536 typography,
537 shadows,
538 touchTargets,
539 animations,
540};
541
542// Export individual theme utilities for convenience
543export { createThemeColors, createThemeIcons, createThemeZero };