- 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>
429 lines
13 KiB
TypeScript
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,
|
|
},
|
|
});
|