Live video on the AT Protocol
at restructure 543 lines 13 kB view raw
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 };