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