1import Foundation from '@expo/vector-icons/build/Foundation';
2import Ionicons from '@expo/vector-icons/build/Ionicons';
3import MaterialCommunityIcons from '@expo/vector-icons/build/MaterialCommunityIcons';
4import MaterialIcons from '@expo/vector-icons/build/MaterialIcons';
5import Octicons from '@expo/vector-icons/build/Octicons';
6import { BarCodeScanner } from 'expo-barcode-scanner';
7import {
8  AutoFocus,
9  BarCodePoint,
10  BarCodeScanningResult,
11  Camera,
12  CameraType,
13  FlashMode,
14  PermissionStatus,
15  WhiteBalance,
16} from 'expo-camera';
17import Constants from 'expo-constants';
18import * as FileSystem from 'expo-file-system';
19import React from 'react';
20import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
21import { isIphoneX } from 'react-native-iphone-x-helper';
22import * as Svg from 'react-native-svg';
23
24import { face, landmarks } from '../../components/Face';
25import GalleryScreen from './GalleryScreen';
26
27interface Picture {
28  width: number;
29  height: number;
30  uri: string;
31  base64?: string;
32  exif?: any;
33}
34
35type FlashModeString = keyof typeof FlashMode;
36type AutoFocusString = keyof typeof AutoFocus;
37type WhiteBalanceString = keyof typeof WhiteBalance;
38
39const flashModeOrder: { [key: string]: FlashModeString } = {
40  off: 'on',
41  on: 'auto',
42  auto: 'torch',
43  torch: 'off',
44};
45
46const flashIcons: { [key: string]: string } = {
47  off: 'flash-off',
48  on: 'flash',
49  auto: 'flash-outline',
50  torch: 'flashlight',
51};
52
53const wbOrder: { [key: string]: WhiteBalanceString } = {
54  auto: 'sunny',
55  sunny: 'cloudy',
56  cloudy: 'shadow',
57  shadow: 'fluorescent',
58  fluorescent: 'incandescent',
59  incandescent: 'auto',
60};
61
62const wbIcons: { [key: string]: string } = {
63  auto: 'wb-auto',
64  sunny: 'wb-sunny',
65  cloudy: 'wb-cloudy',
66  shadow: 'beach-access',
67  fluorescent: 'wb-iridescent',
68  incandescent: 'wb-incandescent',
69};
70
71const photos: Picture[] = [];
72
73interface State {
74  flash: FlashModeString;
75  zoom: number;
76  autoFocus: AutoFocusString;
77  type: CameraType;
78  depth: number;
79  whiteBalance: WhiteBalanceString;
80  ratio: string;
81  ratios: any[];
82  barcodeScanning: boolean;
83  faceDetecting: boolean;
84  faces: any[];
85  cornerPoints?: BarCodePoint[];
86  barcodeData: string;
87  newPhotos: boolean;
88  permissionsGranted: boolean;
89  permission?: PermissionStatus;
90  pictureSize?: any;
91  pictureSizes: any[];
92  pictureSizeId: number;
93  showGallery: boolean;
94  showMoreOptions: boolean;
95}
96
97// See: https://github.com/expo/expo/pull/10229#discussion_r490961694
98// eslint-disable-next-line @typescript-eslint/ban-types
99export default class CameraScreen extends React.Component<{}, State> {
100  readonly state: State = {
101    flash: FlashMode.off,
102    zoom: 0,
103    autoFocus: AutoFocus.on,
104    type: CameraType.back,
105    depth: 0,
106    whiteBalance: WhiteBalance.auto,
107    ratio: '16:9',
108    ratios: [],
109    barcodeScanning: false,
110    faceDetecting: false,
111    faces: [],
112    cornerPoints: undefined,
113    barcodeData: '',
114    newPhotos: false,
115    permissionsGranted: false,
116    pictureSizes: [],
117    pictureSizeId: 0,
118    showGallery: false,
119    showMoreOptions: false,
120  };
121
122  camera?: Camera;
123
124  componentDidMount() {
125    if (Platform.OS !== 'web') {
126      this.ensureDirectoryExistsAsync();
127    }
128    Camera.requestCameraPermissionsAsync().then(({ status }) => {
129      this.setState({ permission: status, permissionsGranted: status === 'granted' });
130    });
131  }
132
133  async ensureDirectoryExistsAsync() {
134    try {
135      await FileSystem.makeDirectoryAsync(FileSystem.documentDirectory + 'photos');
136    } catch (error) {
137      // tslint:disable-next-line no-console
138      console.log(error, 'Directory exists');
139    }
140  }
141
142  getRatios = async () => this.camera!.getSupportedRatiosAsync();
143
144  toggleView = () =>
145    this.setState((state) => ({ showGallery: !state.showGallery, newPhotos: false }));
146
147  toggleMoreOptions = () => this.setState((state) => ({ showMoreOptions: !state.showMoreOptions }));
148
149  toggleFacing = () =>
150    this.setState((state) => ({
151      type: state.type === CameraType.back ? CameraType.front : CameraType.back,
152    }));
153
154  toggleFlash = () => this.setState((state) => ({ flash: flashModeOrder[state.flash] }));
155
156  setRatio = (ratio: string) => this.setState({ ratio });
157
158  toggleWB = () => this.setState((state) => ({ whiteBalance: wbOrder[state.whiteBalance] }));
159
160  toggleFocus = () =>
161    this.setState((state) => ({ autoFocus: state.autoFocus === 'on' ? 'off' : 'on' }));
162
163  zoomOut = () => this.setState((state) => ({ zoom: state.zoom - 0.1 < 0 ? 0 : state.zoom - 0.1 }));
164
165  zoomIn = () => this.setState((state) => ({ zoom: state.zoom + 0.1 > 1 ? 1 : state.zoom + 0.1 }));
166
167  setFocusDepth = (depth: number) => this.setState({ depth });
168
169  toggleBarcodeScanning = () =>
170    this.setState((state) => ({ barcodeScanning: !state.barcodeScanning }));
171
172  toggleFaceDetection = () => this.setState((state) => ({ faceDetecting: !state.faceDetecting }));
173
174  takePicture = () => {
175    if (this.camera) {
176      this.camera.takePictureAsync({ onPictureSaved: this.onPictureSaved });
177    }
178  };
179
180  // tslint:disable-next-line no-console
181  handleMountError = ({ message }: { message: string }) => console.error(message);
182
183  onPictureSaved = async (photo: Picture) => {
184    if (Platform.OS === 'web') {
185      photos.push(photo);
186    } else {
187      await FileSystem.moveAsync({
188        from: photo.uri,
189        to: `${FileSystem.documentDirectory}photos/${Date.now()}.jpg`,
190      });
191    }
192    this.setState({ newPhotos: true });
193  };
194
195  onBarCodeScanned = (code: BarCodeScanningResult) => {
196    console.log('Found: ', code);
197    this.setState((state) => ({
198      barcodeData: code.data,
199      cornerPoints: code.cornerPoints,
200    }));
201  };
202
203  onFacesDetected = ({ faces }: { faces: any }) => this.setState({ faces });
204
205  collectPictureSizes = async () => {
206    if (this.camera) {
207      const { ratio } = this.state;
208      const pictureSizes = await this.camera.getAvailablePictureSizesAsync(ratio);
209      let pictureSizeId = 0;
210      if (Platform.OS === 'ios') {
211        pictureSizeId = pictureSizes.indexOf('High');
212      } else {
213        // returned array is sorted in ascending order - default size is the largest one
214        pictureSizeId = pictureSizes.length - 1;
215      }
216      this.setState({ pictureSizes, pictureSizeId, pictureSize: pictureSizes[pictureSizeId] });
217    }
218  };
219
220  previousPictureSize = () => this.changePictureSize(1);
221  nextPictureSize = () => this.changePictureSize(-1);
222
223  changePictureSize = (direction: number) => {
224    this.setState((state) => {
225      let newId = state.pictureSizeId + direction;
226      const length = state.pictureSizes.length;
227      if (newId >= length) {
228        newId = 0;
229      } else if (newId < 0) {
230        newId = length - 1;
231      }
232      return {
233        pictureSize: state.pictureSizes[newId],
234        pictureSizeId: newId,
235      };
236    });
237  };
238
239  renderGallery() {
240    const localPhotos = photos.map((photo) => photo.uri);
241    return <GalleryScreen onPress={this.toggleView} photos={localPhotos} />;
242  }
243
244  renderFaces = () => (
245    <View style={styles.facesContainer} pointerEvents="none">
246      {this.state.faces.map(face)}
247    </View>
248  );
249
250  renderLandmarks = () => (
251    <View style={styles.facesContainer} pointerEvents="none">
252      {this.state.faces.map(landmarks)}
253    </View>
254  );
255
256  renderNoPermissions = () => (
257    <View style={styles.noPermissions}>
258      {this.state.permission && (
259        <View>
260          <Text style={{ color: '#4630ec', fontWeight: 'bold', textAlign: 'center', fontSize: 24 }}>
261            Permission {this.state.permission.toLowerCase()}!
262          </Text>
263          <Text style={{ color: '#595959', textAlign: 'center', fontSize: 20 }}>
264            You'll need to enable the camera permission to continue.
265          </Text>
266        </View>
267      )}
268    </View>
269  );
270
271  renderTopBar = () => (
272    <View style={styles.topBar}>
273      <TouchableOpacity style={styles.toggleButton} onPress={this.toggleFacing}>
274        <Ionicons name="camera-reverse" size={32} color="white" />
275      </TouchableOpacity>
276      <TouchableOpacity style={styles.toggleButton} onPress={this.toggleFlash}>
277        <Ionicons name={flashIcons[this.state.flash] as any} size={28} color="white" />
278      </TouchableOpacity>
279      <TouchableOpacity style={styles.toggleButton} onPress={this.toggleWB}>
280        <MaterialIcons name={wbIcons[this.state.whiteBalance] as any} size={32} color="white" />
281      </TouchableOpacity>
282      <TouchableOpacity style={styles.toggleButton} onPress={this.toggleFocus}>
283        <Text
284          style={[
285            styles.autoFocusLabel,
286            { color: this.state.autoFocus === 'on' ? 'white' : '#6b6b6b' },
287          ]}>
288          AF
289        </Text>
290      </TouchableOpacity>
291    </View>
292  );
293
294  renderBottomBar = () => (
295    <View style={styles.bottomBar}>
296      <TouchableOpacity style={styles.bottomButton} onPress={this.toggleMoreOptions}>
297        <Octicons name="kebab-horizontal" size={30} color="white" />
298      </TouchableOpacity>
299      <View style={{ flex: 0.4 }}>
300        <TouchableOpacity onPress={this.takePicture} style={{ alignSelf: 'center' }}>
301          <Ionicons name="ios-radio-button-on" size={70} color="white" />
302        </TouchableOpacity>
303      </View>
304      <TouchableOpacity style={styles.bottomButton} onPress={this.toggleView}>
305        <View>
306          <Foundation name="thumbnails" size={30} color="white" />
307          {this.state.newPhotos && <View style={styles.newPhotosDot} />}
308        </View>
309      </TouchableOpacity>
310    </View>
311  );
312
313  renderMoreOptions = () => (
314    <View style={styles.options}>
315      <View style={styles.detectors}>
316        <TouchableOpacity onPress={this.toggleFaceDetection}>
317          <MaterialIcons
318            name="tag-faces"
319            size={32}
320            color={this.state.faceDetecting ? 'white' : '#858585'}
321          />
322        </TouchableOpacity>
323        <TouchableOpacity onPress={this.toggleBarcodeScanning}>
324          <MaterialCommunityIcons
325            name="barcode-scan"
326            size={32}
327            color={this.state.barcodeScanning ? 'white' : '#858585'}
328          />
329        </TouchableOpacity>
330      </View>
331
332      <View style={styles.pictureSizeContainer}>
333        <Text style={styles.pictureQualityLabel}>Picture quality</Text>
334        <View style={styles.pictureSizeChooser}>
335          <TouchableOpacity onPress={this.previousPictureSize} style={{ padding: 6 }}>
336            <Ionicons name="arrow-back" size={14} color="white" />
337          </TouchableOpacity>
338          <View style={styles.pictureSizeLabel}>
339            <Text style={{ color: 'white' }}>{this.state.pictureSize}</Text>
340          </View>
341          <TouchableOpacity onPress={this.nextPictureSize} style={{ padding: 6 }}>
342            <Ionicons name="arrow-forward" size={14} color="white" />
343          </TouchableOpacity>
344        </View>
345      </View>
346    </View>
347  );
348  renderBarCode = () => {
349    const origin: BarCodePoint | undefined = this.state.cornerPoints
350      ? this.state.cornerPoints[0]
351      : undefined;
352    return (
353      <Svg.Svg style={styles.barcode} pointerEvents="none">
354        {origin && (
355          <Svg.Text fill="#CF4048" stroke="#CF4048" fontSize="14" x={origin.x} y={origin.y - 8}>
356            {this.state.barcodeData}
357          </Svg.Text>
358        )}
359
360        <Svg.Polygon
361          points={this.state.cornerPoints?.map((coord) => `${coord.x},${coord.y}`).join(' ')}
362          stroke="green"
363          strokeWidth={10}
364        />
365      </Svg.Svg>
366    );
367  };
368  renderCamera = () => (
369    <View style={{ flex: 1 }}>
370      <Camera
371        ref={(ref) => (this.camera = ref!)}
372        style={styles.camera}
373        onCameraReady={this.collectPictureSizes}
374        type={this.state.type}
375        flashMode={FlashMode[this.state.flash]}
376        autoFocus={AutoFocus[this.state.autoFocus]}
377        zoom={this.state.zoom}
378        whiteBalance={WhiteBalance[this.state.whiteBalance]}
379        ratio={this.state.ratio}
380        pictureSize={this.state.pictureSize}
381        onMountError={this.handleMountError}
382        onFacesDetected={this.state.faceDetecting ? this.onFacesDetected : undefined}
383        faceDetectorSettings={{
384          tracking: true,
385        }}
386        barCodeScannerSettings={{
387          barCodeTypes: [
388            BarCodeScanner.Constants.BarCodeType.qr,
389            BarCodeScanner.Constants.BarCodeType.pdf417,
390          ],
391        }}
392        onBarCodeScanned={this.state.barcodeScanning ? this.onBarCodeScanned : undefined}>
393        {this.renderTopBar()}
394
395        {this.renderBottomBar()}
396      </Camera>
397      {this.state.faceDetecting && this.renderFaces()}
398      {this.state.faceDetecting && this.renderLandmarks()}
399      {this.state.barcodeScanning && this.renderBarCode()}
400      {this.state.showMoreOptions && this.renderMoreOptions()}
401    </View>
402  );
403
404  render() {
405    const cameraScreenContent = this.state.permissionsGranted
406      ? this.renderCamera()
407      : this.renderNoPermissions();
408    const content = this.state.showGallery ? this.renderGallery() : cameraScreenContent;
409    return <View style={styles.container}>{content}</View>;
410  }
411}
412
413const styles = StyleSheet.create({
414  container: {
415    flex: 1,
416    backgroundColor: '#000',
417  },
418  camera: {
419    flex: 1,
420    justifyContent: 'space-between',
421  },
422  topBar: {
423    flex: 0.2,
424    backgroundColor: 'transparent',
425    flexDirection: 'row',
426    justifyContent: 'space-around',
427    paddingTop: Constants.statusBarHeight / 2,
428  },
429  bottomBar: {
430    paddingBottom: isIphoneX() ? 25 : 5,
431    backgroundColor: 'transparent',
432    justifyContent: 'space-between',
433    flexDirection: 'row',
434  },
435  noPermissions: {
436    flex: 1,
437    alignItems: 'center',
438    justifyContent: 'center',
439    padding: 10,
440    backgroundColor: '#f8fdff',
441  },
442  gallery: {
443    flex: 1,
444    flexDirection: 'row',
445    flexWrap: 'wrap',
446  },
447  toggleButton: {
448    flex: 0.25,
449    height: 40,
450    marginHorizontal: 2,
451    marginBottom: 10,
452    marginTop: 20,
453    padding: 5,
454    alignItems: 'center',
455    justifyContent: 'center',
456  },
457  autoFocusLabel: {
458    fontSize: 20,
459    fontWeight: 'bold',
460  },
461  bottomButton: {
462    flex: 0.3,
463    height: 58,
464    justifyContent: 'center',
465    alignItems: 'center',
466  },
467  newPhotosDot: {
468    position: 'absolute',
469    top: 0,
470    right: -5,
471    width: 8,
472    height: 8,
473    borderRadius: 4,
474    backgroundColor: '#4630EB',
475  },
476  options: {
477    position: 'absolute',
478    bottom: 80,
479    left: 30,
480    width: 200,
481    height: 160,
482    backgroundColor: '#000000BA',
483    borderRadius: 4,
484    padding: 10,
485  },
486  detectors: {
487    flex: 0.5,
488    justifyContent: 'space-around',
489    alignItems: 'center',
490    flexDirection: 'row',
491  },
492  pictureQualityLabel: {
493    fontSize: 10,
494    marginVertical: 3,
495    color: 'white',
496  },
497  pictureSizeContainer: {
498    flex: 0.5,
499    alignItems: 'center',
500    paddingTop: 10,
501  },
502  pictureSizeChooser: {
503    alignItems: 'center',
504    justifyContent: 'space-between',
505    flexDirection: 'row',
506  },
507  pictureSizeLabel: {
508    flex: 1,
509    alignItems: 'center',
510    justifyContent: 'center',
511  },
512  facesContainer: {
513    position: 'absolute',
514    bottom: 0,
515    right: 0,
516    left: 0,
517    top: 0,
518  },
519  row: {
520    flexDirection: 'row',
521  },
522  barcode: {
523    position: 'absolute',
524    borderWidth: 2,
525    borderColor: 'red',
526  },
527});
528