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:
parent
290b0e218b
commit
610104090a
@ -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 (
|
||||
<TouchableOpacity
|
||||
style={styles.drawerItem}
|
||||
style={[styles.drawerItem, { borderBottomColor: colors.border }]}
|
||||
onPress={onPress}
|
||||
disabled={!onPress && !rightElement}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<View style={[styles.iconContainer, danger && styles.iconContainerDanger]}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: colors.surfaceSecondary }, danger && { backgroundColor: colors.errorLight }]}>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={22}
|
||||
color={danger ? AppColors.error : AppColors.textSecondary}
|
||||
color={danger ? colors.error : colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.drawerItemLabel, danger && styles.dangerText]}>
|
||||
<Text style={[styles.drawerItemLabel, { color: colors.text }, danger && { color: colors.error }]}>
|
||||
{label}
|
||||
</Text>
|
||||
{badge && (
|
||||
<Text style={styles.badgeText}>{badge}</Text>
|
||||
<Text style={[styles.badgeText, { color: colors.textMuted }]}>{badge}</Text>
|
||||
)}
|
||||
{rightElement || (onPress && (
|
||||
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textMuted} />
|
||||
))}
|
||||
</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 {
|
||||
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({
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{ transform: [{ translateX: slideAnim }] },
|
||||
{ transform: [{ translateX: slideAnim }], backgroundColor: colors.surface },
|
||||
]}
|
||||
>
|
||||
<SafeAreaView style={styles.drawerContent} edges={['left']}>
|
||||
{/* Header */}
|
||||
<View style={[styles.drawerHeader, { paddingTop: insets.top + Spacing.md }]}>
|
||||
<Text style={styles.drawerTitle}>Settings</Text>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<Ionicons name="close" size={24} color={AppColors.textSecondary} />
|
||||
<View style={[styles.drawerHeader, { paddingTop: insets.top + Spacing.md, borderBottomColor: colors.border }]}>
|
||||
<Text style={[styles.drawerTitle, { color: colors.text }]}>Settings</Text>
|
||||
<TouchableOpacity style={[styles.closeButton, { backgroundColor: colors.surfaceSecondary }]} onPress={onClose}>
|
||||
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<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 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Preferences</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.textMuted }]}>Preferences</Text>
|
||||
<DrawerItem
|
||||
icon="notifications-outline"
|
||||
label="Push Notifications"
|
||||
@ -147,9 +211,9 @@ export function ProfileDrawer({
|
||||
<Switch
|
||||
value={settings.pushNotifications}
|
||||
onValueChange={(v) => 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({
|
||||
<Switch
|
||||
value={settings.emailNotifications}
|
||||
onValueChange={(v) => 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 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.textMuted }]}>Account</Text>
|
||||
<DrawerItem
|
||||
icon="language-outline"
|
||||
label="Language"
|
||||
@ -181,7 +245,7 @@ export function ProfileDrawer({
|
||||
|
||||
{/* Support */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Support</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.textMuted }]}>Support</Text>
|
||||
<DrawerItem
|
||||
icon="help-circle-outline"
|
||||
label="Help Center"
|
||||
@ -201,7 +265,7 @@ export function ProfileDrawer({
|
||||
|
||||
{/* About */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>About</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.textMuted }]}>About</Text>
|
||||
<DrawerItem
|
||||
icon="information-circle-outline"
|
||||
label="About WellNuo"
|
||||
@ -212,8 +276,8 @@ export function ProfileDrawer({
|
||||
</ScrollView>
|
||||
|
||||
{/* Version */}
|
||||
<View style={styles.versionContainer}>
|
||||
<Text style={styles.versionText}>WellNuo v1.0.0</Text>
|
||||
<View style={[styles.versionContainer, { borderTopColor: colors.border }]}>
|
||||
<Text style={[styles.versionText, { color: colors.textMuted }]}>WellNuo v1.0.0</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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 (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<ThemeProvider value={resolvedTheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
<StatusBar style={isDark ? 'light' : 'dark'} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<StripeProvider
|
||||
publishableKey={STRIPE_PUBLISHABLE_KEY}
|
||||
merchantIdentifier="merchant.com.wellnuo.app"
|
||||
>
|
||||
<AuthProvider>
|
||||
<BeneficiaryProvider>
|
||||
<ToastProvider>
|
||||
<RootLayoutNav />
|
||||
</ToastProvider>
|
||||
</BeneficiaryProvider>
|
||||
</AuthProvider>
|
||||
</StripeProvider>
|
||||
<WellNuoThemeProvider>
|
||||
<StripeProvider
|
||||
publishableKey={STRIPE_PUBLISHABLE_KEY}
|
||||
merchantIdentifier="merchant.com.wellnuo.app"
|
||||
>
|
||||
<AuthProvider>
|
||||
<BeneficiaryProvider>
|
||||
<ToastProvider>
|
||||
<RootLayoutNav />
|
||||
</ToastProvider>
|
||||
</BeneficiaryProvider>
|
||||
</AuthProvider>
|
||||
</StripeProvider>
|
||||
</WellNuoThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<View style={styles.webContainer}>
|
||||
<View style={styles.mobileWrapper}>
|
||||
<ThemeProvider value={resolvedTheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<View style={[styles.webContainer, { backgroundColor: isDark ? '#1a1a2e' : '#e5e5e5' }]}>
|
||||
<View style={[styles.mobileWrapper, { backgroundColor: themeColors.background }]}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
<StatusBar style={isDark ? 'light' : 'dark'} />
|
||||
</View>
|
||||
</View>
|
||||
</ThemeProvider>
|
||||
@ -94,28 +96,28 @@ function RootLayoutNav() {
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BeneficiaryProvider>
|
||||
<ToastProvider>
|
||||
<RootLayoutNav />
|
||||
</ToastProvider>
|
||||
</BeneficiaryProvider>
|
||||
</AuthProvider>
|
||||
<WellNuoThemeProvider>
|
||||
<AuthProvider>
|
||||
<BeneficiaryProvider>
|
||||
<ToastProvider>
|
||||
<RootLayoutNav />
|
||||
</ToastProvider>
|
||||
</BeneficiaryProvider>
|
||||
</AuthProvider>
|
||||
</WellNuoThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
98
contexts/ThemeContext.tsx
Normal file
98
contexts/ThemeContext.tsx
Normal 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);
|
||||
}
|
||||
284
contexts/__tests__/ThemeContext.test.tsx
Normal file
284
contexts/__tests__/ThemeContext.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
165
hooks/__tests__/useThemeColor.test.tsx
Normal file
165
hooks/__tests__/useThemeColor.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user