Add dark mode support with theme toggle

- 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 <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-02-01 10:01:07 -08:00
parent 290b0e218b
commit 610104090a
8 changed files with 838 additions and 73 deletions

View File

@ -16,10 +16,12 @@ import { router } from 'expo-router';
import { import {
AppColors, AppColors,
BorderRadius, BorderRadius,
Colors,
FontSizes, FontSizes,
Spacing, Spacing,
FontWeights, FontWeights,
} from '@/constants/theme'; } from '@/constants/theme';
import { useTheme, type ThemeMode } from '@/contexts/ThemeContext';
const { width: SCREEN_WIDTH } = Dimensions.get('window'); const { width: SCREEN_WIDTH } = Dimensions.get('window');
const DRAWER_WIDTH = SCREEN_WIDTH * 0.85; const DRAWER_WIDTH = SCREEN_WIDTH * 0.85;
@ -33,34 +35,80 @@ interface DrawerItemProps {
badge?: string; 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 ( return (
<TouchableOpacity <TouchableOpacity
style={styles.drawerItem} style={[styles.drawerItem, { borderBottomColor: colors.border }]}
onPress={onPress} onPress={onPress}
disabled={!onPress && !rightElement} disabled={!onPress && !rightElement}
activeOpacity={0.6} activeOpacity={0.6}
> >
<View style={[styles.iconContainer, danger && styles.iconContainerDanger]}> <View style={[styles.iconContainer, { backgroundColor: colors.surfaceSecondary }, danger && { backgroundColor: colors.errorLight }]}>
<Ionicons <Ionicons
name={icon} name={icon}
size={22} size={22}
color={danger ? AppColors.error : AppColors.textSecondary} color={danger ? colors.error : colors.textSecondary}
/> />
</View> </View>
<Text style={[styles.drawerItemLabel, danger && styles.dangerText]}> <Text style={[styles.drawerItemLabel, { color: colors.text }, danger && { color: colors.error }]}>
{label} {label}
</Text> </Text>
{badge && ( {badge && (
<Text style={styles.badgeText}>{badge}</Text> <Text style={[styles.badgeText, { color: colors.textMuted }]}>{badge}</Text>
)} )}
{rightElement || (onPress && ( {rightElement || (onPress && (
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} /> <Ionicons name="chevron-forward" size={18} color={colors.textMuted} />
))} ))}
</TouchableOpacity> </TouchableOpacity>
); );
} }
// 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 (
<View style={[styles.themeModeContainer, { backgroundColor: colors.surfaceSecondary }]}>
{options.map((option) => (
<TouchableOpacity
key={option.mode}
style={[
styles.themeModeButton,
themeMode === option.mode && [styles.themeModeButtonActive, { backgroundColor: colors.primary }],
]}
onPress={() => setThemeMode(option.mode)}
activeOpacity={0.7}
>
<Ionicons
name={option.icon}
size={18}
color={themeMode === option.mode ? '#FFFFFF' : colors.textSecondary}
/>
<Text
style={[
styles.themeModeLabel,
{ color: colors.textSecondary },
themeMode === option.mode && styles.themeModeLabelActive,
]}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
);
}
interface ProfileDrawerProps { interface ProfileDrawerProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
@ -83,6 +131,8 @@ export function ProfileDrawer({
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const slideAnim = React.useRef(new Animated.Value(-DRAWER_WIDTH)).current; const slideAnim = React.useRef(new Animated.Value(-DRAWER_WIDTH)).current;
const fadeAnim = React.useRef(new Animated.Value(0)).current; const fadeAnim = React.useRef(new Animated.Value(0)).current;
const { resolvedTheme } = useTheme();
const colors = Colors[resolvedTheme];
React.useEffect(() => { React.useEffect(() => {
Animated.parallel([ Animated.parallel([
@ -124,22 +174,36 @@ export function ProfileDrawer({
<Animated.View <Animated.View
style={[ style={[
styles.drawer, styles.drawer,
{ transform: [{ translateX: slideAnim }] }, { transform: [{ translateX: slideAnim }], backgroundColor: colors.surface },
]} ]}
> >
<SafeAreaView style={styles.drawerContent} edges={['left']}> <SafeAreaView style={styles.drawerContent} edges={['left']}>
{/* Header */} {/* Header */}
<View style={[styles.drawerHeader, { paddingTop: insets.top + Spacing.md }]}> <View style={[styles.drawerHeader, { paddingTop: insets.top + Spacing.md, borderBottomColor: colors.border }]}>
<Text style={styles.drawerTitle}>Settings</Text> <Text style={[styles.drawerTitle, { color: colors.text }]}>Settings</Text>
<TouchableOpacity style={styles.closeButton} onPress={onClose}> <TouchableOpacity style={[styles.closeButton, { backgroundColor: colors.surfaceSecondary }]} onPress={onClose}>
<Ionicons name="close" size={24} color={AppColors.textSecondary} /> <Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView style={styles.drawerScroll} showsVerticalScrollIndicator={false}> <ScrollView style={styles.drawerScroll} showsVerticalScrollIndicator={false}>
{/* Appearance */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.textMuted }]}>Appearance</Text>
<View style={[styles.drawerItem, { borderBottomColor: colors.border }]}>
<View style={[styles.iconContainer, { backgroundColor: colors.surfaceSecondary }]}>
<Ionicons name="contrast-outline" size={22} color={colors.textSecondary} />
</View>
<Text style={[styles.drawerItemLabel, { color: colors.text }]}>Theme</Text>
</View>
<View style={styles.themeModeWrapper}>
<ThemeModeSelector />
</View>
</View>
{/* Preferences */} {/* Preferences */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Preferences</Text> <Text style={[styles.sectionTitle, { color: colors.textMuted }]}>Preferences</Text>
<DrawerItem <DrawerItem
icon="notifications-outline" icon="notifications-outline"
label="Push Notifications" label="Push Notifications"
@ -147,9 +211,9 @@ export function ProfileDrawer({
<Switch <Switch
value={settings.pushNotifications} value={settings.pushNotifications}
onValueChange={(v) => onSettingChange('pushNotifications', v)} onValueChange={(v) => onSettingChange('pushNotifications', v)}
trackColor={{ false: AppColors.border, true: AppColors.primary }} trackColor={{ false: colors.border, true: colors.primary }}
thumbColor={AppColors.white} thumbColor="#FFFFFF"
ios_backgroundColor={AppColors.border} ios_backgroundColor={colors.border}
/> />
} }
/> />
@ -160,9 +224,9 @@ export function ProfileDrawer({
<Switch <Switch
value={settings.emailNotifications} value={settings.emailNotifications}
onValueChange={(v) => onSettingChange('emailNotifications', v)} onValueChange={(v) => onSettingChange('emailNotifications', v)}
trackColor={{ false: AppColors.border, true: AppColors.primary }} trackColor={{ false: colors.border, true: colors.primary }}
thumbColor={AppColors.white} thumbColor="#FFFFFF"
ios_backgroundColor={AppColors.border} ios_backgroundColor={colors.border}
/> />
} }
/> />
@ -170,7 +234,7 @@ export function ProfileDrawer({
{/* Account */} {/* Account */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text> <Text style={[styles.sectionTitle, { color: colors.textMuted }]}>Account</Text>
<DrawerItem <DrawerItem
icon="language-outline" icon="language-outline"
label="Language" label="Language"
@ -181,7 +245,7 @@ export function ProfileDrawer({
{/* Support */} {/* Support */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Support</Text> <Text style={[styles.sectionTitle, { color: colors.textMuted }]}>Support</Text>
<DrawerItem <DrawerItem
icon="help-circle-outline" icon="help-circle-outline"
label="Help Center" label="Help Center"
@ -201,7 +265,7 @@ export function ProfileDrawer({
{/* About */} {/* About */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text> <Text style={[styles.sectionTitle, { color: colors.textMuted }]}>About</Text>
<DrawerItem <DrawerItem
icon="information-circle-outline" icon="information-circle-outline"
label="About WellNuo" label="About WellNuo"
@ -212,8 +276,8 @@ export function ProfileDrawer({
</ScrollView> </ScrollView>
{/* Version */} {/* Version */}
<View style={styles.versionContainer}> <View style={[styles.versionContainer, { borderTopColor: colors.border }]}>
<Text style={styles.versionText}>WellNuo v1.0.0</Text> <Text style={[styles.versionText, { color: colors.textMuted }]}>WellNuo v1.0.0</Text>
</View> </View>
</SafeAreaView> </SafeAreaView>
</Animated.View> </Animated.View>
@ -237,13 +301,45 @@ const styles = StyleSheet.create({
top: 0, top: 0,
bottom: 0, bottom: 0,
width: DRAWER_WIDTH, width: DRAWER_WIDTH,
backgroundColor: '#FFFFFF',
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 2, height: 0 }, shadowOffset: { width: 2, height: 0 },
shadowOpacity: 0.15, shadowOpacity: 0.15,
shadowRadius: 12, shadowRadius: 12,
elevation: 8, 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: { drawerContent: {
flex: 1, flex: 1,
}, },

View File

@ -10,7 +10,7 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToastProvider } from '@/components/ui/Toast'; import { ToastProvider } from '@/components/ui/Toast';
import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { AuthProvider, useAuth } from '@/contexts/AuthContext';
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext'; 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 // Stripe publishable key (test mode) - must match backend STRIPE_PUBLISHABLE_KEY
const STRIPE_PUBLISHABLE_KEY = 'pk_test_51P3kdqP0gvUw6M9C7ixPQHqbPcvga4G5kAYx1h6QXQAt1psbrC2rrmOojW0fTeQzaxD1Q9RKS3zZ23MCvjjZpWLi00eCFWRHMk'; const STRIPE_PUBLISHABLE_KEY = 'pk_test_51P3kdqP0gvUw6M9C7ixPQHqbPcvga4G5kAYx1h6QXQAt1psbrC2rrmOojW0fTeQzaxD1Q9RKS3zZ23MCvjjZpWLi00eCFWRHMk';
@ -22,7 +22,7 @@ SplashScreen.preventAutoHideAsync().catch(() => {});
let splashHidden = false; let splashHidden = false;
function RootLayoutNav() { function RootLayoutNav() {
const colorScheme = useColorScheme(); const { resolvedTheme, isDark } = useTheme();
const { isAuthenticated, isInitializing } = useAuth(); const { isAuthenticated, isInitializing } = useAuth();
const segments = useSegments(); const segments = useSegments();
const navigationState = useRootNavigationState(); const navigationState = useRootNavigationState();
@ -71,19 +71,20 @@ function RootLayoutNav() {
} }
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={resolvedTheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" /> <Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} /> <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style={isDark ? 'light' : 'dark'} />
</ThemeProvider> </ThemeProvider>
); );
} }
export default function RootLayout() { export default function RootLayout() {
return ( return (
<WellNuoThemeProvider>
<StripeProvider <StripeProvider
publishableKey={STRIPE_PUBLISHABLE_KEY} publishableKey={STRIPE_PUBLISHABLE_KEY}
merchantIdentifier="merchant.com.wellnuo.app" merchantIdentifier="merchant.com.wellnuo.app"
@ -96,5 +97,6 @@ export default function RootLayout() {
</BeneficiaryProvider> </BeneficiaryProvider>
</AuthProvider> </AuthProvider>
</StripeProvider> </StripeProvider>
</WellNuoThemeProvider>
); );
} }

View File

@ -10,7 +10,8 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToastProvider } from '@/components/ui/Toast'; import { ToastProvider } from '@/components/ui/Toast';
import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { AuthProvider, useAuth } from '@/contexts/AuthContext';
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext'; 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 // Polyfill for $ to prevent ReferenceError: Cannot access '$' before initialization
// This is a workaround for some web-specific bundling issues in this project. // This is a workaround for some web-specific bundling issues in this project.
@ -28,10 +29,11 @@ SplashScreen.preventAutoHideAsync().catch(() => { });
let splashHidden = false; let splashHidden = false;
function RootLayoutNav() { function RootLayoutNav() {
const colorScheme = useColorScheme(); const { resolvedTheme, isDark } = useTheme();
const { isAuthenticated, isInitializing } = useAuth(); const { isAuthenticated, isInitializing } = useAuth();
const segments = useSegments(); const segments = useSegments();
const navigationState = useRootNavigationState(); const navigationState = useRootNavigationState();
const themeColors = Colors[resolvedTheme];
// Track if initial redirect was done // Track if initial redirect was done
const hasInitialRedirect = useRef(false); const hasInitialRedirect = useRef(false);
@ -77,15 +79,15 @@ function RootLayoutNav() {
} }
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={resolvedTheme === 'dark' ? DarkTheme : DefaultTheme}>
<View style={styles.webContainer}> <View style={[styles.webContainer, { backgroundColor: isDark ? '#1a1a2e' : '#e5e5e5' }]}>
<View style={styles.mobileWrapper}> <View style={[styles.mobileWrapper, { backgroundColor: themeColors.background }]}>
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" /> <Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} /> <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style={isDark ? 'light' : 'dark'} />
</View> </View>
</View> </View>
</ThemeProvider> </ThemeProvider>
@ -94,6 +96,7 @@ function RootLayoutNav() {
export default function RootLayout() { export default function RootLayout() {
return ( return (
<WellNuoThemeProvider>
<AuthProvider> <AuthProvider>
<BeneficiaryProvider> <BeneficiaryProvider>
<ToastProvider> <ToastProvider>
@ -101,21 +104,20 @@ export default function RootLayout() {
</ToastProvider> </ToastProvider>
</BeneficiaryProvider> </BeneficiaryProvider>
</AuthProvider> </AuthProvider>
</WellNuoThemeProvider>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
webContainer: { webContainer: {
flex: 1, flex: 1,
backgroundColor: '#e5e5e5', // Light gray background for desktop
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
mobileWrapper: { mobileWrapper: {
flex: 1, flex: 1,
width: '100%', width: '100%',
maxWidth: 430, // Mobile width constraint maxWidth: 430,
backgroundColor: '#fff',
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { shadowOffset: {
width: 0, width: 0,
@ -124,6 +126,6 @@ const styles = StyleSheet.create({
shadowOpacity: 0.25, shadowOpacity: 0.25,
shadowRadius: 3.84, shadowRadius: 3.84,
elevation: 5, elevation: 5,
overflow: 'hidden', // Clip content to rounded corners if we want overflow: 'hidden',
} }
}); });

View File

@ -72,36 +72,116 @@ export const AppColors = {
overlayLight: 'rgba(15, 23, 42, 0.3)', 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 tintColorLight = AppColors.primary;
const tintColorDark = AppColors.primaryLight; const tintColorDark = DarkColors.primary;
export const Colors = { export const Colors = {
light: { light: {
text: AppColors.textPrimary, text: AppColors.textPrimary,
textSecondary: AppColors.textSecondary,
textMuted: AppColors.textMuted,
background: AppColors.background, background: AppColors.background,
backgroundSecondary: AppColors.backgroundSecondary,
tint: tintColorLight, tint: tintColorLight,
icon: AppColors.textSecondary, icon: AppColors.textSecondary,
tabIconDefault: AppColors.textMuted, tabIconDefault: AppColors.textMuted,
tabIconSelected: tintColorLight, tabIconSelected: tintColorLight,
surface: AppColors.surface, surface: AppColors.surface,
surfaceSecondary: AppColors.surfaceSecondary,
border: AppColors.border, border: AppColors.border,
borderLight: AppColors.borderLight,
primary: AppColors.primary, primary: AppColors.primary,
primaryLight: AppColors.primaryLight,
error: AppColors.error, error: AppColors.error,
errorLight: AppColors.errorLight,
success: AppColors.success, success: AppColors.success,
successLight: AppColors.successLight,
warning: AppColors.warning,
warningLight: AppColors.warningLight,
accent: AppColors.accent,
accentLight: AppColors.accentLight,
overlay: AppColors.overlay,
}, },
dark: { dark: {
text: '#F1F5F9', text: DarkColors.textPrimary,
background: '#0F172A', textSecondary: DarkColors.textSecondary,
textMuted: DarkColors.textMuted,
background: DarkColors.background,
backgroundSecondary: DarkColors.backgroundSecondary,
tint: tintColorDark, tint: tintColorDark,
icon: '#94A3B8', icon: DarkColors.textSecondary,
tabIconDefault: '#64748B', tabIconDefault: DarkColors.textMuted,
tabIconSelected: tintColorDark, tabIconSelected: tintColorDark,
surface: '#1E293B', surface: DarkColors.surface,
border: '#334155', surfaceSecondary: DarkColors.surfaceSecondary,
primary: AppColors.primaryLight, border: DarkColors.border,
error: '#F87171', borderLight: DarkColors.borderLight,
success: '#34D399', 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,
}, },
}; };

98
contexts/ThemeContext.tsx Normal file
View File

@ -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<void>;
toggleTheme: () => Promise<void>;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const systemColorScheme = useSystemColorScheme();
const [themeMode, setThemeModeState] = useState<ThemeMode>('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 (
<ThemeContext.Provider
value={{
themeMode,
resolvedTheme,
isDark,
setThemeMode,
toggleTheme,
}}
>
{children}
</ThemeContext.Provider>
);
}
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);
}

View File

@ -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 }) => (
<ThemeProvider>{children}</ThemeProvider>
);
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');
});
});
});
});

View File

@ -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 }) => (
<ThemeProvider>{children}</ThemeProvider>
);
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);
});
});

View File

@ -1,16 +1,23 @@
/** /**
* Learn more about light and dark modes: * Theme color hook with dark mode support
* https://docs.expo.dev/guides/color-schemes/ * Uses ThemeContext for user preference, falls back to system preference
*/ */
import { Colors } from '@/constants/theme'; import { Colors } from '@/constants/theme';
import { useThemeOptional } from '@/contexts/ThemeContext';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
export type ThemeColorName = keyof typeof Colors.light & keyof typeof Colors.dark;
export function useThemeColor( export function useThemeColor(
props: { light?: string; dark?: string }, 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]; const colorFromProps = props[theme];
if (colorFromProps) { if (colorFromProps) {
@ -19,3 +26,34 @@ export function useThemeColor(
return Colors[theme][colorName]; 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';
}