WellNuo/components/ProfileDrawer.tsx
Sergei 610104090a 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>
2026-02-01 10:01:07 -08:00

429 lines
13 KiB
TypeScript

import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Modal,
Animated,
Dimensions,
Switch,
ScrollView,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
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;
interface DrawerItemProps {
icon: keyof typeof Ionicons.glyphMap;
label: string;
onPress?: () => void;
rightElement?: React.ReactNode;
danger?: boolean;
badge?: string;
}
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, { borderBottomColor: colors.border }]}
onPress={onPress}
disabled={!onPress && !rightElement}
activeOpacity={0.6}
>
<View style={[styles.iconContainer, { backgroundColor: colors.surfaceSecondary }, danger && { backgroundColor: colors.errorLight }]}>
<Ionicons
name={icon}
size={22}
color={danger ? colors.error : colors.textSecondary}
/>
</View>
<Text style={[styles.drawerItemLabel, { color: colors.text }, danger && { color: colors.error }]}>
{label}
</Text>
{badge && (
<Text style={[styles.badgeText, { color: colors.textMuted }]}>{badge}</Text>
)}
{rightElement || (onPress && (
<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;
onLogout: () => void;
settings: {
pushNotifications: boolean;
emailNotifications: boolean;
biometricLogin: boolean;
};
onSettingChange: (key: string, value: boolean) => void;
}
export function ProfileDrawer({
visible,
onClose,
onLogout,
settings,
onSettingChange,
}: ProfileDrawerProps) {
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([
Animated.timing(slideAnim, {
toValue: visible ? 0 : -DRAWER_WIDTH,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: visible ? 1 : 0,
duration: 250,
useNativeDriver: true,
}),
]).start();
}, [visible]);
const handleNavigate = (route: string) => {
onClose();
router.push(route as any);
};
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<Animated.View
style={[styles.backdrop, { opacity: fadeAnim }]}
>
<TouchableOpacity
style={StyleSheet.absoluteFill}
activeOpacity={1}
onPress={onClose}
/>
</Animated.View>
<Animated.View
style={[
styles.drawer,
{ transform: [{ translateX: slideAnim }], backgroundColor: colors.surface },
]}
>
<SafeAreaView style={styles.drawerContent} edges={['left']}>
{/* Header */}
<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, { color: colors.textMuted }]}>Preferences</Text>
<DrawerItem
icon="notifications-outline"
label="Push Notifications"
rightElement={
<Switch
value={settings.pushNotifications}
onValueChange={(v) => onSettingChange('pushNotifications', v)}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#FFFFFF"
ios_backgroundColor={colors.border}
/>
}
/>
<DrawerItem
icon="mail-outline"
label="Email Notifications"
rightElement={
<Switch
value={settings.emailNotifications}
onValueChange={(v) => onSettingChange('emailNotifications', v)}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#FFFFFF"
ios_backgroundColor={colors.border}
/>
}
/>
</View>
{/* Account */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.textMuted }]}>Account</Text>
<DrawerItem
icon="language-outline"
label="Language"
badge="EN"
onPress={() => handleNavigate('/(tabs)/profile/language')}
/>
</View>
{/* Support */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.textMuted }]}>Support</Text>
<DrawerItem
icon="help-circle-outline"
label="Help Center"
onPress={() => handleNavigate('/(tabs)/profile/help')}
/>
<DrawerItem
icon="chatbubble-outline"
label="Contact Support"
onPress={() => handleNavigate('/(tabs)/profile/support')}
/>
<DrawerItem
icon="document-text-outline"
label="Terms & Privacy"
onPress={() => handleNavigate('/(tabs)/profile/terms')}
/>
</View>
{/* About */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.textMuted }]}>About</Text>
<DrawerItem
icon="information-circle-outline"
label="About WellNuo"
onPress={() => handleNavigate('/(tabs)/profile/about')}
/>
</View>
</ScrollView>
{/* Version */}
<View style={[styles.versionContainer, { borderTopColor: colors.border }]}>
<Text style={[styles.versionText, { color: colors.textMuted }]}>WellNuo v1.0.0</Text>
</View>
</SafeAreaView>
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
flexDirection: 'row',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.4)',
},
drawer: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: DRAWER_WIDTH,
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,
},
drawerHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.lg,
paddingBottom: Spacing.lg,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
drawerTitle: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
closeButton: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
},
drawerScroll: {
flex: 1,
},
section: {
paddingTop: Spacing.lg,
},
sectionTitle: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.semibold,
color: AppColors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: Spacing.sm,
marginLeft: Spacing.lg,
},
drawerItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.lg,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
iconContainerDanger: {
backgroundColor: AppColors.errorLight,
},
drawerItemLabel: {
flex: 1,
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
},
dangerText: {
color: AppColors.error,
},
badgeText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textMuted,
marginRight: Spacing.sm,
},
versionContainer: {
paddingVertical: Spacing.lg,
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: AppColors.border,
},
versionText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
},
});