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