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( 49 preferredInitialType: CameraType 50): { 51 // The current camera type, null when loading types. 52 type: CameraType | null; 53 // Available camera types, null when loading types. 54 types: CameraType[] | null; 55 // Toggle the current camera type to the next available camera type, null when toggling isn't possible (1 or less cameras on the device). 56 toggle: null | (() => CameraType); 57} { 58 const [type, setType] = React.useState<CameraType | null>(null); 59 const types = useCameraTypes(); 60 61 React.useEffect(() => { 62 if (!types) return; 63 if (types.includes(preferredInitialType)) { 64 setType(preferredInitialType); 65 } else { 66 setType(types[0]); 67 } 68 }, [types]); 69 70 const toggle = 71 types && types.length > 1 72 ? () => { 73 const selectedIndex = types.findIndex(c => c === type); 74 const nextIndex = (selectedIndex + 1) % types.length; 75 setType(types[nextIndex]); 76 return types[nextIndex]; 77 } 78 : null; 79 80 return { type, toggle, types }; 81} 82 83function useCameraAvailable(): boolean { 84 const [isAvailable, setAvailable] = React.useState(false); 85 86 React.useEffect(() => { 87 let isMounted = true; 88 if (Platform.OS !== 'web') { 89 setAvailable(true); 90 } else { 91 // TODO: This method isn't supported on native 92 Camera.isAvailableAsync().then(isAvailable => { 93 if (isMounted) { 94 setAvailable(isAvailable); 95 } 96 }); 97 } 98 return () => { 99 isMounted = false; 100 }; 101 }, []); 102 return isAvailable; 103} 104 105export default function QRCodeScreen() { 106 const [isPermissionsGranted] = usePermissions(Camera.requestPermissionsAsync); 107 const isAvailable = useCameraAvailable(); 108 109 if (!isPermissionsGranted || !isAvailable) { 110 // this can also occur if the device doesn't have a camera 111 const message = isAvailable 112 ? 'You have not granted permission to use the camera on this device!' 113 : 'Your device does not have a camera'; 114 return ( 115 <View style={styles.container}> 116 <Text>{message}</Text> 117 </View> 118 ); 119 } 120 121 return <QRCodeView />; 122} 123 124QRCodeScreen.navigationOptions = { 125 title: 'QR Code', 126}; 127 128function QRCodeView() { 129 const [data, setData] = React.useState<string | null>(null); 130 const [isLit, setLit] = React.useState(false); 131 const [zoom, setZoom] = React.useState(0); 132 const { type, toggle } = useToggleCameraType(CameraType.back); 133 134 const onFlashToggle = React.useCallback(() => { 135 setLit(isLit => !isLit); 136 }, []); 137 138 // hide footer when no actions are possible -- i.e. desktop web 139 const showFooter = !!toggle || type === CameraType.back; 140 141 // TODO(Bacon): We need a way to determine if the current camera supports certain capabilities (like zooming). 142 const supportsZoom = true; 143 144 return ( 145 <View style={styles.container}> 146 {type && ( 147 <OverlayView 148 style={StyleSheet.absoluteFill} 149 renderOverlay={() => ( 150 <View 151 style={{ 152 position: 'absolute', 153 bottom: 8, 154 left: 24, 155 right: 24, 156 alignItems: 'center', 157 }}> 158 <Slider 159 disabled={!supportsZoom} 160 minimumTrackTintColor={Colors.tintColor} 161 thumbTintColor={Colors.tintColor} 162 value={zoom} 163 onValueChange={setZoom} 164 style={{ flex: 1, maxWidth: 560, width: '95%' }} 165 /> 166 </View> 167 )}> 168 <Camera 169 type={type} 170 zoom={zoom} 171 barCodeScannerSettings={{ 172 interval: 1000, 173 barCodeTypes: [ 174 BarCodeScanner.Constants.BarCodeType.qr, 175 BarCodeScanner.Constants.BarCodeType.pdf417, 176 ], 177 }} 178 onBarCodeScanned={incoming => { 179 if (data !== incoming.data) { 180 console.log('found: ', incoming); 181 setData(incoming.data); 182 } 183 }} 184 style={{ flex: 1 }} 185 flashMode={isLit ? 'torch' : 'off'} 186 /> 187 </OverlayView> 188 )} 189 190 <View pointerEvents="none" style={[styles.header, { top: 40 }]}> 191 {data && <Hint>{data}</Hint>} 192 </View> 193 194 <QRIndicator /> 195 196 {showFooter && ( 197 <View pointerEvents="box-none" style={[styles.footer, { bottom: 30 }]}> 198 <QRFooterButton disabled={!toggle} onPress={toggle} iconName="camera-reverse" /> 199 <QRFooterButton 200 disabled={type !== CameraType.back} 201 onPress={onFlashToggle} 202 isActive={isLit} 203 iconName="ios-flashlight" 204 /> 205 </View> 206 )} 207 </View> 208 ); 209} 210 211function OverlayView({ 212 style, 213 renderOverlay, 214 ...props 215}: React.ComponentProps<typeof View> & { 216 renderOverlay: () => React.ReactNode; 217 children?: React.ReactNode; 218}) { 219 const [isOverlayActive, setOverlayActive] = React.useState(false); 220 const timer = React.useRef<number | undefined>(); 221 const opacity = React.useRef(new Animated.Value(0)); 222 223 React.useEffect(() => { 224 Animated.timing(opacity.current, { 225 toValue: isOverlayActive ? 1 : 0, 226 duration: 500, 227 useNativeDriver: true, 228 }).start(); 229 }, [isOverlayActive]); 230 231 const onPress = () => { 232 clearTimeout(timer.current); 233 setOverlayActive(true); 234 // @ts-expect-error: TS resolves node types first 235 timer.current = setTimeout(() => { 236 setOverlayActive(() => false); 237 }, 5000); 238 }; 239 240 return ( 241 <Pressable style={style} onPress={onPress}> 242 {props.children} 243 <Animated.View 244 pointerEvents={isOverlayActive ? 'box-none' : 'none'} 245 style={[StyleSheet.absoluteFill, { opacity: opacity.current }]}> 246 {renderOverlay()} 247 </Animated.View> 248 </Pressable> 249 ); 250} 251 252function Hint({ children }: { children: string }) { 253 return ( 254 <BlurView style={styles.hint} intensity={100} tint="dark"> 255 <Text style={styles.headerText}>{children}</Text> 256 </BlurView> 257 ); 258} 259 260function QRIndicator() { 261 const scale = React.useMemo(() => new Animated.Value(1), []); 262 const duration = 500; 263 React.useEffect(() => { 264 let mounted = true; 265 266 function cycleAnimation() { 267 Animated.sequence([ 268 Animated.timing(scale, { 269 easing: Easing.in(Easing.quad), 270 toValue: 1, 271 duration, 272 useNativeDriver: true, 273 }), 274 Animated.timing(scale, { 275 easing: Easing.out(Easing.quad), 276 toValue: 1.05, 277 duration, 278 useNativeDriver: true, 279 }), 280 ]).start(() => { 281 if (mounted) { 282 cycleAnimation(); 283 } 284 }); 285 } 286 cycleAnimation(); 287 return () => { 288 mounted = false; 289 }; 290 }, []); 291 292 return ( 293 <AnimatedScanner 294 pointerEvents="none" 295 style={[ 296 // shadow is only properly supported on iOS 297 Platform.OS === 'ios' && styles.scanner, 298 { 299 transform: [{ scale }], 300 }, 301 ]} 302 /> 303 ); 304} 305 306class SvgComponent extends React.Component<SvgProps> { 307 render() { 308 const props = { ...this.props }; 309 if (Platform.OS === 'web') { 310 delete props.collapsable; 311 } 312 return ( 313 <Svg width={258} height={258} viewBox="0 0 258 258" fill="none" {...props}> 314 <Path 315 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" 316 fill="#fff" 317 /> 318 </Svg> 319 ); 320 } 321} 322 323const AnimatedScanner = Animated.createAnimatedComponent(SvgComponent); 324 325// note(bacon): Purposefully skip using the themed icons since we want the icons to change color based on toggle state. 326const shouldUseHaptics = Platform.OS === 'ios'; 327 328const size = 64; 329const slop = 40; 330 331const hitSlop = { top: slop, bottom: slop, right: slop, left: slop }; 332 333function QRFooterButton({ 334 onPress, 335 isActive = false, 336 iconName, 337 iconSize = 36, 338 style, 339 disabled, 340}: { 341 style?: StyleProp<ViewStyle>; 342 onPress?: (() => void) | null; 343 isActive?: boolean; 344 iconName: React.ComponentProps<typeof Ionicons>['name']; 345 iconSize?: number; 346 disabled?: boolean; 347}) { 348 const tint = isActive ? 'default' : 'dark'; 349 const iconColor = isActive ? Colors.tintColor : '#ffffff'; 350 351 const onPressIn = React.useCallback(() => { 352 if (shouldUseHaptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); 353 }, []); 354 355 const onPressButton = React.useCallback(() => { 356 onPress?.(); 357 if (shouldUseHaptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); 358 }, [onPress]); 359 360 return ( 361 <TouchableOpacity 362 style={[style, { opacity: disabled ? 0.5 : 1.0 }]} 363 disabled={disabled || !onPress} 364 hitSlop={hitSlop} 365 onPressIn={onPressIn} 366 onPress={onPressButton}> 367 <BlurView intensity={100} style={styles.buttonContainer} tint={tint}> 368 <Ionicons name={iconName} size={iconSize} color={iconColor} /> 369 </BlurView> 370 </TouchableOpacity> 371 ); 372} 373 374const styles = StyleSheet.create({ 375 buttonContainer: { 376 width: size, 377 height: size, 378 borderRadius: size / 2, 379 overflow: 'hidden', 380 justifyContent: 'center', 381 alignItems: 'center', 382 }, 383 scanner: { 384 shadowColor: '#000', 385 shadowOffset: { 386 width: 0, 387 height: 1, 388 }, 389 shadowOpacity: 0.22, 390 shadowRadius: 2.22, 391 }, 392 container: { 393 flex: 1, 394 backgroundColor: '#fff', 395 justifyContent: 'center', 396 alignItems: 'center', 397 }, 398 hint: { 399 paddingHorizontal: 16, 400 paddingVertical: 20, 401 borderRadius: 16, 402 justifyContent: 'center', 403 alignItems: 'center', 404 }, 405 header: { 406 position: 'absolute', 407 left: 0, 408 right: 0, 409 alignItems: 'center', 410 }, 411 headerText: { 412 color: '#fff', 413 backgroundColor: 'transparent', 414 textAlign: 'center', 415 fontSize: 16, 416 fontWeight: '500', 417 }, 418 footer: { 419 position: 'absolute', 420 left: 0, 421 right: 0, 422 alignItems: 'center', 423 flexDirection: 'row', 424 justifyContent: 'space-between', 425 paddingHorizontal: '10%', 426 }, 427}); 428