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 {
|
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
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:
|
* 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';
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user