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