diff --git a/assets/images/odometer-end.svg b/assets/images/odometer-end.svg index beef1b152efcf..4b8994a9f4381 100644 --- a/assets/images/odometer-end.svg +++ b/assets/images/odometer-end.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/odometer-start.svg b/assets/images/odometer-start.svg index 8b7fac2b7aa93..3f97c5d22c3d1 100644 --- a/assets/images/odometer-start.svg +++ b/assets/images/odometer-start.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/CONST/index.ts b/src/CONST/index.ts index bdb5ef4f3b30c..d1e1f462513fa 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1189,6 +1189,7 @@ const CONST = { HAND_ICON_WIDTH: 200, SHUTTER_SIZE: 90, MAX_REPORT_PREVIEW_RECEIPTS: 3, + FLASH_DELAY_MS: 2000, }, RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120, REPORT: { @@ -8706,6 +8707,8 @@ const CONST = { FLASH: 'OdometerImage-Flash', GALLERY: 'OdometerImage-Gallery', SHUTTER: 'OdometerImage-Shutter', + CHOOSE_FILE: 'OdometerImage-ChooseFile', + CONTINUE_BUTTON: 'OdometerImage-ContinueButton', }, }, NEW_CHAT: { diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx index 9ac8167a7fc61..ec5a2e08214b8 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -281,6 +281,7 @@ function IOURequestStepOdometerImage({ accessibilityLabel={translate('common.continue')} style={[styles.p9, styles.pt5]} onPress={capturePhoto} + sentryLabel={CONST.SENTRY_LABEL.REQUEST_STEP.ODOMETER_IMAGE.CONTINUE_BUTTON} /> )} diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index 0600c037240c6..51d9e9347f512 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -1,21 +1,32 @@ -import React, {useCallback, useEffect, useRef} from 'react'; -import {View} from 'react-native'; +import {useIsFocused} from '@react-navigation/native'; +import React, {useCallback, useEffect, useReducer, useRef, useState} from 'react'; +import {PanResponder, View} from 'react-native'; +import type {LayoutRectangle} from 'react-native'; +import type Webcam from 'react-webcam'; +import ActivityIndicator from '@components/ActivityIndicator'; import AttachmentPicker from '@components/AttachmentPicker'; import Button from '@components/Button'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import {useDragAndDropState} from '@components/DragAndDrop/Provider'; import DropZoneUI from '@components/DropZone/DropZoneUI'; import Icon from '@components/Icon'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import useFilesValidation from '@hooks/useFilesValidation'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {isMobile, isMobileWebKit} from '@libs/Browser'; +import {base64ToFile} from '@libs/fileDownload/FileUtils'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; +import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; +import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; +import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/WebCamera'; import StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper'; import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound'; @@ -26,6 +37,7 @@ import type {IOUAction, IOUType} from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {FileObject} from '@src/types/utils/Attachment'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; type IOURequestStepOdometerImageProps = WithFullTransactionOrNotFoundProps; @@ -38,7 +50,6 @@ function IOURequestStepOdometerImage({ const styles = useThemeStyles(); const theme = useTheme(); const {isDraggingOver} = useDragAndDropState(); - const lazyIcons = useMemoizedLazyExpensifyIcons(['OdometerStart', 'OdometerEnd']); const actionValue: IOUAction = action ?? CONST.IOU.ACTION.CREATE; const iouTypeValue: IOUType = iouType ?? CONST.IOU.TYPE.REQUEST; const isTransactionDraft = shouldUseTransactionDraft(actionValue, iouTypeValue); @@ -48,34 +59,35 @@ function IOURequestStepOdometerImage({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); + const [cameraPermissionState, setCameraPermissionState] = useState('prompt'); + const [isFlashLightOn, toggleFlashlight] = useReducer((state) => !state, false); + const [isTorchAvailable, setIsTorchAvailable] = useState(false); + const cameraRef = useRef(null); + const trackRef = useRef(null); + const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(false); + const getScreenshotTimeoutRef = useRef(null); + const [videoConstraints, setVideoConstraints] = useState(); + const isTabActive = useIsFocused(); + + const lazyIcons = useMemoizedLazyExpensifyIcons(['OdometerStart', 'OdometerEnd', 'Bolt', 'Gallery']); + const lazyIllustrations = useMemoizedLazyIllustrations(['Hand', 'Shutter']); const title = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.startTitle') : translate('distance.odometer.endTitle'); const message = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.startMessageWeb') : translate('distance.odometer.endMessageWeb'); + const snapPhotoText = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.snapPhotoStart') : translate('distance.odometer.snapPhotoEnd'); const icon = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? lazyIcons.OdometerStart : lazyIcons.OdometerEnd; const messageHTML = `${message}`; const odometerRoute = ROUTES.DISTANCE_REQUEST_CREATE_TAB_ODOMETER.getRoute(action, iouType, transactionID, reportID); - const navigateBack = useCallback(() => { + const navigateBack = () => { Navigation.goBack(odometerRoute); - }, [odometerRoute]); + }; - const revokeDropBlobUrls = useCallback(() => { - for (const url of dropBlobUrlsRef.current) { - if (url.startsWith('blob:')) { - URL.revokeObjectURL(url); - } - } - dropBlobUrlsRef.current = []; - }, []); - - const handleImageSelected = useCallback( - (file: FileObject) => { - setMoneyRequestOdometerImage(transactionID, imageType, file as File, isTransactionDraft); - shouldRevokeOnUnmountRef.current = false; - navigateBack(); - }, - [transactionID, imageType, isTransactionDraft, navigateBack], - ); + const handleImageSelected = (file: FileObject) => { + setMoneyRequestOdometerImage(transactionID, imageType, file as File, isTransactionDraft); + shouldRevokeOnUnmountRef.current = false; + navigateBack(); + }; const {validateFiles, ErrorModal} = useFilesValidation((files: FileObject[]) => { if (files.length === 0) { @@ -89,26 +101,335 @@ function IOURequestStepOdometerImage({ handleImageSelected(file); }); - const handleDrop = useCallback( - (event: DragEvent) => { - const files = Array.from(event.dataTransfer?.files ?? []); - if (files.length === 0) { + /** + * On phones that have ultra-wide lens, react-webcam uses ultra-wide by default. + * The last deviceId is of regular len camera. + */ + const requestCameraPermission = useCallback(() => { + if (!isMobile()) { + return; + } + + const defaultConstraints = {facingMode: {exact: 'environment'}}; + navigator.mediaDevices + .getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}}) + .then((stream) => { + setCameraPermissionState('granted'); + for (const track of stream.getTracks()) { + track.stop(); + } + // Only Safari 17+ supports zoom constraint + if (isMobileWebKit() && stream.getTracks().length > 0) { + let deviceId; + for (const track of stream.getTracks()) { + const setting = track.getSettings(); + if (setting.zoom === 1) { + deviceId = setting.deviceId; + break; + } + } + if (deviceId) { + setVideoConstraints({deviceId}); + return; + } + } + if (!navigator.mediaDevices.enumerateDevices) { + setVideoConstraints(defaultConstraints); + return; + } + navigator.mediaDevices + .enumerateDevices() + .then((devices) => { + let lastBackDeviceId = ''; + for (let i = devices.length - 1; i >= 0; i--) { + const device = devices.at(i); + if (device?.kind === 'videoinput') { + lastBackDeviceId = device.deviceId; + break; + } + } + if (!lastBackDeviceId) { + setVideoConstraints(defaultConstraints); + return; + } + setVideoConstraints({deviceId: lastBackDeviceId}); + }) + .catch(() => { + setVideoConstraints(defaultConstraints); + }); + }) + .catch(() => { + setVideoConstraints(defaultConstraints); + setCameraPermissionState('denied'); + }); + }, []); + + useEffect(() => { + if (!isMobile() || !isTabActive) { + return; + } + navigator.permissions + .query({ + name: 'camera', + }) + .then((permissionState) => { + setCameraPermissionState(permissionState.state); + if (permissionState.state === 'granted') { + requestCameraPermission(); + } + }) + .catch(() => { + setCameraPermissionState('denied'); + }) + .finally(() => { + setIsQueriedPermissionState(true); + }); + return () => { + setVideoConstraints(undefined); + }; + }, [isTabActive, requestCameraPermission]); + + const setupCameraPermissionsAndCapabilities = (stream: MediaStream) => { + setCameraPermissionState('granted'); + + const [track] = stream.getVideoTracks(); + const capabilities = track.getCapabilities(); + + if ('torch' in capabilities && capabilities.torch) { + trackRef.current = track; + } + setIsTorchAvailable('torch' in capabilities && !!capabilities.torch); + }; + + const viewfinderLayout = useRef(null); + + const getScreenshot = () => { + if (!cameraRef.current) { + requestCameraPermission(); + return; + } + + const imageBase64 = cameraRef.current.getScreenshot(); + + if (imageBase64 === null) { + return; + } + + const originalFileName = `receipt_${Date.now()}.png`; + const originalFile = base64ToFile(imageBase64 ?? '', originalFileName); + const imageObject: ImageObject = {file: originalFile, filename: originalFile.name, source: URL.createObjectURL(originalFile)}; + // Some browsers center-crop the viewfinder inside the video element (due to object-position: center), + // while other browsers let the video element overflow and the container crops it from the top. + // We crop and algin the result image the same way. + const videoHeight = cameraRef.current.video?.getBoundingClientRect?.()?.height ?? NaN; + const viewFinderHeight = viewfinderLayout.current?.height ?? NaN; + const shouldAlignTop = videoHeight > viewFinderHeight; + cropImageToAspectRatio(imageObject, viewfinderLayout.current?.width, viewfinderLayout.current?.height, shouldAlignTop) + .then(({source}) => { + if (source !== imageObject.source) { + URL.revokeObjectURL(imageObject.source); + } + setMoneyRequestOdometerImage(transactionID, imageType, source, isTransactionDraft); + navigateBack(); + }) + .catch((error: unknown) => { + Log.warn('Error cropping photo', error instanceof Error ? error.message : String(error)); + }); + }; + + const clearTorchConstraints = () => { + if (!trackRef.current) { + return; + } + trackRef.current.applyConstraints({ + advanced: [{torch: false}], + }); + }; + + const capturePhoto = () => { + if (trackRef.current && isFlashLightOn) { + trackRef.current + .applyConstraints({ + advanced: [{torch: true}], + }) + .then(() => { + getScreenshotTimeoutRef.current = setTimeout(() => { + getScreenshot(); + clearTorchConstraints(); + }, CONST.RECEIPT.FLASH_DELAY_MS); + }); + return; + } + + getScreenshot(); + }; + + useEffect( + () => () => { + if (!getScreenshotTimeoutRef.current) { return; } - revokeDropBlobUrls(); - const blobUrls: string[] = []; - for (const file of files) { - const blobUrl = URL.createObjectURL(file); - blobUrls.push(blobUrl); - // eslint-disable-next-line no-param-reassign - file.uri = blobUrl; - } - dropBlobUrlsRef.current = blobUrls; - validateFiles(files as FileObject[], Array.from(event.dataTransfer?.items ?? [])); + clearTimeout(getScreenshotTimeoutRef.current); }, - [revokeDropBlobUrls, validateFiles], + [], ); + const mobileCameraView = () => ( + <> + + {((cameraPermissionState === 'prompt' && !isQueriedPermissionState) || (cameraPermissionState === 'granted' && isEmptyObject(videoConstraints))) && ( + + )} + {cameraPermissionState !== 'granted' && isQueriedPermissionState && ( + + + {translate('receipt.takePhoto')} + {cameraPermissionState === 'denied' ? ( + + + + ) : ( + {translate('distance.odometer.cameraAccessRequired')} + )} +