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