diff --git a/assets/images/camera-flip.svg b/assets/images/camera-flip.svg new file mode 100644 index 0000000000000..6d05251e0c777 --- /dev/null +++ b/assets/images/camera-flip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jest/setup.ts b/jest/setup.ts index ee2b1702ba1e9..ea2a0d44086ed 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -375,3 +375,10 @@ jest.mock('@src/hooks/useDomainDocumentTitle', () => ({ __esModule: true, default: jest.fn(), })); + +jest.mock('react-native-vision-camera', () => ({ + Camera: 'Camera', + useCameraDevice: jest.fn(() => null), + useCameraFormat: jest.fn(() => null), + useCameraPermission: jest.fn(() => ({hasPermission: false, requestPermission: jest.fn()})), +})); diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 856389fb39cc0..2f9c3d7ec2b7d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1266,6 +1266,7 @@ const CONST = { SHUTTER_SIZE: 90, MAX_REPORT_PREVIEW_RECEIPTS: 3, FLASH_DELAY_MS: 2000, + PHOTO_ASPECT_RATIO: 4 / 3, }, RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120, REPORT: { diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx new file mode 100644 index 0000000000000..080407d17691f --- /dev/null +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -0,0 +1,253 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {Alert, Modal, View} from 'react-native'; +import {RESULTS} from 'react-native-permissions'; +import type {Camera, PhotoFile} from 'react-native-vision-camera'; +import {useCameraDevice, useCameraFormat, Camera as VisionCamera} from 'react-native-vision-camera'; +import ActivityIndicator from '@components/ActivityIndicator'; +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import ImageSVG from '@components/ImageSVG'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; +import getPhotoSource from '@libs/fileDownload/getPhotoSource'; +import Log from '@libs/Log'; +import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; +import CONST from '@src/CONST'; + +type CapturedPhoto = { + uri: string; + fileName: string; + type: string; + width: number; + height: number; +}; + +type AttachmentCameraProps = { + /** Whether the camera modal is visible */ + isVisible: boolean; + + /** Callback when a photo is captured */ + onCapture: (photos: CapturedPhoto[]) => void; + + /** Callback when the camera is closed without capturing */ + onClose: () => void; +}; + +function AttachmentCamera({isVisible, onCapture, onClose}: AttachmentCameraProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const insets = useSafeAreaInsets(); + + const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'boltSlash', 'CameraFlip']); + const lazyIllustrations = useMemoizedLazyIllustrations(['Shutter', 'Hand']); + + const camera = useRef(null); + const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); + const isCapturing = useRef(false); + const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>('back'); + + const device = useCameraDevice(cameraPosition, { + physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], + }); + const format = useCameraFormat(device, [{photoAspectRatio: CONST.RECEIPT.PHOTO_ASPECT_RATIO}, {photoResolution: 'max'}]); + const cameraAspectRatio = format ? format.photoHeight / format.photoWidth : undefined; + const hasFlash = !!device?.hasFlash; + + // Request camera permissions when modal opens + useEffect(() => { + if (!isVisible) { + return; + } + + CameraPermission.getCameraPermissionStatus?.() + .then((status) => { + if (status === RESULTS.DENIED) { + return CameraPermission.requestCameraPermission?.().then(setCameraPermissionStatus); + } + setCameraPermissionStatus(status); + }) + .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); + }, [isVisible]); + + const [flash, setFlash] = useState(false); + + const askForPermissions = useCallback(() => { + CameraPermission.requestCameraPermission?.() + .then((status: string) => { + setCameraPermissionStatus(status); + if (status === RESULTS.BLOCKED) { + showCameraPermissionsAlert(translate); + } + }) + .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); + }, [translate]); + + const capturePhoto = useCallback(() => { + if (!camera.current || isCapturing.current) { + return; + } + + if (cameraPermissionStatus === RESULTS.DENIED || cameraPermissionStatus === RESULTS.BLOCKED) { + askForPermissions(); + return; + } + + isCapturing.current = true; + + camera.current + .takePhoto({ + flash: flash && hasFlash ? 'on' : 'off', + }) + .then((photo: PhotoFile) => { + const uri = getPhotoSource(photo.path); + const fileName = photo.path.split('/').pop() ?? `photo_${Date.now()}.jpg`; + + onCapture([ + { + uri, + fileName, + type: 'image/jpeg', + width: photo.width, + height: photo.height, + }, + ]); + }) + .catch((error: unknown) => { + Log.warn('AttachmentCamera: Error taking photo', {error}); + Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); + }) + .finally(() => { + isCapturing.current = false; + }); + }, [cameraPermissionStatus, flash, hasFlash, onCapture, translate, askForPermissions]); + + return ( + + + + {/* Camera viewfinder area */} + + {cameraPermissionStatus !== RESULTS.GRANTED && ( + + + {translate('receipt.takePhoto')} + {translate('receipt.cameraAccess')} +