Skip to content

Commit 61611a7

Browse files
Jinwoo-Horca-ide
andcommitted
feat(mobile): interactive swipe-to-dismiss drawers and improved QR instructions
- Add shared BottomDrawer component with pan gesture tracking via reanimated - Drawer follows finger on swipe down, dismisses past threshold or on velocity - Rubber-band resistance on upward pull for tactile feel - Migrate all 5 modal components to use BottomDrawer - Replace plain text QR instructions with numbered steps with icons Co-authored-by: Orca <help@stably.ai>
1 parent eedada6 commit 61611a7

7 files changed

Lines changed: 523 additions & 579 deletions

File tree

mobile/app/pair-scan.tsx

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,25 @@ import { useState, useRef, useCallback } from 'react'
22
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
33
import { CameraView, useCameraPermissions } from 'expo-camera'
44
import { useRouter } from 'expo-router'
5+
import { Monitor, Settings, Smartphone } from 'lucide-react-native'
56
import { decodePairingUrl } from '../src/transport/pairing'
67
import { connect } from '../src/transport/rpc-client'
78
import { saveHost, getNextHostName } from '../src/transport/host-store'
89
import type { PairingOffer } from '../src/transport/types'
910
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
1011

12+
function Step({ number, icon, text }: { number: number; icon: React.ReactNode; text: string }) {
13+
return (
14+
<View style={styles.step}>
15+
<View style={styles.stepBadge}>
16+
<Text style={styles.stepNumber}>{number}</Text>
17+
</View>
18+
<View style={styles.stepIcon}>{icon}</View>
19+
<Text style={styles.stepText}>{text}</Text>
20+
</View>
21+
)
22+
}
23+
1124
export default function PairScanScreen() {
1225
const router = useRouter()
1326
const [permission, requestPermission] = useCameraPermissions()
@@ -107,9 +120,23 @@ export default function PairScanScreen() {
107120

108121
return (
109122
<View style={styles.container}>
110-
<Text style={styles.instruction}>
111-
Open Orca on your computer, go to Settings → Mobile, and scan the QR code shown there.
112-
</Text>
123+
<View style={styles.steps}>
124+
<Step
125+
number={1}
126+
icon={<Monitor size={14} color={colors.textSecondary} />}
127+
text="Open Orca on your computer"
128+
/>
129+
<Step
130+
number={2}
131+
icon={<Settings size={14} color={colors.textSecondary} />}
132+
text="Go to Settings → Mobile"
133+
/>
134+
<Step
135+
number={3}
136+
icon={<Smartphone size={14} color={colors.textSecondary} />}
137+
text="Point this camera at the QR code"
138+
/>
139+
</View>
113140

114141
{status === 'scanning' && (
115142
<CameraView
@@ -145,11 +172,36 @@ const styles = StyleSheet.create({
145172
backgroundColor: colors.bgBase,
146173
padding: spacing.lg
147174
},
148-
instruction: {
149-
fontSize: 13,
175+
steps: {
176+
gap: spacing.sm,
177+
marginBottom: spacing.lg
178+
},
179+
step: {
180+
flexDirection: 'row',
181+
alignItems: 'center',
182+
gap: spacing.sm
183+
},
184+
stepBadge: {
185+
width: 22,
186+
height: 22,
187+
borderRadius: 11,
188+
backgroundColor: colors.bgRaised,
189+
alignItems: 'center',
190+
justifyContent: 'center'
191+
},
192+
stepNumber: {
193+
fontSize: 12,
194+
fontWeight: '700',
195+
color: colors.textSecondary
196+
},
197+
stepIcon: {
198+
width: 20,
199+
alignItems: 'center'
200+
},
201+
stepText: {
202+
fontSize: typography.bodySize,
150203
color: colors.textSecondary,
151-
marginBottom: spacing.lg,
152-
lineHeight: 18
204+
flex: 1
153205
},
154206
camera: {
155207
flex: 1,

mobile/src/components/ActionSheetModal.tsx

Lines changed: 39 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Modal, View, Text, Pressable, StyleSheet, Platform } from 'react-native'
1+
import { View, Text, Pressable, StyleSheet } from 'react-native'
22
import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native'
33
import { colors, spacing, typography } from '../theme/mobile-theme'
4+
import { BottomDrawer } from './BottomDrawer'
45

56
export type ActionSheetAction = {
67
label: string
@@ -25,90 +26,50 @@ function iconForAction(label: string, destructive?: boolean, icon?: LucideIcon):
2526

2627
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
2728
return (
28-
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
29-
<Pressable style={styles.backdrop} onPress={onClose}>
30-
<View style={styles.drawer}>
31-
<View style={styles.handle} />
29+
<BottomDrawer visible={visible} onClose={onClose}>
30+
{(title || message) && (
31+
<View style={styles.header}>
32+
{title ? (
33+
<Text style={styles.title} numberOfLines={1}>
34+
{title}
35+
</Text>
36+
) : null}
37+
{message ? <Text style={styles.message}>{message}</Text> : null}
38+
</View>
39+
)}
3240

33-
{(title || message) && (
34-
<View style={styles.header}>
35-
{title ? (
36-
<Text style={styles.title} numberOfLines={1}>
37-
{title}
41+
<View style={styles.actionGroup}>
42+
{actions.map((action, i) => {
43+
const Icon = iconForAction(action.label, action.destructive, action.icon)
44+
return (
45+
<View key={action.label}>
46+
{i > 0 && <View style={styles.separator} />}
47+
<Pressable
48+
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
49+
onPress={() => {
50+
onClose()
51+
action.onPress()
52+
}}
53+
>
54+
<Icon
55+
size={16}
56+
color={action.destructive ? colors.statusRed : colors.textSecondary}
57+
/>
58+
<Text
59+
style={[styles.actionText, action.destructive && styles.actionTextDestructive]}
60+
>
61+
{action.label}
3862
</Text>
39-
) : null}
40-
{message ? <Text style={styles.message}>{message}</Text> : null}
63+
</Pressable>
4164
</View>
42-
)}
43-
44-
<View style={styles.actionGroup}>
45-
{actions.map((action, i) => {
46-
const Icon = iconForAction(action.label, action.destructive, action.icon)
47-
return (
48-
<View key={action.label}>
49-
{i > 0 && <View style={styles.separator} />}
50-
<Pressable
51-
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
52-
onPress={() => {
53-
onClose()
54-
action.onPress()
55-
}}
56-
>
57-
<Icon
58-
size={16}
59-
color={action.destructive ? colors.statusRed : colors.textSecondary}
60-
/>
61-
<Text
62-
style={[
63-
styles.actionText,
64-
action.destructive && styles.actionTextDestructive
65-
]}
66-
>
67-
{action.label}
68-
</Text>
69-
</Pressable>
70-
</View>
71-
)
72-
})}
73-
</View>
74-
</View>
75-
</Pressable>
76-
</Modal>
65+
)
66+
})}
67+
</View>
68+
</BottomDrawer>
7769
)
7870
}
7971

8072
const styles = StyleSheet.create({
81-
backdrop: {
82-
flex: 1,
83-
backgroundColor: 'rgba(0,0,0,0.5)',
84-
justifyContent: 'flex-end'
85-
},
86-
drawer: {
87-
backgroundColor: colors.bgBase,
88-
borderTopLeftRadius: 16,
89-
borderTopRightRadius: 16,
90-
paddingHorizontal: spacing.md,
91-
paddingBottom: spacing.xl + spacing.md,
92-
...Platform.select({
93-
ios: {
94-
shadowColor: '#000',
95-
shadowOffset: { width: 0, height: -2 },
96-
shadowOpacity: 0.2,
97-
shadowRadius: 10
98-
},
99-
android: { elevation: 8 }
100-
})
101-
},
102-
handle: {
103-
alignSelf: 'center',
104-
width: 36,
105-
height: 4,
106-
borderRadius: 2,
107-
backgroundColor: colors.textMuted,
108-
marginTop: spacing.sm,
109-
marginBottom: spacing.md,
110-
opacity: 0.4
111-
},
11273
header: {
11374
paddingHorizontal: spacing.xs,
11475
paddingBottom: spacing.sm
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { type ReactNode, useCallback, useEffect } from 'react'
2+
import { Modal, View, Pressable, StyleSheet, Platform, useWindowDimensions } from 'react-native'
3+
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
4+
import Animated, {
5+
useSharedValue,
6+
useAnimatedStyle,
7+
withSpring,
8+
withTiming,
9+
runOnJS,
10+
interpolate,
11+
Extrapolation
12+
} from 'react-native-reanimated'
13+
import { colors, spacing } from '../theme/mobile-theme'
14+
15+
const DISMISS_THRESHOLD = 80
16+
const SPRING_CONFIG = { damping: 20, stiffness: 300 }
17+
// Why: negative translateY (pulling up) is damped with a rubber-band factor
18+
// so the drawer resists upward dragging — a subtle polish touch that signals
19+
// the drawer cannot expand further.
20+
const RUBBER_BAND_FACTOR = 0.25
21+
22+
type Props = {
23+
visible: boolean
24+
onClose: () => void
25+
children: ReactNode
26+
}
27+
28+
export function BottomDrawer({ visible, onClose, children }: Props) {
29+
const translateY = useSharedValue(0)
30+
const backdropOpacity = useSharedValue(0)
31+
const { height: screenHeight } = useWindowDimensions()
32+
33+
useEffect(() => {
34+
if (visible) {
35+
translateY.value = 0
36+
backdropOpacity.value = withTiming(1, { duration: 200 })
37+
}
38+
}, [visible])
39+
40+
const dismiss = useCallback(() => {
41+
onClose()
42+
}, [onClose])
43+
44+
const panGesture = Gesture.Pan()
45+
.onUpdate((e) => {
46+
if (e.translationY > 0) {
47+
translateY.value = e.translationY
48+
} else {
49+
translateY.value = e.translationY * RUBBER_BAND_FACTOR
50+
}
51+
})
52+
.onEnd((e) => {
53+
if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
54+
translateY.value = withTiming(screenHeight, { duration: 200 })
55+
backdropOpacity.value = withTiming(0, { duration: 200 })
56+
runOnJS(dismiss)()
57+
} else {
58+
translateY.value = withSpring(0, SPRING_CONFIG)
59+
}
60+
})
61+
62+
const drawerStyle = useAnimatedStyle(() => ({
63+
transform: [{ translateY: translateY.value }]
64+
}))
65+
66+
const backdropStyle = useAnimatedStyle(() => ({
67+
opacity: interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP)
68+
}))
69+
70+
return (
71+
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
72+
<GestureHandlerRootView style={styles.root}>
73+
<Animated.View style={[styles.backdrop, backdropStyle]}>
74+
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
75+
</Animated.View>
76+
77+
<View style={styles.anchor} pointerEvents="box-none">
78+
<GestureDetector gesture={panGesture}>
79+
<Animated.View style={[styles.drawer, drawerStyle]}>
80+
<View style={styles.handle} />
81+
{children}
82+
</Animated.View>
83+
</GestureDetector>
84+
</View>
85+
</GestureHandlerRootView>
86+
</Modal>
87+
)
88+
}
89+
90+
const styles = StyleSheet.create({
91+
root: {
92+
flex: 1
93+
},
94+
backdrop: {
95+
...StyleSheet.absoluteFillObject,
96+
backgroundColor: 'rgba(0,0,0,0.5)'
97+
},
98+
anchor: {
99+
flex: 1,
100+
justifyContent: 'flex-end'
101+
},
102+
drawer: {
103+
backgroundColor: colors.bgBase,
104+
borderTopLeftRadius: 16,
105+
borderTopRightRadius: 16,
106+
paddingHorizontal: spacing.md,
107+
paddingBottom: spacing.xl + spacing.md,
108+
...Platform.select({
109+
ios: {
110+
shadowColor: '#000',
111+
shadowOffset: { width: 0, height: -2 },
112+
shadowOpacity: 0.2,
113+
shadowRadius: 10
114+
},
115+
android: { elevation: 8 }
116+
})
117+
},
118+
handle: {
119+
alignSelf: 'center',
120+
width: 36,
121+
height: 4,
122+
borderRadius: 2,
123+
backgroundColor: colors.textMuted,
124+
marginTop: spacing.sm,
125+
marginBottom: spacing.md,
126+
opacity: 0.4
127+
}
128+
})

0 commit comments

Comments
 (0)