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