1import Ionicons from '@expo/vector-icons/build/Ionicons'; 2import Slider from '@react-native-community/slider'; 3import * as BarCodeScanner from 'expo-barcode-scanner'; 4import { BlurView } from 'expo-blur'; 5import { Camera } from 'expo-camera'; 6import { CameraType } from 'expo-camera/build/Camera.types'; 7import * as Haptics from 'expo-haptics'; 8import * as React from 'react'; 9import { 10 Animated, 11 Easing, 12 Platform, 13 StyleProp, 14 StyleSheet, 15 Text, 16 TouchableOpacity, 17 View, 18 ViewStyle, 19 Pressable, 20} from 'react-native'; 21import { Path, Svg, SvgProps } from 'react-native-svg'; 22 23import Colors from '../constants/Colors'; 24import usePermissions from '../utilities/usePermissions'; 25 26function useCameraTypes(): CameraType[] | null { 27 const [types, setTypes] = React.useState<CameraType[] | null>(null); 28 29 React.useEffect(() => { 30 let isMounted = true; 31 if (Platform.OS !== 'web') { 32 setTypes([CameraType.front, CameraType.back]); 33 } else { 34 // TODO: This method isn't supported on native 35 Camera.getAvailableCameraTypesAsync().then((types) => { 36 if (isMounted) { 37 setTypes(types as CameraType[]); 38 } 39 }); 40 } 41 return () => { 42 isMounted = false; 43 }; 44 }, []); 45 return types; 46} 47 48function useToggleCameraType(preferredInitialType: CameraType): { 49 // The current camera type, null when loading types. 50 type: CameraType | null; 51 // Available camera types, null when loading types. 52 types: CameraType[] | null; 53 // Toggle the current camera type to the next available camera type, null when toggling isn't possible (1 or less cameras on the device). 54 toggle: null | (() => CameraType); 55} { 56 const [type, setType] = React.useState<CameraType | null>(null); 57 const types = useCameraTypes(); 58 59 React.useEffect(() => { 60 if (!types) return; 61 if (types.includes(preferredInitialType)) { 62 setType(preferredInitialType); 63 } else { 64 setType(types[0]); 65 } 66 }, [types]); 67 68 const toggle = 69 types && types.length > 1 70 ? () => { 71 const selectedIndex = types.findIndex((c) => c === type); 72 const nextIndex = (selectedIndex + 1) % types.length; 73 setType(types[nextIndex]); 74 return types[nextIndex]; 75 } 76 : null; 77 78 return { type, toggle, types }; 79} 80 81function useCameraAvailable(): boolean { 82 const [isAvailable, setAvailable] = React.useState(false); 83 84 React.useEffect(() => { 85 let isMounted = true; 86 if (Platform.OS !== 'web') { 87 setAvailable(true); 88 } else { 89 // TODO: This method isn't supported on native 90 Camera.isAvailableAsync().then((isAvailable) => { 91 if (isMounted) { 92 setAvailable(isAvailable); 93 } 94 }); 95 } 96 return () => { 97 isMounted = false; 98 }; 99 }, []); 100 return isAvailable; 101} 102 103export default function QRCodeScreen() { 104 const [isPermissionsGranted] = usePermissions(Camera.requestPermissionsAsync); 105 const isAvailable = useCameraAvailable(); 106 107 if (!isPermissionsGranted || !isAvailable) { 108 // this can also occur if the device doesn't have a camera 109 const message = isAvailable 110 ? 'You have not granted permission to use the camera on this device!' 111 : 'Your device does not have a camera'; 112 return ( 113 <View style={styles.container}> 114 <Text>{message}</Text> 115 </View> 116 ); 117 } 118 119 return <QRCodeView />; 120} 121 122QRCodeScreen.navigationOptions = { 123 title: 'QR Code', 124}; 125 126function QRCodeView() { 127 const [data, setData] = React.useState<string | null>(null); 128 const [isLit, setLit] = React.useState(false); 129 const [zoom, setZoom] = React.useState(0); 130 const { type, toggle } = useToggleCameraType(CameraType.back); 131 132 const onFlashToggle = React.useCallback(() => { 133 setLit((isLit) => !isLit); 134 }, []); 135 136 // hide footer when no actions are possible -- i.e. desktop web 137 const showFooter = !!toggle || type === CameraType.back; 138 139 // TODO(Bacon): We need a way to determine if the current camera supports certain capabilities (like zooming). 140 const supportsZoom = true; 141 142 return ( 143 <View style={styles.container}> 144 {type && ( 145 <OverlayView 146 style={StyleSheet.absoluteFill} 147 renderOverlay={() => ( 148 <View 149 style={{ 150 position: 'absolute', 151 bottom: 8, 152 left: 24, 153 right: 24, 154 alignItems: 'center', 155 }}> 156 <Slider 157 disabled={!supportsZoom} 158 minimumTrackTintColor={Colors.tintColor} 159 thumbTintColor={Colors.tintColor} 160 value={zoom} 161 onValueChange={setZoom} 162 style={{ flex: 1, maxWidth: 560, width: '95%' }} 163 /> 164 </View> 165 )}> 166 <Camera 167 type={type} 168 zoom={zoom} 169 barCodeScannerSettings={{ 170 interval: 1000, 171 barCodeTypes: [ 172 BarCodeScanner.Constants.BarCodeType.qr, 173 BarCodeScanner.Constants.BarCodeType.pdf417, 174 ], 175 }} 176 onBarCodeScanned={(incoming) => { 177 if (data !== incoming.data) { 178 console.log('found: ', incoming); 179 setData(incoming.data); 180 } 181 }} 182 style={{ flex: 1 }} 183 flashMode={isLit ? 'torch' : 'off'} 184 /> 185 </OverlayView> 186 )} 187 188 <View pointerEvents="none" style={[styles.header, { top: 40 }]}> 189 {data && <Hint>{data}</Hint>} 190 </View> 191 192 <QRIndicator /> 193 194 {showFooter && ( 195 <View pointerEvents="box-none" style={[styles.footer, { bottom: 30 }]}> 196 <QRFooterButton disabled={!toggle} onPress={toggle} iconName="camera-reverse" /> 197 <QRFooterButton 198 disabled={type !== CameraType.back} 199 onPress={onFlashToggle} 200 isActive={isLit} 201 iconName="ios-flashlight" 202 /> 203 </View> 204 )} 205 </View> 206 ); 207} 208 209function OverlayView({ 210 style, 211 renderOverlay, 212 ...props 213}: React.ComponentProps<typeof View> & { 214 renderOverlay: () => React.ReactNode; 215 children?: React.ReactNode; 216}) { 217 const [isOverlayActive, setOverlayActive] = React.useState(false); 218 const timer = React.useRef<number | undefined>(); 219 const opacity = React.useRef(new Animated.Value(0)); 220 221 React.useEffect(() => { 222 Animated.timing(opacity.current, { 223 toValue: isOverlayActive ? 1 : 0, 224 duration: 500, 225 useNativeDriver: true, 226 }).start(); 227 }, [isOverlayActive]); 228 229 const onPress = () => { 230 clearTimeout(timer.current); 231 setOverlayActive(true); 232 // @ts-expect-error: TS resolves node types first 233 timer.current = setTimeout(() => { 234 setOverlayActive(() => false); 235 }, 5000); 236 }; 237 238 return ( 239 <Pressable style={style} onPress={onPress}> 240 {props.children} 241 <Animated.View 242 pointerEvents={isOverlayActive ? 'box-none' : 'none'} 243 style={[StyleSheet.absoluteFill, { opacity: opacity.current }]}> 244 {renderOverlay()} 245 </Animated.View> 246 </Pressable> 247 ); 248} 249 250function Hint({ children }: { children: string }) { 251 return ( 252 <BlurView style={styles.hint} intensity={100} tint="dark"> 253 <Text style={styles.headerText}>{children}</Text> 254 </BlurView> 255 ); 256} 257 258function QRIndicator() { 259 const scale = React.useMemo(() => new Animated.Value(1), []); 260 const duration = 500; 261 React.useEffect(() => { 262 let mounted = true; 263 264 function cycleAnimation() { 265 Animated.sequence([ 266 Animated.timing(scale, { 267 easing: Easing.in(Easing.quad), 268 toValue: 1, 269 duration, 270 useNativeDriver: true, 271 }), 272 Animated.timing(scale, { 273 easing: Easing.out(Easing.quad), 274 toValue: 1.05, 275 duration, 276 useNativeDriver: true, 277 }), 278 ]).start(() => { 279 if (mounted) { 280 cycleAnimation(); 281 } 282 }); 283 } 284 cycleAnimation(); 285 return () => { 286 mounted = false; 287 }; 288 }, []); 289 290 return ( 291 <AnimatedScanner 292 pointerEvents="none" 293 style={[ 294 // shadow is only properly supported on iOS 295 Platform.OS === 'ios' && styles.scanner, 296 { 297 transform: [{ scale }], 298 }, 299 ]} 300 /> 301 ); 302} 303 304class SvgComponent extends React.Component<SvgProps> { 305 render() { 306 const props = { ...this.props }; 307 if (Platform.OS === 'web') { 308 delete props.collapsable; 309 } 310 return ( 311 <Svg width={258} height={258} viewBox="0 0 258 258" fill="none" {...props}> 312 <Path 313 d="M211 250a4 4 0 000 8v-8zm47-39a4 4 0 00-8 0h8zm-11.5 34l-2.948-2.703L246.5 245zM211 258c6.82 0 14.15-.191 20.795-1.495 6.629-1.3 13.067-3.799 17.653-8.802l-5.896-5.406c-2.944 3.21-7.457 5.212-13.297 6.358C224.433 249.798 217.777 250 211 250v8zm38.448-10.297c4.209-4.59 6.258-10.961 7.322-17.287 1.076-6.395 1.23-13.307 1.23-19.416h-8c0 6.056-.162 12.398-1.119 18.089-.969 5.759-2.669 10.306-5.329 13.208l5.896 5.406zM250 47a4 4 0 008 0h-8zM211 0a4 4 0 000 8V0zm34 11.5l-2.703 2.948L245 11.5zM258 47c0-6.82-.191-14.15-1.495-20.795-1.3-6.629-3.799-13.067-8.802-17.653l-5.406 5.896c3.21 2.944 5.212 7.457 6.358 13.297C249.798 33.568 250 40.223 250 47h8zM247.703 8.552c-4.59-4.209-10.961-6.258-17.287-7.322C224.021.154 217.109 0 211 0v8c6.056 0 12.398.162 18.089 1.119 5.759.969 10.306 2.67 13.208 5.33l5.406-5.897zM8 211a4 4 0 00-8 0h8zm39 47a4 4 0 000-8v8zm-34-11.5l2.703-2.948L13 246.5zM0 211c0 6.82.19 14.15 1.495 20.795 1.3 6.629 3.799 13.067 8.802 17.653l5.406-5.896c-3.21-2.944-5.212-7.457-6.358-13.297C8.202 224.433 8 217.777 8 211H0zm10.297 38.448c4.59 4.209 10.961 6.258 17.287 7.322C33.98 257.846 40.892 258 47 258v-8c-6.056 0-12.398-.162-18.088-1.119-5.76-.969-10.307-2.669-13.209-5.329l-5.406 5.896zM47 8a4 4 0 000-8v8zM0 47a4 4 0 008 0H0zm11.5-34l2.948 2.703L11.5 13zM47 0c-6.82 0-14.15.19-20.795 1.495-6.629 1.3-13.067 3.799-17.653 8.802l5.896 5.406c2.944-3.21 7.457-5.212 13.297-6.358C33.568 8.202 40.223 8 47 8V0zM8.552 10.297c-4.209 4.59-6.258 10.961-7.322 17.287C.154 33.98 0 40.892 0 47h8c0-6.056.162-12.398 1.119-18.088.969-5.76 2.67-10.307 5.33-13.209l-5.897-5.406z" 314 fill="#fff" 315 /> 316 </Svg> 317 ); 318 } 319} 320 321const AnimatedScanner = Animated.createAnimatedComponent(SvgComponent); 322 323// note(bacon): Purposefully skip using the themed icons since we want the icons to change color based on toggle state. 324const shouldUseHaptics = Platform.OS === 'ios'; 325 326const size = 64; 327const slop = 40; 328 329const hitSlop = { top: slop, bottom: slop, right: slop, left: slop }; 330 331function QRFooterButton({ 332 onPress, 333 isActive = false, 334 iconName, 335 iconSize = 36, 336 style, 337 disabled, 338}: { 339 style?: StyleProp<ViewStyle>; 340 onPress?: (() => void) | null; 341 isActive?: boolean; 342 iconName: React.ComponentProps<typeof Ionicons>['name']; 343 iconSize?: number; 344 disabled?: boolean; 345}) { 346 const tint = isActive ? 'default' : 'dark'; 347 const iconColor = isActive ? Colors.tintColor : '#ffffff'; 348 349 const onPressIn = React.useCallback(() => { 350 if (shouldUseHaptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); 351 }, []); 352 353 const onPressButton = React.useCallback(() => { 354 onPress?.(); 355 if (shouldUseHaptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); 356 }, [onPress]); 357 358 return ( 359 <TouchableOpacity 360 style={[style, { opacity: disabled ? 0.5 : 1.0 }]} 361 disabled={disabled || !onPress} 362 hitSlop={hitSlop} 363 onPressIn={onPressIn} 364 onPress={onPressButton}> 365 <BlurView intensity={100} style={styles.buttonContainer} tint={tint}> 366 <Ionicons name={iconName} size={iconSize} color={iconColor} /> 367 </BlurView> 368 </TouchableOpacity> 369 ); 370} 371 372const styles = StyleSheet.create({ 373 buttonContainer: { 374 width: size, 375 height: size, 376 borderRadius: size / 2, 377 overflow: 'hidden', 378 justifyContent: 'center', 379 alignItems: 'center', 380 }, 381 scanner: { 382 shadowColor: '#000', 383 shadowOffset: { 384 width: 0, 385 height: 1, 386 }, 387 shadowOpacity: 0.22, 388 shadowRadius: 2.22, 389 }, 390 container: { 391 flex: 1, 392 backgroundColor: '#fff', 393 justifyContent: 'center', 394 alignItems: 'center', 395 }, 396 hint: { 397 paddingHorizontal: 16, 398 paddingVertical: 20, 399 borderRadius: 16, 400 justifyContent: 'center', 401 alignItems: 'center', 402 }, 403 header: { 404 position: 'absolute', 405 left: 0, 406 right: 0, 407 alignItems: 'center', 408 }, 409 headerText: { 410 color: '#fff', 411 backgroundColor: 'transparent', 412 textAlign: 'center', 413 fontSize: 16, 414 fontWeight: '500', 415 }, 416 footer: { 417 position: 'absolute', 418 left: 0, 419 right: 0, 420 alignItems: 'center', 421 flexDirection: 'row', 422 justifyContent: 'space-between', 423 paddingHorizontal: '10%', 424 }, 425}); 426