All files / src/theme ThemeContext.tsx

100% Statements 37/37
100% Branches 10/10
100% Functions 10/10
100% Lines 34/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 901x               1x 1x 1x       1x                         1x   1x 27x 27x 27x   27x 12x   10x 3x             11x       27x 19x 17x 13x     27x   27x 4x 4x 4x           27x 19x           27x 12x     15x         1x 17x 16x 2x   14x    
import React, {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useState,
} from 'react';
import { useColorScheme } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import { type Theme, darkTheme, lightTheme } from './themes';
 
export type ThemePreference = 'light' | 'dark' | 'system';
 
const SECURE_STORE_KEY = 'theme_preference';
 
interface ThemeContextValue {
  /** Resolved theme object for the current color scheme */
  theme: Theme;
  /** Whether dark mode is currently active */
  isDark: boolean;
  /** User-persisted preference: 'light' | 'dark' | 'system' */
  themePreference: ThemePreference;
  /** Persist a new preference and apply immediately */
  setThemePreference: (preference: ThemePreference) => Promise<void>;
}
 
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const systemScheme = useColorScheme();
  const [preference, setPreference] = useState<ThemePreference>('system');
  const [isHydrated, setIsHydrated] = useState(false);
 
  useEffect(() => {
    SecureStore.getItemAsync(SECURE_STORE_KEY)
      .then((stored) => {
        if (stored === 'light' || stored === 'dark' || stored === 'system') {
          setPreference(stored);
        }
      })
      .catch(() => {
        // Ignore read errors — fall back to 'system'
      })
      .finally(() => {
        setIsHydrated(true);
      });
  }, []);
 
  const isDark = useMemo(() => {
    if (preference === 'light') return false;
    if (preference === 'dark') return true;
    return systemScheme === 'dark';
  }, [preference, systemScheme]);
 
  const theme = useMemo(() => (isDark ? darkTheme : lightTheme), [isDark]);
 
  const setThemePreference = useCallback(async (next: ThemePreference) => {
    setPreference(next);
    try {
      await SecureStore.setItemAsync(SECURE_STORE_KEY, next);
    } catch {
      // Persist failure is non-fatal; preference still applies for the session
    }
  }, []);
 
  const value = useMemo<ThemeContextValue>(
    () => ({ theme, isDark, themePreference: preference, setThemePreference }),
    [theme, isDark, preference, setThemePreference],
  );
 
  // Don't render children until we've read the stored preference so the
  // initial render uses the correct theme instead of flickering.
  if (!isHydrated) {
    return null;
  }
 
  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
}
 
export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) {
    throw new Error('useTheme must be called inside <ThemeProvider>');
  }
  return ctx;
}