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