From 610104090a33153a51dc8127ec6fcba557ae9a5f Mon Sep 17 00:00:00 2001 From: Sergei Date: Sun, 1 Feb 2026 10:01:07 -0800 Subject: [PATCH] Add dark mode support with theme toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create ThemeContext with light/dark/system mode support - Add DarkColors palette for dark mode UI - Extend Colors object with full dark theme variants - Update useThemeColor hook to use ThemeContext - Add useThemeColors, useResolvedTheme, useIsDarkMode hooks - Update RootLayout (native and web) with ThemeProvider - Add theme toggle UI in ProfileDrawer settings - Theme preference persisted to AsyncStorage - Add comprehensive tests for ThemeContext and hooks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/ProfileDrawer.tsx | 146 ++++++++++-- components/layout/RootLayout.native.tsx | 34 +-- components/layout/RootLayout.web.tsx | 36 +-- constants/theme.ts | 102 +++++++- contexts/ThemeContext.tsx | 98 ++++++++ contexts/__tests__/ThemeContext.test.tsx | 284 +++++++++++++++++++++++ hooks/__tests__/useThemeColor.test.tsx | 165 +++++++++++++ hooks/use-theme-color.ts | 46 +++- 8 files changed, 838 insertions(+), 73 deletions(-) create mode 100644 contexts/ThemeContext.tsx create mode 100644 contexts/__tests__/ThemeContext.test.tsx create mode 100644 hooks/__tests__/useThemeColor.test.tsx diff --git a/components/ProfileDrawer.tsx b/components/ProfileDrawer.tsx index 827e325..d3cdcaf 100644 --- a/components/ProfileDrawer.tsx +++ b/components/ProfileDrawer.tsx @@ -16,10 +16,12 @@ import { router } from 'expo-router'; import { AppColors, BorderRadius, + Colors, FontSizes, Spacing, FontWeights, } from '@/constants/theme'; +import { useTheme, type ThemeMode } from '@/contexts/ThemeContext'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); const DRAWER_WIDTH = SCREEN_WIDTH * 0.85; @@ -33,34 +35,80 @@ interface DrawerItemProps { badge?: string; } -function DrawerItem({ icon, label, onPress, rightElement, danger, badge }: DrawerItemProps) { +function DrawerItem({ icon, label, onPress, rightElement, danger, badge }: DrawerItemProps & { colors?: typeof Colors.light }) { + const { resolvedTheme } = useTheme(); + const colors = Colors[resolvedTheme]; + return ( - + - + {label} {badge && ( - {badge} + {badge} )} {rightElement || (onPress && ( - + ))} ); } +// Theme mode selector component +function ThemeModeSelector() { + const { themeMode, setThemeMode, resolvedTheme } = useTheme(); + const colors = Colors[resolvedTheme]; + + const options: { mode: ThemeMode; icon: keyof typeof Ionicons.glyphMap; label: string }[] = [ + { mode: 'light', icon: 'sunny-outline', label: 'Light' }, + { mode: 'dark', icon: 'moon-outline', label: 'Dark' }, + { mode: 'system', icon: 'phone-portrait-outline', label: 'System' }, + ]; + + return ( + + {options.map((option) => ( + setThemeMode(option.mode)} + activeOpacity={0.7} + > + + + {option.label} + + + ))} + + ); +} + interface ProfileDrawerProps { visible: boolean; onClose: () => void; @@ -83,6 +131,8 @@ export function ProfileDrawer({ const insets = useSafeAreaInsets(); const slideAnim = React.useRef(new Animated.Value(-DRAWER_WIDTH)).current; const fadeAnim = React.useRef(new Animated.Value(0)).current; + const { resolvedTheme } = useTheme(); + const colors = Colors[resolvedTheme]; React.useEffect(() => { Animated.parallel([ @@ -124,22 +174,36 @@ export function ProfileDrawer({ {/* Header */} - - Settings - - + + Settings + + + {/* Appearance */} + + Appearance + + + + + Theme + + + + + + {/* Preferences */} - Preferences + Preferences onSettingChange('pushNotifications', v)} - trackColor={{ false: AppColors.border, true: AppColors.primary }} - thumbColor={AppColors.white} - ios_backgroundColor={AppColors.border} + trackColor={{ false: colors.border, true: colors.primary }} + thumbColor="#FFFFFF" + ios_backgroundColor={colors.border} /> } /> @@ -160,9 +224,9 @@ export function ProfileDrawer({ onSettingChange('emailNotifications', v)} - trackColor={{ false: AppColors.border, true: AppColors.primary }} - thumbColor={AppColors.white} - ios_backgroundColor={AppColors.border} + trackColor={{ false: colors.border, true: colors.primary }} + thumbColor="#FFFFFF" + ios_backgroundColor={colors.border} /> } /> @@ -170,7 +234,7 @@ export function ProfileDrawer({ {/* Account */} - Account + Account - Support + Support - About + About {/* Version */} - - WellNuo v1.0.0 + + WellNuo v1.0.0 @@ -237,13 +301,45 @@ const styles = StyleSheet.create({ top: 0, bottom: 0, width: DRAWER_WIDTH, - backgroundColor: '#FFFFFF', shadowColor: '#000', shadowOffset: { width: 2, height: 0 }, shadowOpacity: 0.15, shadowRadius: 12, elevation: 8, }, + themeModeWrapper: { + paddingHorizontal: Spacing.lg, + paddingBottom: Spacing.md, + }, + themeModeContainer: { + flexDirection: 'row', + borderRadius: BorderRadius.lg, + padding: Spacing.xs, + }, + themeModeButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: Spacing.sm, + paddingHorizontal: Spacing.sm, + borderRadius: BorderRadius.md, + gap: Spacing.xs, + }, + themeModeButtonActive: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + themeModeLabel: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.medium, + }, + themeModeLabelActive: { + color: '#FFFFFF', + }, drawerContent: { flex: 1, }, diff --git a/components/layout/RootLayout.native.tsx b/components/layout/RootLayout.native.tsx index c339199..b0fb4b3 100644 --- a/components/layout/RootLayout.native.tsx +++ b/components/layout/RootLayout.native.tsx @@ -10,7 +10,7 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { ToastProvider } from '@/components/ui/Toast'; import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext'; -import { useColorScheme } from '@/hooks/use-color-scheme'; +import { ThemeProvider as WellNuoThemeProvider, useTheme } from '@/contexts/ThemeContext'; // Stripe publishable key (test mode) - must match backend STRIPE_PUBLISHABLE_KEY const STRIPE_PUBLISHABLE_KEY = 'pk_test_51P3kdqP0gvUw6M9C7ixPQHqbPcvga4G5kAYx1h6QXQAt1psbrC2rrmOojW0fTeQzaxD1Q9RKS3zZ23MCvjjZpWLi00eCFWRHMk'; @@ -22,7 +22,7 @@ SplashScreen.preventAutoHideAsync().catch(() => {}); let splashHidden = false; function RootLayoutNav() { - const colorScheme = useColorScheme(); + const { resolvedTheme, isDark } = useTheme(); const { isAuthenticated, isInitializing } = useAuth(); const segments = useSegments(); const navigationState = useRootNavigationState(); @@ -71,30 +71,32 @@ function RootLayoutNav() { } return ( - + - + ); } export default function RootLayout() { return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/components/layout/RootLayout.web.tsx b/components/layout/RootLayout.web.tsx index aa817d7..941785c 100644 --- a/components/layout/RootLayout.web.tsx +++ b/components/layout/RootLayout.web.tsx @@ -10,7 +10,8 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { ToastProvider } from '@/components/ui/Toast'; import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext'; -import { useColorScheme } from '@/hooks/use-color-scheme'; +import { ThemeProvider as WellNuoThemeProvider, useTheme } from '@/contexts/ThemeContext'; +import { Colors } from '@/constants/theme'; // Polyfill for $ to prevent ReferenceError: Cannot access '$' before initialization // This is a workaround for some web-specific bundling issues in this project. @@ -28,10 +29,11 @@ SplashScreen.preventAutoHideAsync().catch(() => { }); let splashHidden = false; function RootLayoutNav() { - const colorScheme = useColorScheme(); + const { resolvedTheme, isDark } = useTheme(); const { isAuthenticated, isInitializing } = useAuth(); const segments = useSegments(); const navigationState = useRootNavigationState(); + const themeColors = Colors[resolvedTheme]; // Track if initial redirect was done const hasInitialRedirect = useRef(false); @@ -77,15 +79,15 @@ function RootLayoutNav() { } return ( - - - + + + - + @@ -94,28 +96,28 @@ function RootLayoutNav() { export default function RootLayout() { return ( - - - - - - - + + + + + + + + + ); } const styles = StyleSheet.create({ webContainer: { flex: 1, - backgroundColor: '#e5e5e5', // Light gray background for desktop alignItems: 'center', justifyContent: 'center', }, mobileWrapper: { flex: 1, width: '100%', - maxWidth: 430, // Mobile width constraint - backgroundColor: '#fff', + maxWidth: 430, shadowColor: '#000', shadowOffset: { width: 0, @@ -124,6 +126,6 @@ const styles = StyleSheet.create({ shadowOpacity: 0.25, shadowRadius: 3.84, elevation: 5, - overflow: 'hidden', // Clip content to rounded corners if we want + overflow: 'hidden', } }); diff --git a/constants/theme.ts b/constants/theme.ts index b6e82ea..95580d8 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -72,36 +72,116 @@ export const AppColors = { overlayLight: 'rgba(15, 23, 42, 0.3)', }; -// Theme variants for future dark mode support +// Dark mode color palette +export const DarkColors = { + // Backgrounds + background: '#0F172A', + backgroundSecondary: '#1E293B', + surface: '#1E293B', + surfaceSecondary: '#334155', + surfaceElevated: '#334155', + + // Text + textPrimary: '#F1F5F9', + textSecondary: '#94A3B8', + textMuted: '#64748B', + textLight: '#0F172A', + textDisabled: '#475569', + + // Borders + border: '#334155', + borderLight: '#475569', + borderFocus: '#3391CC', + + // Primary (slightly lighter for dark mode) + primary: '#3391CC', + primaryDark: '#0076BF', + primaryLight: '#60A5FA', + primaryLighter: '#1E3A5F', + primarySubtle: '#0F2744', + + // Status Colors (brighter for dark mode visibility) + success: '#34D399', + successLight: '#064E3B', + warning: '#FBBF24', + warningLight: '#78350F', + error: '#F87171', + errorLight: '#7F1D1D', + info: '#60A5FA', + infoLight: '#1E3A8A', + + // Accent + accent: '#A78BFA', + accentLight: '#2E1065', + accentDark: '#8B5CF6', + + // Status Indicators + online: '#34D399', + offline: '#64748B', + away: '#FBBF24', + + // Special + white: '#F8FAFC', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayLight: 'rgba(0, 0, 0, 0.4)', +}; + +// Theme variants const tintColorLight = AppColors.primary; -const tintColorDark = AppColors.primaryLight; +const tintColorDark = DarkColors.primary; export const Colors = { light: { text: AppColors.textPrimary, + textSecondary: AppColors.textSecondary, + textMuted: AppColors.textMuted, background: AppColors.background, + backgroundSecondary: AppColors.backgroundSecondary, tint: tintColorLight, icon: AppColors.textSecondary, tabIconDefault: AppColors.textMuted, tabIconSelected: tintColorLight, surface: AppColors.surface, + surfaceSecondary: AppColors.surfaceSecondary, border: AppColors.border, + borderLight: AppColors.borderLight, primary: AppColors.primary, + primaryLight: AppColors.primaryLight, error: AppColors.error, + errorLight: AppColors.errorLight, success: AppColors.success, + successLight: AppColors.successLight, + warning: AppColors.warning, + warningLight: AppColors.warningLight, + accent: AppColors.accent, + accentLight: AppColors.accentLight, + overlay: AppColors.overlay, }, dark: { - text: '#F1F5F9', - background: '#0F172A', + text: DarkColors.textPrimary, + textSecondary: DarkColors.textSecondary, + textMuted: DarkColors.textMuted, + background: DarkColors.background, + backgroundSecondary: DarkColors.backgroundSecondary, tint: tintColorDark, - icon: '#94A3B8', - tabIconDefault: '#64748B', + icon: DarkColors.textSecondary, + tabIconDefault: DarkColors.textMuted, tabIconSelected: tintColorDark, - surface: '#1E293B', - border: '#334155', - primary: AppColors.primaryLight, - error: '#F87171', - success: '#34D399', + surface: DarkColors.surface, + surfaceSecondary: DarkColors.surfaceSecondary, + border: DarkColors.border, + borderLight: DarkColors.borderLight, + primary: DarkColors.primary, + primaryLight: DarkColors.primaryLight, + error: DarkColors.error, + errorLight: DarkColors.errorLight, + success: DarkColors.success, + successLight: DarkColors.successLight, + warning: DarkColors.warning, + warningLight: DarkColors.warningLight, + accent: DarkColors.accent, + accentLight: DarkColors.accentLight, + overlay: DarkColors.overlay, }, }; diff --git a/contexts/ThemeContext.tsx b/contexts/ThemeContext.tsx new file mode 100644 index 0000000..dcde8f0 --- /dev/null +++ b/contexts/ThemeContext.tsx @@ -0,0 +1,98 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import React, { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; +import { useColorScheme as useSystemColorScheme } from 'react-native'; + +export type ThemeMode = 'light' | 'dark' | 'system'; +export type ResolvedTheme = 'light' | 'dark'; + +const THEME_STORAGE_KEY = '@wellnuo:theme_preference'; + +interface ThemeContextType { + themeMode: ThemeMode; + resolvedTheme: ResolvedTheme; + isDark: boolean; + setThemeMode: (mode: ThemeMode) => Promise; + toggleTheme: () => Promise; +} + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const systemColorScheme = useSystemColorScheme(); + const [themeMode, setThemeModeState] = useState('system'); + const [isLoaded, setIsLoaded] = useState(false); + + // Load saved theme preference on mount + useEffect(() => { + const loadThemePreference = async () => { + try { + const savedTheme = await AsyncStorage.getItem(THEME_STORAGE_KEY); + if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark' || savedTheme === 'system')) { + setThemeModeState(savedTheme as ThemeMode); + } + } catch (error) { + console.warn('Failed to load theme preference:', error); + } finally { + setIsLoaded(true); + } + }; + + loadThemePreference(); + }, []); + + // Resolve the actual theme based on mode and system preference + const resolvedTheme: ResolvedTheme = + themeMode === 'system' ? (systemColorScheme === 'dark' ? 'dark' : 'light') : themeMode; + + const isDark = resolvedTheme === 'dark'; + + // Set theme mode and persist to storage + const setThemeMode = useCallback(async (mode: ThemeMode) => { + try { + await AsyncStorage.setItem(THEME_STORAGE_KEY, mode); + setThemeModeState(mode); + } catch (error) { + console.warn('Failed to save theme preference:', error); + // Still update state even if storage fails + setThemeModeState(mode); + } + }, []); + + // Toggle between light and dark (skips system) + const toggleTheme = useCallback(async () => { + const newMode: ThemeMode = resolvedTheme === 'light' ? 'dark' : 'light'; + await setThemeMode(newMode); + }, [resolvedTheme, setThemeMode]); + + // Prevent flash by not rendering until theme is loaded + if (!isLoaded) { + return null; + } + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} + +// For components that need optional theme access (during initialization) +export function useThemeOptional() { + return useContext(ThemeContext); +} diff --git a/contexts/__tests__/ThemeContext.test.tsx b/contexts/__tests__/ThemeContext.test.tsx new file mode 100644 index 0000000..2914315 --- /dev/null +++ b/contexts/__tests__/ThemeContext.test.tsx @@ -0,0 +1,284 @@ +/** + * ThemeContext Tests + * + * Tests for dark mode support functionality + */ + +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { ThemeProvider, useTheme, useThemeOptional, type ThemeMode } from '../ThemeContext'; + +// Mock AsyncStorage +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(() => Promise.resolve(null)), + setItem: jest.fn(() => Promise.resolve()), +})); + +// Mock useColorScheme hook via the hook path +let mockSystemColorScheme: 'light' | 'dark' | null = 'light'; +jest.mock('react-native/Libraries/Utilities/useColorScheme', () => ({ + default: () => mockSystemColorScheme, +})); + +describe('ThemeContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSystemColorScheme = 'light'; + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + describe('ThemeProvider', () => { + it('should provide default theme values', async () => { + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('system'); + expect(result.current.resolvedTheme).toBe('light'); + expect(result.current.isDark).toBe(false); + }); + }); + + it('should resolve to dark theme when system is dark', async () => { + mockSystemColorScheme = 'dark'; + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.resolvedTheme).toBe('dark'); + expect(result.current.isDark).toBe(true); + }); + }); + + it('should load saved theme preference from storage', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('dark'); + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('dark'); + expect(result.current.resolvedTheme).toBe('dark'); + expect(result.current.isDark).toBe(true); + }); + }); + + it('should load light theme preference from storage', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('light'); + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('light'); + expect(result.current.resolvedTheme).toBe('light'); + expect(result.current.isDark).toBe(false); + }); + }); + }); + + describe('setThemeMode', () => { + it('should change theme mode to dark', async () => { + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('system'); + }); + + await act(async () => { + await result.current.setThemeMode('dark'); + }); + + expect(result.current.themeMode).toBe('dark'); + expect(result.current.resolvedTheme).toBe('dark'); + expect(result.current.isDark).toBe(true); + expect(AsyncStorage.setItem).toHaveBeenCalledWith('@wellnuo:theme_preference', 'dark'); + }); + + it('should change theme mode to light', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('dark'); + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('dark'); + }); + + await act(async () => { + await result.current.setThemeMode('light'); + }); + + expect(result.current.themeMode).toBe('light'); + expect(result.current.resolvedTheme).toBe('light'); + expect(result.current.isDark).toBe(false); + expect(AsyncStorage.setItem).toHaveBeenCalledWith('@wellnuo:theme_preference', 'light'); + }); + + it('should change theme mode to system', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('dark'); + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('dark'); + }); + + await act(async () => { + await result.current.setThemeMode('system'); + }); + + expect(result.current.themeMode).toBe('system'); + expect(result.current.resolvedTheme).toBe('light'); // system is light + expect(AsyncStorage.setItem).toHaveBeenCalledWith('@wellnuo:theme_preference', 'system'); + }); + }); + + describe('toggleTheme', () => { + it('should toggle from light to dark', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('light'); + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.resolvedTheme).toBe('light'); + }); + + await act(async () => { + await result.current.toggleTheme(); + }); + + expect(result.current.themeMode).toBe('dark'); + expect(result.current.resolvedTheme).toBe('dark'); + }); + + it('should toggle from dark to light', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('dark'); + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.resolvedTheme).toBe('dark'); + }); + + await act(async () => { + await result.current.toggleTheme(); + }); + + expect(result.current.themeMode).toBe('light'); + expect(result.current.resolvedTheme).toBe('light'); + }); + + it('should toggle from system (light) to dark', async () => { + mockSystemColorScheme = 'light'; + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('system'); + expect(result.current.resolvedTheme).toBe('light'); + }); + + await act(async () => { + await result.current.toggleTheme(); + }); + + expect(result.current.themeMode).toBe('dark'); + expect(result.current.resolvedTheme).toBe('dark'); + }); + + it('should toggle from system (dark) to light', async () => { + mockSystemColorScheme = 'dark'; + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('system'); + expect(result.current.resolvedTheme).toBe('dark'); + }); + + await act(async () => { + await result.current.toggleTheme(); + }); + + expect(result.current.themeMode).toBe('light'); + expect(result.current.resolvedTheme).toBe('light'); + }); + }); + + describe('useThemeOptional', () => { + it('should return null when used outside provider', () => { + const { result } = renderHook(() => useThemeOptional()); + expect(result.current).toBeNull(); + }); + + it('should return theme context when used inside provider', async () => { + const { result } = renderHook(() => useThemeOptional(), { wrapper }); + + await waitFor(() => { + expect(result.current).not.toBeNull(); + expect(result.current?.themeMode).toBe('system'); + }); + }); + }); + + describe('useTheme error handling', () => { + it('should throw error when used outside provider', () => { + // Suppress console.error for this test + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useTheme()); + }).toThrow('useTheme must be used within a ThemeProvider'); + + consoleSpy.mockRestore(); + }); + }); + + describe('error handling', () => { + it('should handle AsyncStorage read error gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('Storage error')); + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + // Should fallback to default 'system' mode + expect(result.current.themeMode).toBe('system'); + }); + + consoleSpy.mockRestore(); + }); + + it('should handle AsyncStorage write error gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + (AsyncStorage.setItem as jest.Mock).mockRejectedValue(new Error('Storage error')); + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('system'); + }); + + // Should still update state even if storage fails + await act(async () => { + await result.current.setThemeMode('dark'); + }); + + expect(result.current.themeMode).toBe('dark'); + consoleSpy.mockRestore(); + }); + + it('should ignore invalid stored theme values', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('invalid_theme'); + + const { result } = renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + // Should fallback to default 'system' mode + expect(result.current.themeMode).toBe('system'); + }); + }); + }); +}); diff --git a/hooks/__tests__/useThemeColor.test.tsx b/hooks/__tests__/useThemeColor.test.tsx new file mode 100644 index 0000000..beaff8e --- /dev/null +++ b/hooks/__tests__/useThemeColor.test.tsx @@ -0,0 +1,165 @@ +/** + * useThemeColor Hook Tests + * + * Tests for theme color resolution with dark mode support + */ + +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import { + useThemeColor, + useThemeColors, + useResolvedTheme, + useIsDarkMode, +} from '../use-theme-color'; +import { Colors } from '@/constants/theme'; +import { ThemeProvider } from '@/contexts/ThemeContext'; + +// Mock useColorScheme hook via the hook path +let mockSystemColorScheme: 'light' | 'dark' | null = 'light'; +jest.mock('react-native/Libraries/Utilities/useColorScheme', () => ({ + default: () => mockSystemColorScheme, +})); + +// Mock AsyncStorage +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(() => Promise.resolve(null)), + setItem: jest.fn(() => Promise.resolve()), +})); + +describe('useThemeColor', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSystemColorScheme = 'light'; + }); + + describe('without ThemeProvider', () => { + it('should use system light theme by default', () => { + mockSystemColorScheme = 'light'; + const { result } = renderHook(() => useThemeColor({}, 'text')); + expect(result.current).toBe(Colors.light.text); + }); + + it('should use system dark theme when system is dark', () => { + mockSystemColorScheme = 'dark'; + const { result } = renderHook(() => useThemeColor({}, 'text')); + expect(result.current).toBe(Colors.dark.text); + }); + + it('should return light prop color when provided in light mode', () => { + mockSystemColorScheme = 'light'; + const { result } = renderHook(() => + useThemeColor({ light: '#CUSTOM_LIGHT' }, 'text') + ); + expect(result.current).toBe('#CUSTOM_LIGHT'); + }); + + it('should return dark prop color when provided in dark mode', () => { + mockSystemColorScheme = 'dark'; + const { result } = renderHook(() => + useThemeColor({ dark: '#CUSTOM_DARK' }, 'text') + ); + expect(result.current).toBe('#CUSTOM_DARK'); + }); + + it('should fallback to null system scheme as light', () => { + mockSystemColorScheme = null; + const { result } = renderHook(() => useThemeColor({}, 'text')); + expect(result.current).toBe(Colors.light.text); + }); + }); + + describe('with ThemeProvider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('should resolve background color for light theme', async () => { + const { result } = renderHook(() => useThemeColor({}, 'background'), { wrapper }); + await waitFor(() => { + expect(result.current).toBe(Colors.light.background); + }); + }); + + it('should resolve primary color', async () => { + const { result } = renderHook(() => useThemeColor({}, 'primary'), { wrapper }); + await waitFor(() => { + expect(result.current).toBe(Colors.light.primary); + }); + }); + + it('should resolve surface color', async () => { + const { result } = renderHook(() => useThemeColor({}, 'surface'), { wrapper }); + await waitFor(() => { + expect(result.current).toBe(Colors.light.surface); + }); + }); + }); +}); + +describe('useThemeColors', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSystemColorScheme = 'light'; + }); + + it('should return all light theme colors in light mode', () => { + mockSystemColorScheme = 'light'; + const { result } = renderHook(() => useThemeColors()); + expect(result.current).toEqual(Colors.light); + }); + + it('should return all dark theme colors in dark mode', () => { + mockSystemColorScheme = 'dark'; + const { result } = renderHook(() => useThemeColors()); + expect(result.current).toEqual(Colors.dark); + }); +}); + +describe('useResolvedTheme', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return light when system is light', () => { + mockSystemColorScheme = 'light'; + const { result } = renderHook(() => useResolvedTheme()); + expect(result.current).toBe('light'); + }); + + it('should return dark when system is dark', () => { + mockSystemColorScheme = 'dark'; + const { result } = renderHook(() => useResolvedTheme()); + expect(result.current).toBe('dark'); + }); + + it('should default to light when system is null', () => { + mockSystemColorScheme = null; + const { result } = renderHook(() => useResolvedTheme()); + expect(result.current).toBe('light'); + }); +}); + +describe('useIsDarkMode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return false in light mode', () => { + mockSystemColorScheme = 'light'; + const { result } = renderHook(() => useIsDarkMode()); + expect(result.current).toBe(false); + }); + + it('should return true in dark mode', () => { + mockSystemColorScheme = 'dark'; + const { result } = renderHook(() => useIsDarkMode()); + expect(result.current).toBe(true); + }); + + it('should return false when system is null', () => { + mockSystemColorScheme = null; + const { result } = renderHook(() => useIsDarkMode()); + expect(result.current).toBe(false); + }); +}); diff --git a/hooks/use-theme-color.ts b/hooks/use-theme-color.ts index 0cbc3a6..677a97d 100644 --- a/hooks/use-theme-color.ts +++ b/hooks/use-theme-color.ts @@ -1,16 +1,23 @@ /** - * Learn more about light and dark modes: - * https://docs.expo.dev/guides/color-schemes/ + * Theme color hook with dark mode support + * Uses ThemeContext for user preference, falls back to system preference */ import { Colors } from '@/constants/theme'; +import { useThemeOptional } from '@/contexts/ThemeContext'; import { useColorScheme } from '@/hooks/use-color-scheme'; +export type ThemeColorName = keyof typeof Colors.light & keyof typeof Colors.dark; + export function useThemeColor( props: { light?: string; dark?: string }, - colorName: keyof typeof Colors.light & keyof typeof Colors.dark + colorName: ThemeColorName ) { - const theme = useColorScheme() ?? 'light'; + const themeContext = useThemeOptional(); + const systemTheme = useColorScheme(); + + // Use ThemeContext if available, otherwise fall back to system + const theme = themeContext?.resolvedTheme ?? systemTheme ?? 'light'; const colorFromProps = props[theme]; if (colorFromProps) { @@ -19,3 +26,34 @@ export function useThemeColor( return Colors[theme][colorName]; } } + +/** + * Hook to get all theme colors at once + * Useful for components that need multiple colors + */ +export function useThemeColors() { + const themeContext = useThemeOptional(); + const systemTheme = useColorScheme(); + const theme = themeContext?.resolvedTheme ?? systemTheme ?? 'light'; + + return Colors[theme]; +} + +/** + * Hook to get resolved theme + */ +export function useResolvedTheme(): 'light' | 'dark' { + const themeContext = useThemeOptional(); + const systemTheme = useColorScheme(); + return themeContext?.resolvedTheme ?? systemTheme ?? 'light'; +} + +/** + * Hook to check if dark mode is active + */ +export function useIsDarkMode(): boolean { + const themeContext = useThemeOptional(); + const systemTheme = useColorScheme(); + const resolvedTheme = themeContext?.resolvedTheme ?? systemTheme ?? 'light'; + return resolvedTheme === 'dark'; +}