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