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