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')}
+ )}
+
+
+ )}
+ {cameraPermissionState === 'granted' && !isEmptyObject(videoConstraints) && (
+ (viewfinderLayout.current = e.nativeEvent.layout)}
+ >
+ setCameraPermissionState('denied')}
+ style={{
+ ...styles.videoContainer,
+ display: cameraPermissionState !== 'granted' ? 'none' : 'block',
+ }}
+ ref={cameraRef}
+ screenshotFormat="image/png"
+ videoConstraints={videoConstraints}
+ forceScreenshotSourceSize
+ audio={false}
+ disablePictureInPicture={false}
+ imageSmoothing={false}
+ mirrored={false}
+ screenshotQuality={0}
+ />
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+ )}
+
+
+
+
+ {({openPicker}) => (
+ {
+ openPicker({
+ onPicked: (data) => validateFiles(data),
+ });
+ }}
+ >
+
+
+ )}
+
+
+
+
+ {/* Empty View matching gallery size so justifyContentAround keeps the shutter exactly centered - it's the simplest solution */}
+
+
+ >
+ );
+
+ const revokeDropBlobUrls = useCallback(() => {
+ for (const url of dropBlobUrlsRef.current) {
+ if (url.startsWith('blob:')) {
+ URL.revokeObjectURL(url);
+ }
+ }
+ dropBlobUrlsRef.current = [];
+ }, []);
+
+ const handleDrop = (event: DragEvent) => {
+ const files = Array.from(event.dataTransfer?.files ?? []).filter((file) => CONST.FILE_TYPE_REGEX.IMAGE.test(file.name));
+ if (files.length === 0) {
+ 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 ?? []));
+ };
+
useEffect(() => {
return () => {
if (!shouldRevokeOnUnmountRef.current) {
@@ -118,6 +439,12 @@ function IOURequestStepOdometerImage({
};
}, [revokeDropBlobUrls]);
+ const panResponder = useRef(
+ PanResponder.create({
+ onPanResponderTerminationRequest: () => false,
+ }),
+ ).current;
+
const desktopUploadView = () => (
- {title}
-
-
+
+ {title}
+
+
+
-
+
{({openPicker}) => (