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';
+}