diff --git a/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx b/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx new file mode 100644 index 000000000000..4ffd22281d27 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import {isPolicyExpenseChat} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type {IOUAction, IOUType} from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import type {Policy, Report} from '@src/types/onyx'; +import type Transaction from '@src/types/onyx/Transaction'; +import MultiScanGate from './components/MultiScanGate'; +import ScanEditReceipt from './components/ScanEditReceipt'; +import ScanFromReport from './components/ScanFromReport'; +import ScanGlobalCreate from './components/ScanGlobalCreate'; +import ScanSkipConfirmation from './components/ScanSkipConfirmation'; + +type ScanRouterProps = { + report: OnyxEntry; + action: IOUAction; + iouType: IOUType; + reportID: string; + transactionID: string; + transaction: OnyxEntry; + backTo: Route | undefined; + backToReport: string | undefined; +}; + +type NonGlobalCreateProps = { + report: OnyxEntry; + iouType: IOUType; + reportID: string; + transactionID: string; + transaction: OnyxEntry; + backToReport: string | undefined; +}; + +type NewReceiptProps = NonGlobalCreateProps; + +const policyRequiresTagOrCategorySelector = (policy: OnyxEntry) => !!policy?.requiresCategory || !!policy?.requiresTag; + +/** + * Owns the policy + skip-confirmation subscriptions so the edit and global-create branches don't pay for them. + * Decides between SkipConfirmation (quick action) and FromReport (report (+) entry). + */ +function ScanNonGlobalCreate({report, iouType, reportID, transactionID, transaction, backToReport}: NonGlobalCreateProps) { + const [policyRequiresTagOrCategory] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {selector: policyRequiresTagOrCategorySelector}); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); + const shouldSkipConfirmation = !!skipConfirmation && !!report?.reportID && !(isPolicyExpenseChat(report) && policyRequiresTagOrCategory); + + if (shouldSkipConfirmation) { + return ( + + ); + } + + return ( + + ); +} + +ScanNonGlobalCreate.displayName = 'ScanNonGlobalCreate'; + +/** + * Splits new-receipt flows: global-create (FAB) vs. report-scoped (FromReport / SkipConfirm). + * The archived-report check lives here so neither global-create nor non-global-create variants need to subscribe. + */ +function ScanNewReceipt({report, iouType, reportID, transactionID, transaction, backToReport}: NewReceiptProps) { + const isArchived = useReportIsArchived(report?.reportID); + const isFromGlobalCreate = !!transaction?.isFromGlobalCreate; + + if (!isFromGlobalCreate && !isArchived && iouType !== CONST.IOU.TYPE.CREATE) { + return ( + + ); + } + + return ( + + ); +} + +ScanNewReceipt.displayName = 'ScanNewReceipt'; + +/** + * ScanRouter — selects the appropriate scan variant based on route params and transaction state. + * + * Edit branch is a fast-path that subscribes to nothing extra. Non-edit branches go through MultiScanGate + * and the layered ScanNewReceipt/ScanNonGlobalCreate components, which scope their subscriptions to the + * narrowest variant that needs them. + */ +function ScanRouter({report, action, iouType, reportID, transactionID, transaction, backTo, backToReport}: ScanRouterProps) { + const isEditing = action === CONST.IOU.ACTION.EDIT; + + if (backTo || isEditing) { + return ( + + ); + } + + return ( + + + + ); +} + +ScanRouter.displayName = 'ScanRouter'; + +export default ScanRouter; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx index b0cad6c717e5..92b9408a26fc 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx @@ -19,11 +19,11 @@ import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpa import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {useMultiScanActions, useMultiScanState} from '@pages/iou/request/step/IOURequestStepScan/components/MultiScanContext'; import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera'; +import ReceiptPreviews from '@pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews'; import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {CameraProps} from './types'; @@ -34,7 +34,7 @@ const BLINK_DURATION_MS = 80; * Renders a camera viewfinder, shutter button, flash toggle and gallery picker. * Calls `onCapture(file, source)` for each photo taken or file picked from the gallery. */ -function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}: CameraProps) { +function CameraCapture({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayout, onMultiScanSubmit}: CameraProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -106,6 +106,13 @@ function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}: const originalFileName = `receipt_${Date.now()}.png`; const originalFile = base64ToFile(imageBase64 ?? '', originalFileName); + + if (originalFile.size === 0) { + cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); + return; + } + 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. @@ -123,13 +130,6 @@ function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}: capturePhotoWithFlash(getScreenshot); }; - const emitPickedFiles = (files: FileObject[]) => { - for (const file of files) { - const source = file.uri ?? URL.createObjectURL(file as Blob); - onCapture(file, source); - } - }; - return ( { - openPicker({ - onPicked: (data) => emitPickedFiles(data), - }); - }} + onPress={() => openPicker({onPicked})} sentryLabel={shouldAcceptMultipleFiles ? CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILES : CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILE} > + {canUseMultiScan && !!onMultiScanSubmit && ( + + )} ); } diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/FileUpload.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/FileUpload.tsx index b8b37d6a77e5..874d2bcac029 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/FileUpload.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/FileUpload.tsx @@ -24,7 +24,7 @@ const panResponder = PanResponder.create({ * FileUpload — desktop web capture variant. * Renders a drag-and-drop zone + file picker button + receipt alternative methods. */ -function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isReplacingReceipt = false, isDraggingOverWrapper}: CameraProps) { +function FileUpload({onPicked, shouldAcceptMultipleFiles = false, onLayout, isReplacingReceipt = false}: CameraProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -51,9 +51,7 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl return file; }); - if (onDrop) { - onDrop(files, Array.from(e.dataTransfer?.items ?? [])); - } + onPicked(files, Array.from(e.dataTransfer?.items ?? [])); }; return ( @@ -65,7 +63,7 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl style={[styles.flex1, styles.chooseFilesView(false)]} > - {!(isDraggingOver ?? isDraggingOverWrapper) && ( + {!isDraggingOver && ( setUploadViewHeight(e.nativeEvent.layout.height)} @@ -78,7 +76,6 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl {translate(shouldAcceptMultipleFiles ? 'receipt.uploadMultiple' : 'receipt.upload')} @@ -94,11 +91,7 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl text={translate(shouldAcceptMultipleFiles ? 'common.chooseFiles' : 'common.chooseFile')} accessibilityLabel={translate(shouldAcceptMultipleFiles ? 'common.chooseFiles' : 'common.chooseFile')} style={[styles.p5]} - onPress={() => { - openPicker({ - onPicked: (data) => onDrop?.(data, []), - }); - }} + onPress={() => openPicker({onPicked})} sentryLabel={CONST.SENTRY_LABEL.IOU_REQUEST_STEP.SCAN_SUBMIT_BUTTON} /> )} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index fdacefc7b220..874c1b301499 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -1,24 +1,13 @@ -import React, {useEffect, useRef} from 'react'; -import {Alert, StyleSheet, View} from 'react-native'; -import ReactNativeBlobUtil from 'react-native-blob-util'; -import {GestureDetector} from 'react-native-gesture-handler'; +import React, {useRef} from 'react'; +import {Alert, View} from 'react-native'; import {RESULTS} from 'react-native-permissions'; -import Animated, {useAnimatedStyle, useSharedValue, withSequence, withTiming} from 'react-native-reanimated'; +import {useAnimatedStyle, useSharedValue, withSequence, withTiming} from 'react-native-reanimated'; import type {PhotoFile} from 'react-native-vision-camera'; import {useCameraFormat} from 'react-native-vision-camera'; import ActivityIndicator from '@components/ActivityIndicator'; -import AttachmentPicker from '@components/AttachmentPicker'; -import Button from '@components/Button'; -import Icon from '@components/Icon'; -import ImageSVG from '@components/ImageSVG'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import ScrollView from '@components/ScrollView'; -import Text from '@components/Text'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; -import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNativeCamera from '@hooks/useNativeCamera'; -import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -28,10 +17,13 @@ import HapticFeedback from '@libs/HapticFeedback'; import Log from '@libs/Log'; import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import captureReceipt from '@pages/iou/request/step/IOURequestStepScan/captureReceipt'; +import CameraPermissionPrompt from '@pages/iou/request/step/IOURequestStepScan/components/CameraPermissionPrompt'; +import CameraViewport from '@pages/iou/request/step/IOURequestStepScan/components/CameraViewport'; import {useMultiScanActions, useMultiScanState} from '@pages/iou/request/step/IOURequestStepScan/components/MultiScanContext'; -import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera'; +import ReceiptPreviews from '@pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews'; +import ScannerControlsBar from '@pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar'; import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; -import variables from '@styles/variables'; +import useCameraInitTelemetry from '@pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; import type {CameraProps} from './types'; @@ -43,15 +35,12 @@ const BLINK_DURATION_MS = 80; * Renders a react-native-vision-camera viewfinder with shutter, flash toggle, gallery picker, and focus gesture. * Calls `onCapture(file, source)` for each photo taken or file picked from the gallery. */ -function Camera({onCapture, shouldAcceptMultipleFiles = false, onLayout, onCameraInitialized, onAttachmentPickerStatusChange}: CameraProps) { +function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayout, onAttachmentPickerStatusChange, onMultiScanSubmit}: CameraProps) { const theme = useTheme(); const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const isInLandscapeMode = useIsInLandscapeMode(); const {windowWidth, windowHeight} = useWindowDimensions(); - const lazyIllustrations = useMemoizedLazyIllustrations(['Hand', 'Shutter']); - const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'ReceiptMultiple', 'boltSlash']); const {isMultiScanEnabled, canUseMultiScan} = useMultiScanState(); const {toggleMultiScan} = useMultiScanActions(); @@ -97,10 +86,6 @@ function Camera({onCapture, shouldAcceptMultipleFiles = false, onLayout, onCamer const cameraAspectRatio = getCameraAspectRatio(format, isInLandscapeMode); const fps = format ? Math.min(Math.max(30, format.minFps), format.maxFps) : 30; - // Track camera init telemetry - const cameraInitSpanStarted = useRef(false); - const cameraInitialized = useRef(false); - // Blink animation for shutter feedback const blinkOpacity = useSharedValue(0); const blinkStyle = useAnimatedStyle(() => ({ @@ -112,67 +97,7 @@ function Camera({onCapture, shouldAcceptMultipleFiles = false, onLayout, onCamer HapticFeedback.press(); }; - // Start camera init span when permission is granted and camera is ready - useEffect(() => { - if (cameraInitSpanStarted.current || cameraPermissionStatus !== RESULTS.GRANTED || device == null) { - return; - } - startSpan(CONST.TELEMETRY.SPAN_CAMERA_INIT, { - name: CONST.TELEMETRY.SPAN_CAMERA_INIT, - op: CONST.TELEMETRY.SPAN_CAMERA_INIT, - }); - cameraInitSpanStarted.current = true; - }, [cameraPermissionStatus, device]); - - // Cancel spans when permission is denied/blocked/unavailable - useEffect(() => { - if (cameraPermissionStatus !== RESULTS.BLOCKED && cameraPermissionStatus !== RESULTS.UNAVAILABLE && cameraPermissionStatus !== RESULTS.DENIED) { - return; - } - cancelSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); - }, [cameraPermissionStatus]); - - // Cancel spans on unmount if camera never initialized - useEffect(() => { - return () => { - if (cameraInitialized.current) { - return; - } - if (cameraInitSpanStarted.current) { - cancelSpan(CONST.TELEMETRY.SPAN_CAMERA_INIT); - } - cancelSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); - }; - }, []); - - const handleCameraInitialized = () => { - if (cameraInitialized.current) { - return; - } - cameraInitialized.current = true; - if (cameraInitSpanStarted.current) { - endSpan(CONST.TELEMETRY.SPAN_CAMERA_INIT); - } - endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); - - // Pre-create upload directory to avoid latency during capture - const path = getReceiptsUploadFolderPath(); - ReactNativeBlobUtil.fs - .isDir(path) - .then((isDir) => { - if (isDir) { - return; - } - ReactNativeBlobUtil.fs.mkdir(path).catch((error: string) => { - Log.warn('Error creating the receipts upload directory', error); - }); - }) - .catch((error: string) => { - Log.warn('Error checking if the upload directory exists', error); - }); - - onCameraInitialized?.(); - }; + const {handleCameraInitialized} = useCameraInitTelemetry({cameraPermissionStatus, device}); const maybeCancelShutterSpan = () => { if (isMultiScanEnabled) { @@ -227,10 +152,15 @@ function Camera({onCapture, shouldAcceptMultipleFiles = false, onLayout, onCamer captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}) .then((photo: PhotoFile) => { - setDidCapturePhoto(true); - const source = getPhotoSource(photo.path); endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + if (isMultiScanEnabled) { + isCapturingPhoto.current = false; + } else { + setDidCapturePhoto(true); + } + + const source = getPhotoSource(photo.path); const cameraFile: FileObject = { uri: source, name: photo.path, @@ -238,9 +168,6 @@ function Camera({onCapture, shouldAcceptMultipleFiles = false, onLayout, onCamer }; onCapture(cameraFile, source); - - setDidCapturePhoto(false); - isCapturingPhoto.current = false; }) .catch((error: string) => { isCapturingPhoto.current = false; @@ -250,13 +177,6 @@ function Camera({onCapture, shouldAcceptMultipleFiles = false, onLayout, onCamer }); }; - const emitPickedFiles = (files: FileObject[]) => { - for (const file of files) { - const source = file.uri ?? ''; - onCapture(file, source); - } - }; - // Wait for camera permission status to render if (cameraPermissionStatus == null) { return null; @@ -270,28 +190,10 @@ function Camera({onCapture, shouldAcceptMultipleFiles = false, onLayout, onCamer {cameraPermissionStatus !== RESULTS.GRANTED && ( - - - - - {translate('receipt.takePhoto')} - {translate('receipt.cameraAccess')} -