From 80d204911dada1e33db59b6a60da870b613b75d6 Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Thu, 30 Apr 2026 14:53:37 +0200 Subject: [PATCH 01/33] feat: add shared scan utilities --- .../components/GpsPermissionGate.tsx | 42 ++++++++++++++ .../hooks/useScanCapture.ts | 19 ++++++ .../utils/buildReceiptFiles.ts | 58 +++++++++++++++++++ .../IOURequestStepScan/utils/getFileSource.ts | 11 ++++ .../utils/startScanProcessSpan.ts | 16 +++++ .../utils/useScanFileReadabilityCheck.ts | 50 ++++++++++++++++ 6 files changed, 196 insertions(+) create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/GpsPermissionGate.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepScan/hooks/useScanCapture.ts create mode 100644 src/pages/iou/request/step/IOURequestStepScan/utils/buildReceiptFiles.ts create mode 100644 src/pages/iou/request/step/IOURequestStepScan/utils/getFileSource.ts create mode 100644 src/pages/iou/request/step/IOURequestStepScan/utils/startScanProcessSpan.ts create mode 100644 src/pages/iou/request/step/IOURequestStepScan/utils/useScanFileReadabilityCheck.ts diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/GpsPermissionGate.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/GpsPermissionGate.tsx new file mode 100644 index 000000000000..05c81ff06e0f --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/components/GpsPermissionGate.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import LocationPermissionModal from '@components/LocationPermissionModal'; +import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; +import {updateLastLocationPermissionPrompt} from '@userActions/IOU'; + +type GpsPermissionGateProps = { + /** Whether the GPS permission flow is active */ + startLocationPermissionFlow: boolean; + + /** Receipt files awaiting confirmation */ + receiptFiles: ReceiptFile[]; + + /** Resets the permission flow state */ + resetPermissionFlow: () => void; + + /** Called when GPS permission is granted or denied, with the receipt files and grant status */ + onComplete: (files: ReceiptFile[], locationPermissionGranted: boolean) => void; +}; + +/** + * Pure gate component that renders a LocationPermissionModal when GPS permission + * is needed for distance-based receipt processing. + */ +function GpsPermissionGate({startLocationPermissionFlow, receiptFiles, resetPermissionFlow, onComplete}: GpsPermissionGateProps) { + if (!startLocationPermissionFlow || !receiptFiles.length) { + return null; + } + + return ( + onComplete(receiptFiles, true)} + onDeny={() => { + updateLastLocationPermissionPrompt(); + onComplete(receiptFiles, false); + }} + /> + ); +} + +export default GpsPermissionGate; diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanCapture.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanCapture.ts new file mode 100644 index 000000000000..7048e37b2cc6 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanCapture.ts @@ -0,0 +1,19 @@ +import useFilesValidation from '@hooks/useFilesValidation'; +import type {FileObject} from '@src/types/utils/Attachment'; + +/** + * Wraps useFilesValidation for scan-specific file capture. + * Validates files (type, size, HEIC conversion, PDF thumbnails) and calls + * the provided callback with the validated files. + */ +function useScanCapture(onFilesValidated: (files: FileObject[], dataTransferItems: DataTransferItem[]) => void) { + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); + + return { + validateFiles, + PDFValidationComponent, + ErrorModal, + }; +} + +export default useScanCapture; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/buildReceiptFiles.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/buildReceiptFiles.ts new file mode 100644 index 000000000000..b42368946ded --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/buildReceiptFiles.ts @@ -0,0 +1,58 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {shouldReuseInitialTransaction} from '@libs/TransactionUtils'; +import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; +import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; +import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; +import type Transaction from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; + +type BuildReceiptFilesParams = { + files: FileObject[]; + getFileSource: (file: FileObject) => string; + initialTransaction: OnyxEntry; + initialTransactionID: string; + currentUserPersonalDetails: CurrentUserPersonalDetails; + reportID: string; + shouldAcceptMultipleFiles: boolean; + isMultiScanEnabled: boolean; + transactions: Transaction[]; +}; + +/** + * Builds ReceiptFile[] from captured/picked files, creating optimistic transaction drafts as needed + * and storing receipts in Onyx via setMoneyRequestReceipt. + */ +function buildReceiptFiles({ + files, + getFileSource, + initialTransaction, + initialTransactionID, + currentUserPersonalDetails, + reportID, + shouldAcceptMultipleFiles, + isMultiScanEnabled, + transactions, +}: BuildReceiptFilesParams): ReceiptFile[] { + const receiptFiles: ReceiptFile[] = []; + + for (const [index, file] of files.entries()) { + const source = getFileSource(file); + const transaction = shouldReuseInitialTransaction(initialTransaction, shouldAcceptMultipleFiles, index, isMultiScanEnabled, transactions) + ? initialTransaction + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID, + }); + + const transactionID = transaction?.transactionID ?? initialTransactionID; + receiptFiles.push({file, source, transactionID}); + setMoneyRequestReceipt(transactionID, source, file.name ?? '', true, file.type); + } + + return receiptFiles; +} + +export default buildReceiptFiles; +export type {BuildReceiptFilesParams}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/getFileSource.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/getFileSource.ts new file mode 100644 index 000000000000..3fd9ee4ae6ca --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/getFileSource.ts @@ -0,0 +1,11 @@ +import type {FileObject} from '@src/types/utils/Attachment'; + +/** + * Returns a source URI for the given file. + * On native, files have a `uri` property; on web, we create a blob URL. + */ +function getFileSource(file: FileObject): string { + return file.uri ?? URL.createObjectURL(file as Blob); +} + +export default getFileSource; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/startScanProcessSpan.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/startScanProcessSpan.ts new file mode 100644 index 000000000000..8ac2b83499f1 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/startScanProcessSpan.ts @@ -0,0 +1,16 @@ +import {getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import CONST from '@src/CONST'; + +/** + * Starts the scan-process-and-navigate telemetry span as a child of the shutter-to-confirmation span. + */ +function startScanProcessSpan(isMultiScanEnabled: boolean) { + startSpan(CONST.TELEMETRY.SPAN_SCAN_PROCESS_AND_NAVIGATE, { + name: CONST.TELEMETRY.SPAN_SCAN_PROCESS_AND_NAVIGATE, + op: CONST.TELEMETRY.SPAN_SCAN_PROCESS_AND_NAVIGATE, + parentSpan: getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION), + attributes: {[CONST.TELEMETRY.ATTRIBUTE_IS_MULTI_SCAN]: isMultiScanEnabled}, + }); +} + +export default startScanProcessSpan; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/useScanFileReadabilityCheck.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/useScanFileReadabilityCheck.ts new file mode 100644 index 000000000000..a50b4eeb6a93 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/useScanFileReadabilityCheck.ts @@ -0,0 +1,50 @@ +import {useEffect, useRef} from 'react'; +import {isLocalFile} from '@libs/fileDownload/FileUtils'; +import {checkIfLocalFileIsAccessible} from '@userActions/IOU/Receipt'; +import {removeDraftTransactionsByIDs, removeTransactionReceipt} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import type Transaction from '@src/types/onyx/Transaction'; + +/** + * On mount, verifies that all local receipt files (blob:// URLs) are still readable. + * If the browser was refreshed, blob URLs cease to exist — this resets the scan flow + * so the user can start over rather than seeing broken images. + */ +function useScanFileReadabilityCheck(transactions: Array>, draftTransactionIDs: string[], setIsMultiScanEnabled: (value: boolean) => void) { + const hasValidated = useRef(false); + + useEffect(() => { + if (hasValidated.current) { + return; + } + hasValidated.current = true; + + let isAllScanFilesCanBeRead = true; + + Promise.all( + transactions.map((item) => { + const itemReceiptPath = item.receipt?.source; + const isLocal = isLocalFile(itemReceiptPath); + + if (!isLocal) { + return Promise.resolve(); + } + + const onFailure = () => { + isAllScanFilesCanBeRead = false; + }; + + return checkIfLocalFileIsAccessible(item.receipt?.filename, itemReceiptPath, item.receipt?.type, () => {}, onFailure); + }), + ).then(() => { + if (isAllScanFilesCanBeRead) { + return; + } + setIsMultiScanEnabled(false); + removeTransactionReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); + removeDraftTransactionsByIDs(draftTransactionIDs, true); + }); + }, [setIsMultiScanEnabled, transactions, draftTransactionIDs]); +} + +export default useScanFileReadabilityCheck; From 44124b2536b7ed3a0ec52ae8187b6fc7e3148e3b Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Thu, 30 Apr 2026 16:56:13 +0200 Subject: [PATCH 02/33] feat: add separate variants --- .../step/IOURequestStepScan/ScanRouter.tsx | 103 +++ .../components/DesktopWebUploadView.tsx | 134 ---- .../components/MobileWebCameraView.tsx | 410 ------------ .../components/ScanEditReceipt.tsx | 72 ++ .../components/ScanFromReport.tsx | 93 +++ .../components/ScanGlobalCreate.tsx | 133 ++++ .../components/ScanSkipConfirmation.tsx | 251 +++++++ .../step/IOURequestStepScan/index.native.tsx | 616 ------------------ .../request/step/IOURequestStepScan/index.tsx | 209 +----- 9 files changed, 670 insertions(+), 1351 deletions(-) create mode 100644 src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx delete mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/DesktopWebUploadView.tsx delete mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/MobileWebCameraView.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/ScanFromReport.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/ScanGlobalCreate.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx delete mode 100644 src/pages/iou/request/step/IOURequestStepScan/index.native.tsx 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..b50ed7f0087c --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import {isPolicyExpenseChat} from '@libs/ReportUtils'; +import ScanEditReceipt from '@pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt'; +import ScanFromReport from '@pages/iou/request/step/IOURequestStepScan/components/ScanFromReport'; +import ScanGlobalCreate from '@pages/iou/request/step/IOURequestStepScan/components/ScanGlobalCreate'; +import ScanSkipConfirmation from '@pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation'; +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 {Report} from '@src/types/onyx'; +import type Transaction from '@src/types/onyx/Transaction'; + +type ScanRouterProps = { + report: OnyxEntry; + action: IOUAction; + iouType: IOUType; + reportID: string; + transactionID: string; + transaction: OnyxEntry; + backTo: Route | undefined; + backToReport: string | undefined; +}; + +/** + * ScanRouter — thin routing layer that selects the appropriate scan variant + * based on route params and transaction state. + * + * Variant selection: + * Edit — replacing an existing receipt (backTo or isEditing) + * SkipConfirm — quick action with skipConfirmation flag (direct submit) + * FromReport — initiated from report (+) button (sets participants, shows confirmation) + * GlobalCreate — FAB (+) global create (auto-selects workspace or shows participant picker) + */ +function ScanRouter({report, action, iouType, reportID, transactionID, transaction, backTo, backToReport}: ScanRouterProps) { + const policy = usePolicy(report?.policyID); + const isArchived = useReportIsArchived(report?.reportID); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); + + const isEditing = action === CONST.IOU.ACTION.EDIT; + + // Edit/replace receipt flow + if (backTo || isEditing) { + return ( + + ); + } + + const isFromGlobalCreate = !!transaction?.isFromGlobalCreate; + const shouldSkipConfirmation = + !!skipConfirmation && !!report?.reportID && !isArchived && !(isPolicyExpenseChat(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); + + // From-report flows (not global create, not archived) + if (!isFromGlobalCreate && !isArchived && iouType !== CONST.IOU.TYPE.CREATE) { + if (shouldSkipConfirmation) { + return ( + + ); + } + + return ( + + ); + } + + // Global create flow (FAB or archived report) + return ( + + ); +} + +ScanRouter.displayName = 'ScanRouter'; + +export default ScanRouter; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/DesktopWebUploadView.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/DesktopWebUploadView.tsx deleted file mode 100644 index 0ffdd14ca9e3..000000000000 --- a/src/pages/iou/request/step/IOURequestStepScan/components/DesktopWebUploadView.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React, {useRef, useState} from 'react'; -import {PanResponder, View} from 'react-native'; -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 ReceiptAlternativeMethods from '@components/ReceiptAlternativeMethods'; -import Text from '@components/Text'; -import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper'; -import CONST from '@src/CONST'; -import type {FileObject} from '@src/types/utils/Attachment'; - -type DesktopWebUploadViewProps = { - PDFValidationComponent: React.ReactNode; - shouldAcceptMultipleFiles: boolean; - isReplacingReceipt: boolean; - validateFiles: (files: FileObject[], items?: DataTransferItem[]) => void; - onBackButtonPress: () => void; - shouldShowWrapper: boolean; -}; - -function DesktopWebUploadView({PDFValidationComponent, shouldAcceptMultipleFiles, isReplacingReceipt, validateFiles, onBackButtonPress, shouldShowWrapper}: DesktopWebUploadViewProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const lazyIllustrations = useMemoizedLazyIllustrations(['ReceiptStack']); - const lazyIcons = useMemoizedLazyExpensifyIcons(['ReplaceReceipt', 'SmartScan']); - const panResponder = useRef( - PanResponder.create({ - onPanResponderTerminationRequest: () => false, - }), - ).current; - - const {isDraggingOver} = useDragAndDropState(); - const [containerHeight, setContainerHeight] = useState(0); - const [desktopUploadViewHeight, setDesktopUploadViewHeight] = useState(0); - const [alternativeMethodsHeight, setAlternativeMethodsHeight] = useState(0); - const chooseFilesPaddingVertical = Number(styles.chooseFilesView(false).paddingVertical); - const shouldHideAlternativeMethods = alternativeMethodsHeight + desktopUploadViewHeight + chooseFilesPaddingVertical * 2 > containerHeight; - - const handleDropReceipt = (e: DragEvent) => { - const files = Array.from(e?.dataTransfer?.files ?? []); - if (files.length === 0) { - return; - } - for (const file of files) { - file.uri = URL.createObjectURL(file); - } - - validateFiles(files, Array.from(e.dataTransfer?.items ?? [])); - }; - - return ( - - {(isDraggingOverWrapper) => ( - { - setContainerHeight(event.nativeEvent.layout.height); - }} - style={[styles.flex1, styles.chooseFilesView(false)]} - > - - {!(isDraggingOver ?? isDraggingOverWrapper) && ( - { - setDesktopUploadViewHeight(e.nativeEvent.layout.height); - }} - > - {PDFValidationComponent} - - - {translate(shouldAcceptMultipleFiles ? 'receipt.uploadMultiple' : 'receipt.upload')} - - {translate(shouldAcceptMultipleFiles ? 'receipt.desktopSubtitleMultiple' : 'receipt.desktopSubtitleSingle')} - - - - - {({openPicker}) => ( -