/ components / BottomPopup.tsx
BottomPopup.tsx
1 import React, { useEffect } from 'react'; 2 import { 3 View, 4 Text, 5 TouchableOpacity, 6 StyleSheet, 7 Modal, 8 Dimensions, 9 TouchableWithoutFeedback, 10 useColorScheme, 11 StatusBar, 12 Platform, 13 } from 'react-native'; 14 import { Colors, ColorScheme } from '@/constants/Colors'; 15 import { useTheme } from '@/constants/ThemeContext'; 16 import { Ionicons } from '@expo/vector-icons'; 17 import { useSafeAreaInsets } from 'react-native-safe-area-context'; 18 import Animated, { 19 useSharedValue, 20 useAnimatedStyle, 21 withSpring, 22 withTiming, 23 Easing, 24 } from 'react-native-reanimated'; 25 import { BottomPopupProps } from '@/types'; 26 27 const BottomPopup: React.FC<BottomPopupProps> = ({ 28 visible, 29 title, 30 onClose, 31 options, 32 }) => { 33 const { theme } = useTheme(); 34 const systemColorScheme = useColorScheme() as ColorScheme; 35 const colorScheme = 36 theme === 'system' ? systemColorScheme : (theme as ColorScheme); 37 const colors = Colors[colorScheme]; 38 const insets = useSafeAreaInsets(); 39 40 const styles = getStyles(colors, insets); 41 const screenHeight = Dimensions.get('window').height; 42 43 const translateY = useSharedValue(screenHeight); 44 const overlayOpacity = useSharedValue(0); 45 46 useEffect(() => { 47 if (visible) { 48 // Show the modal 49 StatusBar.setBarStyle('light-content'); 50 if (Platform.OS === 'android') { 51 StatusBar.setBackgroundColor('rgba(0,0,0,0.05)'); 52 } 53 translateY.value = withSpring(0, { 54 damping: 25, 55 stiffness: 200, 56 mass: 0.5, 57 overshootClamping: false, 58 restDisplacementThreshold: 0.1, 59 restSpeedThreshold: 0.1, 60 }); 61 overlayOpacity.value = withTiming(1, { 62 duration: 100, 63 easing: Easing.out(Easing.ease), 64 }); 65 } else { 66 // Hide the modal 67 StatusBar.setBarStyle('default'); 68 if (Platform.OS === 'android') { 69 StatusBar.setBackgroundColor('transparent'); 70 } 71 translateY.value = withTiming(screenHeight, { 72 duration: 100, 73 easing: Easing.in(Easing.ease), 74 }); 75 overlayOpacity.value = withTiming(0, { 76 duration: 100, 77 easing: Easing.in(Easing.ease), 78 }); 79 } 80 }, [visible]); 81 82 const containerAnimatedStyle = useAnimatedStyle(() => { 83 return { 84 transform: [{ translateY: translateY.value }], 85 }; 86 }); 87 88 const overlayAnimatedStyle = useAnimatedStyle(() => { 89 return { 90 opacity: overlayOpacity.value, 91 }; 92 }); 93 94 return ( 95 <Modal 96 transparent={true} 97 visible={visible} 98 animationType="none" 99 onRequestClose={onClose} 100 statusBarTranslucent={true} 101 > 102 <View style={styles.modalContainer}> 103 <TouchableWithoutFeedback onPress={onClose}> 104 <Animated.View style={[styles.modalOverlay, overlayAnimatedStyle]} /> 105 </TouchableWithoutFeedback> 106 <Animated.View style={[styles.container, containerAnimatedStyle]}> 107 <View style={styles.handle} /> 108 <View style={styles.header}> 109 <Text testID="bottom-popup-title" style={styles.title}> 110 {title} 111 </Text> 112 <TouchableOpacity 113 testID="close-button" 114 onPress={onClose} 115 style={styles.closeButton} 116 > 117 <Ionicons name="close" size={24} color={colors.text} /> 118 </TouchableOpacity> 119 </View> 120 <View style={styles.optionsContainer}> 121 {options?.map((option, index) => ( 122 <TouchableOpacity 123 key={index} 124 style={styles.optionButton} 125 onPress={() => { 126 option.onPress(); 127 onClose(); 128 }} 129 > 130 {option.icon && ( 131 <View style={styles.iconContainer}> 132 <Ionicons 133 name={option.icon} 134 size={24} 135 color={colors.primary} 136 /> 137 </View> 138 )} 139 <Text style={styles.optionText}>{option.text}</Text> 140 </TouchableOpacity> 141 ))} 142 </View> 143 </Animated.View> 144 </View> 145 </Modal> 146 ); 147 }; 148 149 const getStyles = (colors: typeof Colors.light, insets: { bottom: number }) => 150 StyleSheet.create({ 151 modalContainer: { 152 flex: 1, 153 justifyContent: 'flex-end', 154 }, 155 modalOverlay: { 156 ...StyleSheet.absoluteFillObject, 157 backgroundColor: 'rgba(0,0,0,0.5)', 158 }, 159 container: { 160 backgroundColor: colors.card, 161 borderTopLeftRadius: 24, 162 borderTopRightRadius: 24, 163 paddingTop: 12, 164 paddingHorizontal: 24, 165 paddingBottom: insets.bottom + 24, 166 maxHeight: '80%', 167 shadowColor: '#000', 168 shadowOffset: { width: 0, height: -3 }, 169 shadowOpacity: 0.1, 170 shadowRadius: 5, 171 elevation: 5, 172 }, 173 handle: { 174 width: 40, 175 height: 4, 176 backgroundColor: colors.border, 177 borderRadius: 2, 178 alignSelf: 'center', 179 marginBottom: 16, 180 }, 181 header: { 182 flexDirection: 'row', 183 justifyContent: 'space-between', 184 alignItems: 'center', 185 marginBottom: 24, 186 }, 187 title: { 188 fontSize: 22, 189 color: colors.text, 190 fontWeight: 'bold', 191 }, 192 closeButton: { 193 padding: 8, 194 borderRadius: 20, 195 backgroundColor: colors.background, 196 }, 197 optionsContainer: { 198 marginTop: 8, 199 }, 200 optionButton: { 201 flexDirection: 'row', 202 alignItems: 'center', 203 paddingVertical: 16, 204 borderBottomWidth: StyleSheet.hairlineWidth, 205 borderBottomColor: colors.border, 206 }, 207 iconContainer: { 208 width: 40, 209 height: 40, 210 borderRadius: 20, 211 backgroundColor: colors.background, 212 justifyContent: 'center', 213 alignItems: 'center', 214 marginRight: 16, 215 }, 216 optionText: { 217 fontSize: 18, 218 color: colors.text, 219 flex: 1, 220 }, 221 }); 222 223 export default BottomPopup;