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