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    timer.current = setTimeout(() => {
232      setOverlayActive(() => false);
233    }, 5000);
234  };
235
236  return (
237    <Pressable style={style} onPress={onPress}>
238      {props.children}
239      <Animated.View
240        pointerEvents={isOverlayActive ? 'box-none' : 'none'}
241        style={[StyleSheet.absoluteFill, { opacity: opacity.current }]}>
242        {renderOverlay()}
243      </Animated.View>
244    </Pressable>
245  );
246}
247
248function Hint({ children }: { children: string }) {
249  return (
250    <BlurView style={styles.hint} intensity={100} tint="dark">
251      <Text style={styles.headerText}>{children}</Text>
252    </BlurView>
253  );
254}
255
256function QRIndicator() {
257  const scale = React.useMemo(() => new Animated.Value(1), []);
258  const duration = 500;
259  React.useEffect(() => {
260    let mounted = true;
261
262    function cycleAnimation() {
263      Animated.sequence([
264        Animated.timing(scale, {
265          easing: Easing.in(Easing.quad),
266          toValue: 1,
267          duration,
268          useNativeDriver: true,
269        }),
270        Animated.timing(scale, {
271          easing: Easing.out(Easing.quad),
272          toValue: 1.05,
273          duration,
274          useNativeDriver: true,
275        }),
276      ]).start(() => {
277        if (mounted) {
278          cycleAnimation();
279        }
280      });
281    }
282    cycleAnimation();
283    return () => {
284      mounted = false;
285    };
286  }, []);
287
288  return (
289    <AnimatedScanner
290      pointerEvents="none"
291      style={[
292        // shadow is only properly supported on iOS
293        Platform.OS === 'ios' && styles.scanner,
294        {
295          transform: [{ scale }],
296        },
297      ]}
298    />
299  );
300}
301
302class SvgComponent extends React.Component<SvgProps> {
303  render() {
304    const props = { ...this.props };
305    if (Platform.OS === 'web') {
306      delete props.collapsable;
307    }
308    return (
309      <Svg width={258} height={258} viewBox="0 0 258 258" fill="none" {...props}>
310        <Path
311          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"
312          fill="#fff"
313        />
314      </Svg>
315    );
316  }
317}
318
319const AnimatedScanner = Animated.createAnimatedComponent(SvgComponent);
320
321// note(bacon): Purposefully skip using the themed icons since we want the icons to change color based on toggle state.
322const shouldUseHaptics = Platform.OS === 'ios';
323
324const size = 64;
325const slop = 40;
326
327const hitSlop = { top: slop, bottom: slop, right: slop, left: slop };
328
329function QRFooterButton({
330  onPress,
331  isActive = false,
332  iconName,
333  iconSize = 36,
334  style,
335  disabled,
336}: {
337  style?: StyleProp<ViewStyle>;
338  onPress?: (() => void) | null;
339  isActive?: boolean;
340  iconName: React.ComponentProps<typeof Ionicons>['name'];
341  iconSize?: number;
342  disabled?: boolean;
343}) {
344  const tint = isActive ? 'default' : 'dark';
345  const iconColor = isActive ? Colors.tintColor : '#ffffff';
346
347  const onPressIn = React.useCallback(() => {
348    if (shouldUseHaptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
349  }, []);
350
351  const onPressButton = React.useCallback(() => {
352    onPress?.();
353    if (shouldUseHaptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
354  }, [onPress]);
355
356  return (
357    <TouchableOpacity
358      style={[style, { opacity: disabled ? 0.5 : 1.0 }]}
359      disabled={disabled || !onPress}
360      hitSlop={hitSlop}
361      onPressIn={onPressIn}
362      onPress={onPressButton}>
363      <BlurView intensity={100} style={styles.buttonContainer} tint={tint}>
364        <Ionicons name={iconName} size={iconSize} color={iconColor} />
365      </BlurView>
366    </TouchableOpacity>
367  );
368}
369
370const styles = StyleSheet.create({
371  buttonContainer: {
372    width: size,
373    height: size,
374    borderRadius: size / 2,
375    overflow: 'hidden',
376    justifyContent: 'center',
377    alignItems: 'center',
378  },
379  scanner: {
380    shadowColor: '#000',
381    shadowOffset: {
382      width: 0,
383      height: 1,
384    },
385    shadowOpacity: 0.22,
386    shadowRadius: 2.22,
387  },
388  container: {
389    flex: 1,
390    backgroundColor: '#fff',
391    justifyContent: 'center',
392    alignItems: 'center',
393  },
394  hint: {
395    paddingHorizontal: 16,
396    paddingVertical: 20,
397    borderRadius: 16,
398    justifyContent: 'center',
399    alignItems: 'center',
400  },
401  header: {
402    position: 'absolute',
403    left: 0,
404    right: 0,
405    alignItems: 'center',
406  },
407  headerText: {
408    color: '#fff',
409    backgroundColor: 'transparent',
410    textAlign: 'center',
411    fontSize: 16,
412    fontWeight: '500',
413  },
414  footer: {
415    position: 'absolute',
416    left: 0,
417    right: 0,
418    alignItems: 'center',
419    flexDirection: 'row',
420    justifyContent: 'space-between',
421    paddingHorizontal: '10%',
422  },
423});
424