Skip to content

Commit 75ccd71

Browse files
feat: bottom sheet component (#231)
* Creating bottom sheet component, adding animation, drag gesture support etc * Adding chat wizard to chat screen, hooking up to nav icon * Addressing PR comments
1 parent 3285891 commit 75ccd71

File tree

12 files changed

+347
-8
lines changed

12 files changed

+347
-8
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import {
3+
Animated,
4+
Dimensions,
5+
Modal,
6+
PanResponder,
7+
Pressable,
8+
StyleSheet,
9+
View,
10+
} from 'react-native';
11+
12+
import { bottomSheetStyle } from '@/styles';
13+
14+
type BottomSheetProps = {
15+
visible: boolean;
16+
onClose: () => void;
17+
children: React.ReactNode;
18+
snapHeight?: number;
19+
testID: string;
20+
};
21+
22+
const SCREEN_HEIGHT = Dimensions.get('window').height;
23+
const DEFAULT_SNAP_HEIGHT = 0.9;
24+
const CLOSE_THRESHOLD = 120;
25+
const DRAG_ACTIVATION_THRESHOLD = 5;
26+
27+
const BottomSheet = ({
28+
visible,
29+
onClose,
30+
children,
31+
snapHeight = DEFAULT_SNAP_HEIGHT,
32+
}: BottomSheetProps) => {
33+
const [internalVisible, setInternalVisible] = useState(false);
34+
const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current;
35+
const SHEET_MAX_HEIGHT = SCREEN_HEIGHT * snapHeight;
36+
37+
const sheetMaxHeightRef = useRef(SHEET_MAX_HEIGHT);
38+
39+
useEffect(() => {
40+
sheetMaxHeightRef.current = SCREEN_HEIGHT * snapHeight;
41+
}, [snapHeight]);
42+
43+
const panResponder = useRef(
44+
PanResponder.create({
45+
onStartShouldSetPanResponder: () => true,
46+
onMoveShouldSetPanResponder: (_, gestureState) =>
47+
Math.abs(gestureState.dy) > DRAG_ACTIVATION_THRESHOLD,
48+
onPanResponderMove: (_, gestureState) => {
49+
const sheetMaxHeight = sheetMaxHeightRef.current;
50+
const newY = SCREEN_HEIGHT - sheetMaxHeight + gestureState.dy;
51+
if (newY >= SCREEN_HEIGHT - sheetMaxHeight) {
52+
translateY.setValue(newY);
53+
}
54+
},
55+
onPanResponderRelease: (_, gestureState) => {
56+
if (gestureState.dy > CLOSE_THRESHOLD) {
57+
onClose();
58+
} else {
59+
Animated.spring(translateY, {
60+
toValue: SCREEN_HEIGHT - sheetMaxHeightRef.current,
61+
useNativeDriver: true,
62+
stiffness: 100,
63+
damping: 20,
64+
}).start();
65+
}
66+
},
67+
}),
68+
).current;
69+
70+
useEffect(() => {
71+
if (visible) {
72+
setInternalVisible(true);
73+
Animated.spring(translateY, {
74+
toValue: SCREEN_HEIGHT - sheetMaxHeightRef.current,
75+
useNativeDriver: true,
76+
stiffness: 250,
77+
damping: 28,
78+
mass: 1,
79+
}).start();
80+
} else {
81+
Animated.timing(translateY, {
82+
toValue: SCREEN_HEIGHT,
83+
duration: 220,
84+
useNativeDriver: true,
85+
}).start(() => setInternalVisible(false));
86+
}
87+
}, [visible, translateY]);
88+
89+
if (!internalVisible) {
90+
return null;
91+
}
92+
93+
return (
94+
<Modal transparent animationType="none" visible={internalVisible}>
95+
<Pressable style={styles.backdrop} onPress={onClose} />
96+
<Animated.View
97+
style={[styles.sheet, { height: SHEET_MAX_HEIGHT, transform: [{ translateY }] }]}
98+
>
99+
<View style={styles.grabber} {...panResponder.panHandlers} />
100+
{children}
101+
</Animated.View>
102+
</Modal>
103+
);
104+
};
105+
106+
const styles = StyleSheet.create({
107+
backdrop: bottomSheetStyle.backdrop,
108+
sheet: bottomSheetStyle.sheet,
109+
grabber: bottomSheetStyle.grabber,
110+
});
111+
112+
export default BottomSheet;

app/src/components/navigation/CreateChatButton.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@ import { TouchableOpacity, StyleSheet } from 'react-native';
22

33
import OutlinedIcon from '@/components/common/OutlinedIcon';
44
import { navigationStyle } from '@/styles';
5+
import { TestIDs } from '@/utils/testID';
56

6-
const CreateChatButton = () => {
7+
type CreateChatButtonProps = {
8+
onPress?: () => void;
9+
};
10+
11+
const CreateChatButton = ({ onPress }: CreateChatButtonProps) => {
712
return (
8-
<TouchableOpacity style={styles.topBarIconWrapper} onPress={() => {}}>
13+
<TouchableOpacity
14+
style={styles.topBarIconWrapper}
15+
onPress={onPress}
16+
testID={TestIDs.CHAT_CREATE_BUTTON}
17+
>
918
<OutlinedIcon
1019
name="edit-square"
1120
color={navigationStyle.header.rightIconColorSecondary}

app/src/i18n/en/chat.json

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
{
2-
"chatsScreen": {
3-
"emptyStateTitle": "No chats",
4-
"emptyStateDescription": "No chats found."
5-
},
2+
"chatsScreen": {
3+
"emptyStateTitle": "No chats",
4+
"emptyStateDescription": "No chats found."
5+
},
66
"messagesScreen": {
77
"emptyStateTitle": "No messages",
88
"emptyStateDescription": "No messages in this conversation yet."
99
},
1010
"chat": {
1111
"messageInputPlaceholder": "Type a message"
12+
},
13+
"createChatWizard": {
14+
"title": "Create chat",
15+
"next": "Next",
16+
"cancel": "Cancel"
1217
}
1318
}

app/src/screens/chat/ChatScreen.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { useNavigation, useFocusEffect } from '@react-navigation/native';
22
import type { StackNavigationProp } from '@react-navigation/stack';
33
import { useQueryClient } from '@tanstack/react-query';
4-
import { useEffect, useCallback } from 'react';
4+
import { useEffect, useCallback, useLayoutEffect, useState } from 'react';
55
import { useTranslation } from 'react-i18next';
66

77
import StatusMessage from '@/components/common/EmptyStateHelper';
88
import ListViewChat from '@/components/list/ListViewChat';
9+
import CreateChatButton from '@/components/navigation/CreateChatButton';
910
import { useAccount } from '@/context/AccountContext';
1011
import { useChats, ChatsProvider } from '@/context/ChatsContext';
1112
import { useSignalR } from '@/context/SignalRContext';
13+
import CreateChatModal from '@/screens/chat/CreateChatModal';
1214
import type { RootStackParamList } from '@/types/navigation';
1315
import type { EntitySubscription } from '@/types/signalr';
1416
import { TestIDs } from '@/utils/testID';
@@ -30,6 +32,8 @@ const ChatScreenContent = () => {
3032
fetchChats,
3133
} = useChats();
3234

35+
const [isCreateChatVisible, setCreateChatVisible] = useState(false);
36+
3337
const { userData } = useAccount();
3438
const userId = userData?.id;
3539
const currentAccountId = userData?.currentAccount?.id;
@@ -40,6 +44,12 @@ const ChatScreenContent = () => {
4044
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
4145
const { subscribe, addMessageListener, isConnected } = useSignalR();
4246

47+
useLayoutEffect(() => {
48+
navigation.setOptions({
49+
headerRight: () => <CreateChatButton onPress={() => setCreateChatVisible(true)} />,
50+
});
51+
}, [navigation, setCreateChatVisible]);
52+
4353
useEffect(() => {
4454
void subscribe(CHAT_SUBSCRIPTIONS);
4555
}, [subscribe, isConnected]);
@@ -91,6 +101,7 @@ const ChatScreenContent = () => {
91101
});
92102
}}
93103
/>
104+
<CreateChatModal visible={isCreateChatVisible} onClose={() => setCreateChatVisible(false)} />
94105
</StatusMessage>
95106
);
96107
};
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { useNavigation } from '@react-navigation/native';
2+
import type { StackNavigationProp } from '@react-navigation/stack';
3+
import { useState, useCallback } from 'react';
4+
import { useTranslation } from 'react-i18next';
5+
import {
6+
View,
7+
Text,
8+
TextInput,
9+
ScrollView,
10+
StyleSheet,
11+
KeyboardAvoidingView,
12+
TouchableOpacity,
13+
Platform,
14+
Button,
15+
} from 'react-native';
16+
17+
import BottomSheet from '@/components/modal/BottomSheet';
18+
import { createChatWizardStyle, inputStyle } from '@/styles';
19+
import type { RootStackParamList } from '@/types/navigation';
20+
import { TestIDs } from '@/utils/testID';
21+
22+
type Props = {
23+
visible: boolean;
24+
onClose: () => void;
25+
};
26+
27+
const CreateChatModal = ({ visible, onClose }: Props) => {
28+
const [step, setStep] = useState(0);
29+
const [chatType, setChatType] = useState<'group' | 'direct' | null>(null);
30+
const [chatName, setChatName] = useState('');
31+
32+
const { t } = useTranslation();
33+
34+
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>();
35+
36+
const handleClose = useCallback(() => {
37+
setStep(0);
38+
setChatType(null);
39+
setChatName('');
40+
onClose();
41+
}, [onClose]);
42+
43+
const handleNext = () => {
44+
if (step === 0) {
45+
if (!chatType) return;
46+
setStep(1);
47+
} else if (step === 1) {
48+
if (!chatName.trim()) return;
49+
setStep(2);
50+
} else if (step === 2) {
51+
navigation.navigate('chatConversation', { id: 'CHT-123-4565' });
52+
handleClose();
53+
}
54+
};
55+
56+
return (
57+
<BottomSheet visible={visible} onClose={handleClose} testID={TestIDs.CHAT_CREATE_MODAL}>
58+
<View style={styles.headerRow}>
59+
<View style={styles.headerSide}>
60+
<TouchableOpacity onPress={handleClose}>
61+
<Text style={styles.headerTextCancel}>{t('createChatWizard.cancel')}</Text>
62+
</TouchableOpacity>
63+
</View>
64+
<View style={styles.headerCenter}>
65+
<Text style={styles.headerTitle}>{t('createChatWizard.title')}</Text>
66+
</View>
67+
<View style={styles.headerSide}>
68+
{step > 0 && (
69+
<TouchableOpacity onPress={handleNext}>
70+
<Text style={styles.headerTextNext}>{t('createChatWizard.next')}</Text>
71+
</TouchableOpacity>
72+
)}
73+
</View>
74+
</View>
75+
<KeyboardAvoidingView
76+
behavior="height"
77+
keyboardVerticalOffset={Platform.OS === 'ios' ? 200 : 0}
78+
>
79+
{step === 0 && (
80+
<View>
81+
<Button
82+
title="Group Chat"
83+
onPress={() => {
84+
setChatType('group');
85+
setStep(1);
86+
}}
87+
/>
88+
<Button
89+
title="Direct Chat: John Doe"
90+
onPress={() => {
91+
handleClose();
92+
navigation.navigate('chatConversation', { id: 'CHT-234-2345' });
93+
}}
94+
/>
95+
</View>
96+
)}
97+
98+
{step === 1 && (
99+
<View>
100+
<TextInput
101+
placeholder="Enter chat name"
102+
value={chatName}
103+
onChangeText={setChatName}
104+
style={styles.input}
105+
autoFocus
106+
/>
107+
</View>
108+
)}
109+
110+
{step === 2 && (
111+
<View>
112+
<ScrollView>
113+
<Text>User list placeholder</Text>
114+
</ScrollView>
115+
</View>
116+
)}
117+
</KeyboardAvoidingView>
118+
</BottomSheet>
119+
);
120+
};
121+
122+
const styles = StyleSheet.create({
123+
headerRow: createChatWizardStyle.headerRow,
124+
headerSide: createChatWizardStyle.headerSide,
125+
headerCenter: createChatWizardStyle.headerCenter,
126+
headerTitle: createChatWizardStyle.headerTitle,
127+
headerTextCancel: createChatWizardStyle.headerTextCancel,
128+
headerTextNext: createChatWizardStyle.headerTextNext,
129+
input: inputStyle.container,
130+
});
131+
132+
export default CreateChatModal;

app/src/screens/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export { default as EnrollmentDetailsScreen } from './enrollments/EnrollmentDeta
2828
export { default as CertificateDetailsScreen } from './certificates/CertificateDetailsScreen';
2929
export { default as ChatScreen } from './chat/ChatScreen';
3030
export { default as ChatConversationScreen } from './chat/ChatConversationScreen';
31+
export { default as CreateChatModal } from './chat/CreateChatModal';
3132

3233
export { WelcomeScreen } from './auth';
3334
export { LoadingScreen } from './loading';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { BorderRadius, Color, Spacing } from '../tokens';
2+
3+
export const bottomSheetStyle = {
4+
backdrop: {
5+
position: 'absolute',
6+
left: Spacing.spacing0,
7+
right: Spacing.spacing0,
8+
top: Spacing.spacing0,
9+
bottom: Spacing.spacing0,
10+
backgroundColor: Color.fills.overlay,
11+
},
12+
sheet: {
13+
position: 'absolute',
14+
bottom: Spacing.spacing0,
15+
width: '100%',
16+
backgroundColor: Color.brand.white,
17+
borderTopLeftRadius: BorderRadius.md,
18+
borderTopRightRadius: BorderRadius.md,
19+
paddingHorizontal: Spacing.spacing2,
20+
paddingBottom: Spacing.spacing2,
21+
},
22+
grabber: {
23+
width: 36,
24+
height: 5,
25+
borderRadius: BorderRadius.xxs,
26+
backgroundColor: Color.gray.gray3,
27+
alignSelf: 'center',
28+
marginTop: Spacing.spacingSmall6,
29+
},
30+
} as const;

0 commit comments

Comments
 (0)