From 09d3885f6b22480b499efb0fd9a760b2fb7f4ed1 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Mon, 16 Mar 2026 16:40:45 +0100 Subject: [PATCH 01/20] feat: add selectors to policy/derived hooks to prevent unnecessary re-renders Add Onyx selectors to usePolicyForMovingExpenses, useDefaultExpensePolicy, and useReportAttributes so they return stable values and don't trigger re-renders when unrelated policies or reports change. - usePolicyForMovingExpenses: selector computes qualifying policy IDs + flags, per-key lookup for the actual policy object - useDefaultExpensePolicy: selector finds single group policy ID, per-key lookup for the policy - useReportAttributes: new useReportAttributesByID(reportID) hook that selects only one report's attributes (cheap deep comparison) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/useDefaultExpensePolicy.tsx | 52 +++++++++++----- src/hooks/usePolicyForMovingExpenses.ts | 83 ++++++++++++++++++------- src/hooks/useReportAttributes.ts | 14 +++++ 3 files changed, 109 insertions(+), 40 deletions(-) diff --git a/src/hooks/useDefaultExpensePolicy.tsx b/src/hooks/useDefaultExpensePolicy.tsx index 60854211b2e43..32630fafff885 100644 --- a/src/hooks/useDefaultExpensePolicy.tsx +++ b/src/hooks/useDefaultExpensePolicy.tsx @@ -1,17 +1,50 @@ +import type {OnyxCollection} from 'react-native-onyx'; import {isPaidGroupPolicy, isPolicyAccessible} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; import usePreferredPolicy from './usePreferredPolicy'; +/** + * Selector that finds the single qualifying group policy ID from the collection. + * Returns only an ID (stable) — prevents re-renders when unrelated policies change. + */ +function getSingleGroupPolicyID(policies: OnyxCollection, login: string): string | undefined { + if (!policies) { + return undefined; + } + + let singlePolicyID: string | undefined; + for (const policy of Object.values(policies)) { + if (!policy || !isPaidGroupPolicy(policy) || !isPolicyAccessible(policy, login)) { + continue; + } + if (!singlePolicyID) { + singlePolicyID = policy.id; + } else { + return undefined; // More than one — no single default + } + } + + return singlePolicyID; +} + export default function useDefaultExpensePolicy() { const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); const {login = ''} = useCurrentUserPersonalDetails(); const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${preferredPolicyID}`); + // Selector returns only the qualifying policy ID — stable value, prevents re-renders + const [singleGroupPolicyID] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: (policies) => getSingleGroupPolicyID(policies, login), + }); + + // Per-key lookup for the single group policy (only fires when that specific policy changes) + const [singleGroupPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${singleGroupPolicyID}`); + if (isRestrictedToPreferredPolicy && isPaidGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login)) { return preferredPolicy; } @@ -20,20 +53,5 @@ export default function useDefaultExpensePolicy() { return activePolicy; } - // If there is exactly one group policy, use that as the default expense policy - let singlePolicy; - for (const policy of Object.values(allPolicies ?? {})) { - if (!policy || !isPaidGroupPolicy(policy) || !isPolicyAccessible(policy, login)) { - continue; - } - - if (!singlePolicy) { - singlePolicy = policy; - } else { - singlePolicy = undefined; - break; - } - } - - return singlePolicy; + return singleGroupPolicy; } diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index 712e689f56b98..b3f88e88a2acb 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -1,5 +1,5 @@ import {activePolicySelector} from '@selectors/Policy'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useSession} from '@components/OnyxListItemProvider'; import {canSubmitPerDiemExpenseFromWorkspace, isPaidGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; @@ -31,48 +31,85 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string ); } -function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: boolean, expensePolicyID?: string) { - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, { - selector: activePolicySelector, - }); +type PolicyQualificationResult = { + singlePolicyID: string | undefined; + isMemberOfMoreThanOnePolicy: boolean; + validExpensePolicyID: string | undefined; +}; - const session = useSession(); - const login = session?.email ?? ''; +/** + * Selector that computes which policies qualify for moving expenses. + * Returns only IDs and flags — stable output that prevents re-renders when unrelated policies change. + */ +function getPolicyQualificationResult( + policies: OnyxCollection, + login: string, + isPerDiemRequest?: boolean, + isTimeRequest?: boolean, + expensePolicyID?: string, +): PolicyQualificationResult { + if (!policies) { + return {singlePolicyID: undefined, isMemberOfMoreThanOnePolicy: false, validExpensePolicyID: undefined}; + } - // Early exit optimization: only need to check if we have 0, 1, or >1 policies - let singleUserPolicy; + let singlePolicyID: string | undefined; let isMemberOfMoreThanOnePolicy = false; - for (const policy of Object.values(allPolicies ?? {})) { + for (const policy of Object.values(policies)) { if (!isPolicyValidForMovingExpenses(policy, login, isPerDiemRequest, isTimeRequest)) { continue; } - - if (!singleUserPolicy) { - singleUserPolicy = policy; + if (!singlePolicyID) { + singlePolicyID = policy?.id; } else { isMemberOfMoreThanOnePolicy = true; - break; // Found 2, no need to continue + break; } } - // If an expense policy ID is provided and valid, prefer it over the active policy - // This ensures that when viewing/editing an expense from workspace B, we show workspace B - // even if the user's default workspace is A + let validExpensePolicyID: string | undefined; if (expensePolicyID) { - const expensePolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${expensePolicyID}`]; + const expensePolicy = policies[`${ONYXKEYS.COLLECTION.POLICY}${expensePolicyID}`]; if (expensePolicy && isPolicyValidForMovingExpenses(expensePolicy, login, isPerDiemRequest, isTimeRequest)) { - return {policyForMovingExpensesID: expensePolicyID, policyForMovingExpenses: expensePolicy, shouldSelectPolicy: false}; + validExpensePolicyID = expensePolicyID; } } + return {singlePolicyID, isMemberOfMoreThanOnePolicy, validExpensePolicyID}; +} + +function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: boolean, expensePolicyID?: string) { + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, { + selector: activePolicySelector, + }); + + const session = useSession(); + const login = session?.email ?? ''; + + // Contextual selector — captures login/flags from closure. + // Returns only IDs + flags (stable output) to prevent re-renders when unrelated policies change. + const policyQualificationSelector = (policies: OnyxCollection) => getPolicyQualificationResult(policies, login, isPerDiemRequest, isTimeRequest, expensePolicyID); + const [qualificationResult] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: policyQualificationSelector, + }); + + const {singlePolicyID, isMemberOfMoreThanOnePolicy, validExpensePolicyID} = qualificationResult ?? {}; + + // Per-key lookup for the resolved policy (only fires when that specific policy changes) + const resolvedPolicyID = validExpensePolicyID ?? singlePolicyID; + const [resolvedPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${resolvedPolicyID}`); + + // If an expense policy ID is provided and valid, prefer it over the active policy + if (validExpensePolicyID) { + return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false}; + } + if (activePolicy && (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && (!isTimeRequest || isTimeTrackingEnabled(activePolicy))) { return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false}; } - if (singleUserPolicy && !isMemberOfMoreThanOnePolicy) { - return {policyForMovingExpensesID: singleUserPolicy.id, policyForMovingExpenses: singleUserPolicy, shouldSelectPolicy: false}; + if (singlePolicyID && !isMemberOfMoreThanOnePolicy) { + return {policyForMovingExpensesID: singlePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false}; } if (isMemberOfMoreThanOnePolicy) { diff --git a/src/hooks/useReportAttributes.ts b/src/hooks/useReportAttributes.ts index d5d308541590c..316cd5cd8c104 100644 --- a/src/hooks/useReportAttributes.ts +++ b/src/hooks/useReportAttributes.ts @@ -14,4 +14,18 @@ function useReportAttributes() { return reportAttributes?.reports; } +/** + * Returns a single report's attributes using a selector. + * Deep comparison is cheap (single small object), so re-renders only occur + * when that specific report's attributes change — not on every global report change. + */ +function useReportAttributesByID(reportID: string | undefined) { + const reportAttributesByIDSelector = (value: {reports?: Record} | undefined) => (reportID ? value?.reports?.[reportID] : undefined); + const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + selector: reportAttributesByIDSelector, + }); + return reportAttributes; +} + export default useReportAttributes; +export {useReportAttributesByID}; From 3aeeabb2f0f813cc7194f260ef0ff0f917bc2d1e Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Mon, 16 Mar 2026 17:12:48 +0100 Subject: [PATCH 02/20] feat: export getAllTransactionDrafts and getAllReportNameValuePairs getters Add getter functions for module-level caches in IOU/index.ts following the existing pattern (getAllPersonalDetails, getAllTransactionViolations, etc.). These will be used by scan variant components to read action-layer data. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libs/actions/IOU/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 99cd0f4e18713..4e63d4b8dec27 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1010,6 +1010,14 @@ function getUserAccountID(): number { return userAccountID; } +function getAllTransactionDrafts(): NonNullable> { + return allTransactionDrafts; +} + +function getAllReportNameValuePairs(): OnyxCollection { + return allReportNameValuePairs; +} + /** * This function uses Onyx.connect and should be replaced with useOnyx for reactive data access. * TODO: remove `getPolicyTagsData` from this file (https://github.com/Expensify/App/issues/72721) @@ -13205,6 +13213,8 @@ export { getAllTransactionViolations, getAllReports, getAllReportActionsFromIOU, + getAllTransactionDrafts, + getAllReportNameValuePairs, getCurrentUserEmail, getUserAccountID, getReceiptError, From 1165b115aa0d8502d3939f36f1695fb36a4f5ca3 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Mon, 16 Mar 2026 17:36:37 +0100 Subject: [PATCH 03/20] feat: add scan variant components (ScanEditReceipt, ScanFromReport, ScanGlobalCreate, ScanSkipConfirmation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 self-contained variant components for the scan screen decomposition: - ScanEditReceipt: simplest, replaceReceipt → goBack - ScanFromReport: common path, setParticipants → navigateToConfirmation - ScanGlobalCreate: shouldUseDefaultExpensePolicy → determine target → navigate - ScanSkipConfirmation: heavy path, inline requestMoney/trackExpense/startSplitBill Each variant receives zero props, reads route params via useRoute(), fetches own Onyx data via hooks. ScanFromReport has zero heavy subscriptions. ScanSkipConfirmation uses selector-optimized hooks. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ScanEditReceipt.tsx | 243 ++++++++++ .../components/ScanFromReport.tsx | 236 ++++++++++ .../components/ScanGlobalCreate.tsx | 275 +++++++++++ .../components/ScanSkipConfirmation.tsx | 444 ++++++++++++++++++ 4 files changed, 1198 insertions(+) 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 diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx new file mode 100644 index 0000000000000..e374c1a802877 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx @@ -0,0 +1,243 @@ +import {useRoute} from '@react-navigation/native'; +import shouldStartLocationPermissionFlowSelector from '@selectors/LocationPermission'; +import noop from 'lodash/noop'; +import React, {useEffect, useState} from 'react'; +import {RESULTS} from 'react-native-permissions'; +import TestReceipt from '@assets/images/fake-receipt.png'; +import LocationPermissionModal from '@components/LocationPermissionModal'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useFilesValidation from '@hooks/useFilesValidation'; +import useOnyx from '@hooks/useOnyx'; +import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; +import usePolicy from '@hooks/usePolicy'; +import setTestReceipt from '@libs/actions/setTestReceipt'; +import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation'; +import {isMobile} from '@libs/Browser'; +import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; +import getCurrentPosition from '@libs/getCurrentPosition'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; +import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {hasReceipt} from '@libs/TransactionUtils'; +import {getLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission'; +import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; +import {checkIfScanFileCanBeRead, replaceReceipt, setMoneyRequestReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU'; +import {removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {FileObject} from '@src/types/utils/Attachment'; +import DesktopWebUploadView from './DesktopWebUploadView'; +import MobileWebCameraView from './MobileWebCameraView'; + +/** + * ScanEditReceipt — the simplest scan variant. + * Used when the user is editing/replacing an existing receipt (backTo or isEditing). + * + * Press handler: replaceReceipt -> navigateBack + */ +function ScanEditReceipt() { + const route = useRoute>(); + const {action, iouType, reportID, transactionID: initialTransactionID, backTo} = route.params; + + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const policy = usePolicy(report?.policyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); + const [initialTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${initialTransactionID}`); + const [transactions] = useOptimisticDraftTransactions(initialTransaction); + + const [shouldStartLocationPermissionFlow] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, { + selector: shouldStartLocationPermissionFlowSelector, + }); + + const isMobileWeb = isMobile(); + + const isEditing = action === CONST.IOU.ACTION.EDIT; + const isReplacingReceipt = (isEditing && hasReceipt(initialTransaction)) || (!!initialTransaction?.receipt && !!backTo); + const shouldAcceptMultipleFiles = false; // editing never accepts multiple files + + const [startLocationPermissionFlow, setStartLocationPermissionFlow] = useState(false); + const [receiptFiles, setReceiptFiles] = useState([]); + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + const updateScanAndNavigate = (file: FileObject, source: string) => { + replaceReceipt({transactionID: initialTransactionID, file: file as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); + navigateBack(); + }; + + const getSource = (file: FileObject) => file.uri ?? URL.createObjectURL(file as Blob); + + // The extra params satisfy the MobileWebCameraView prop contract + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function navigateToConfirmationStep(files: ReceiptFile[], locationPermissionGranted = false, _isTestTransaction = false) { + 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]: false}, + }); + + // For edit variant, backTo is always set — just navigate back + if (backTo) { + Navigation.goBack(backTo); + return; + } + + // Fallback: GPS location permission flow + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && files.length > 0; + if (gpsRequired && !locationPermissionGranted) { + if (shouldStartLocationPermissionFlow) { + setStartLocationPermissionFlow(true); + setReceiptFiles(files); + return; + } + } + + Navigation.goBack(backTo); + } + + function processReceipts(files: FileObject[], getFileSource: (file: FileObject) => string) { + if (files.length === 0) { + return; + } + + // For editing, just replace the receipt + const file = files.at(0); + if (!file) { + return; + } + const source = getFileSource(file); + setMoneyRequestReceipt(initialTransactionID, source, file.name ?? '', !isEditing, file.type); + updateScanAndNavigate(file, source); + } + + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation((files: FileObject[]) => { + processReceipts(files, getSource); + }); + + // Exposed for test infrastructure via onLayout pattern — will be wired by the router component + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function setTestReceiptAndNavigate() { + setTestReceipt(TestReceipt, 'png', (source, file, filename) => { + setMoneyRequestReceipt(initialTransactionID, source, filename, !isEditing, CONST.TEST_RECEIPT.FILE_TYPE, true); + removeDraftTransactions(true); + navigateToConfirmationStep([{file, source, transactionID: initialTransactionID}], false, true); + }); + } + + // End the create expense span on mount + useEffect(() => { + endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); + }, []); + + // Check if scan file can be read on mount + useEffect(() => { + let isAllScanFilesCanBeRead = true; + + Promise.all( + transactions.map((item) => { + const itemReceiptPath = item.receipt?.source; + const isLocalFile = isLocalFileFileUtils(itemReceiptPath); + + if (!isLocalFile) { + return Promise.resolve(); + } + + const onFailure = () => { + isAllScanFilesCanBeRead = false; + }; + + return checkIfScanFileCanBeRead(item.receipt?.filename, itemReceiptPath, item.receipt?.type, () => {}, onFailure); + }), + ).then(() => { + if (isAllScanFilesCanBeRead) { + return; + } + removeTransactionReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); + removeDraftTransactions(true); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Pre-fetch location on web if permission already granted + useEffect(() => { + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT; + if (!gpsRequired) { + return; + } + + getLocationPermission().then((status) => { + if (status !== RESULTS.GRANTED && status !== RESULTS.LIMITED) { + return; + } + clearUserLocation(); + getCurrentPosition( + (successData) => { + setUserLocation({longitude: successData.coords.longitude, latitude: successData.coords.latitude}); + }, + () => {}, + ); + }); + }, [initialTransaction?.amount, iouType]); + + return ( + <> + {isMobileWeb ? ( + + ) : ( + + )} + {ErrorModal} + {startLocationPermissionFlow && !!receiptFiles.length && ( + setStartLocationPermissionFlow(false)} + onGrant={() => navigateToConfirmationStep(receiptFiles, true)} + onDeny={() => { + updateLastLocationPermissionPrompt(); + navigateToConfirmationStep(receiptFiles, false); + }} + /> + )} + + ); +} + +ScanEditReceipt.displayName = 'ScanEditReceipt'; + +export default ScanEditReceipt; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanFromReport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanFromReport.tsx new file mode 100644 index 0000000000000..df1e1ed7a2530 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanFromReport.tsx @@ -0,0 +1,236 @@ +import {useRoute} from '@react-navigation/native'; +import noop from 'lodash/noop'; +import React, {useEffect, useState} from 'react'; +import {RESULTS} from 'react-native-permissions'; +import TestReceipt from '@assets/images/fake-receipt.png'; +import LocationPermissionModal from '@components/LocationPermissionModal'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useFilesValidation from '@hooks/useFilesValidation'; +import useOnyx from '@hooks/useOnyx'; +import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; +import setTestReceipt from '@libs/actions/setTestReceipt'; +import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation'; +import {isMobile} from '@libs/Browser'; +import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; +import getCurrentPosition from '@libs/getCurrentPosition'; +import {navigateToConfirmationPage} from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; +import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {hasReceipt, shouldReuseInitialTransaction} from '@libs/TransactionUtils'; +import {getLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission'; +import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; +import {checkIfScanFileCanBeRead, setMoneyRequestReceipt, setMultipleMoneyRequestParticipantsFromReport, updateLastLocationPermissionPrompt} from '@userActions/IOU'; +import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type Transaction from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; +import DesktopWebUploadView from './DesktopWebUploadView'; +import MobileWebCameraView from './MobileWebCameraView'; + +/** + * ScanFromReport — the most common scan flow. + * Used when scanning from within a report (!isFromGlobalCreate && !shouldSkipConfirmation). + * + * Press handler: setMultipleMoneyRequestParticipantsFromReport -> navigateToConfirmationPage + */ +function ScanFromReport() { + const route = useRoute>(); + const {action, iouType, reportID, transactionID: initialTransactionID, backTo, backToReport} = route.params; + + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [initialTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${initialTransactionID}`); + const [transactions] = useOptimisticDraftTransactions(initialTransaction); + + const isMobileWeb = isMobile(); + + const isEditing = action === CONST.IOU.ACTION.EDIT; + const isReplacingReceipt = (isEditing && hasReceipt(initialTransaction)) || (!!initialTransaction?.receipt && !!backTo); + const shouldAcceptMultipleFiles = !isEditing && !backTo; + + const [startLocationPermissionFlow, setStartLocationPermissionFlow] = useState(false); + const [receiptFiles, setReceiptFiles] = useState([]); + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + const getSource = (file: FileObject) => file.uri ?? URL.createObjectURL(file as Blob); + + // The extra params satisfy the MobileWebCameraView prop contract but are not used by this variant + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function navigateToConfirmationStep(files: ReceiptFile[], _locationPermissionGranted = false, _isTestTransaction = false) { + 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]: false}, + }); + + if (backTo) { + Navigation.goBack(backTo); + return; + } + + // The main flow for ScanFromReport: set participants from the report and navigate to confirmation + const transactionIDs = files.map((receiptFile) => receiptFile.transactionID); + setMultipleMoneyRequestParticipantsFromReport(transactionIDs, report, currentUserPersonalDetails.accountID).then(() => + navigateToConfirmationPage(iouType, initialTransactionID, reportID, backToReport), + ); + } + + function processReceipts(files: FileObject[], getFileSource: (file: FileObject) => string) { + if (files.length === 0) { + return; + } + + const newReceiptFiles: ReceiptFile[] = []; + + for (const [index, file] of files.entries()) { + const source = getFileSource(file); + const transaction = shouldReuseInitialTransaction(initialTransaction, shouldAcceptMultipleFiles, index, false, transactions) + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID, + }); + + const transactionID = transaction.transactionID ?? initialTransactionID; + newReceiptFiles.push({file, source, transactionID}); + setMoneyRequestReceipt(transactionID, source, file.name ?? '', true, file.type); + } + + // ScanFromReport never skips confirmation, so go straight to navigateToConfirmationStep + navigateToConfirmationStep(newReceiptFiles, false); + } + + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation((files: FileObject[]) => { + processReceipts(files, getSource); + }); + + // Exposed for test infrastructure via onLayout pattern — will be wired by the router component + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function setTestReceiptAndNavigate() { + setTestReceipt(TestReceipt, 'png', (source, file, filename) => { + setMoneyRequestReceipt(initialTransactionID, source, filename, !isEditing, CONST.TEST_RECEIPT.FILE_TYPE, true); + removeDraftTransactions(true); + navigateToConfirmationStep([{file, source, transactionID: initialTransactionID}], false, true); + }); + } + + // End the create expense span on mount + useEffect(() => { + endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); + }, []); + + // Check if scan file can be read on mount + useEffect(() => { + let isAllScanFilesCanBeRead = true; + + Promise.all( + transactions.map((item) => { + const itemReceiptPath = item.receipt?.source; + const isLocalFile = isLocalFileFileUtils(itemReceiptPath); + + if (!isLocalFile) { + return Promise.resolve(); + } + + const onFailure = () => { + isAllScanFilesCanBeRead = false; + }; + + return checkIfScanFileCanBeRead(item.receipt?.filename, itemReceiptPath, item.receipt?.type, () => {}, onFailure); + }), + ).then(() => { + if (isAllScanFilesCanBeRead) { + return; + } + removeTransactionReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); + removeDraftTransactions(true); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Pre-fetch location on web if permission already granted + useEffect(() => { + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT; + if (!gpsRequired) { + return; + } + + getLocationPermission().then((status) => { + if (status !== RESULTS.GRANTED && status !== RESULTS.LIMITED) { + return; + } + clearUserLocation(); + getCurrentPosition( + (successData) => { + setUserLocation({longitude: successData.coords.longitude, latitude: successData.coords.latitude}); + }, + () => {}, + ); + }); + }, [initialTransaction?.amount, iouType]); + + return ( + <> + {isMobileWeb ? ( + + ) : ( + + )} + {ErrorModal} + {startLocationPermissionFlow && !!receiptFiles.length && ( + setStartLocationPermissionFlow(false)} + onGrant={() => navigateToConfirmationStep(receiptFiles, true)} + onDeny={() => { + updateLastLocationPermissionPrompt(); + navigateToConfirmationStep(receiptFiles, false); + }} + /> + )} + + ); +} + +ScanFromReport.displayName = 'ScanFromReport'; + +export default ScanFromReport; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanGlobalCreate.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanGlobalCreate.tsx new file mode 100644 index 0000000000000..d572267bacfa4 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanGlobalCreate.tsx @@ -0,0 +1,275 @@ +import {useRoute} from '@react-navigation/native'; +import noop from 'lodash/noop'; +import React, {useEffect, useState} from 'react'; +import {RESULTS} from 'react-native-permissions'; +import TestReceipt from '@assets/images/fake-receipt.png'; +import LocationPermissionModal from '@components/LocationPermissionModal'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; +import useFilesValidation from '@hooks/useFilesValidation'; +import useOnyx from '@hooks/useOnyx'; +import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import useSelfDMReport from '@hooks/useSelfDMReport'; +import setTestReceipt from '@libs/actions/setTestReceipt'; +import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation'; +import {isMobile} from '@libs/Browser'; +import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; +import getCurrentPosition from '@libs/getCurrentPosition'; +import {navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; +import {getPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; +import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; +import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {hasReceipt, shouldReuseInitialTransaction} from '@libs/TransactionUtils'; +import {getLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission'; +import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; +import {checkIfScanFileCanBeRead, setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU'; +import {setTransactionReport} from '@userActions/Transaction'; +import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type Transaction from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; +import DesktopWebUploadView from './DesktopWebUploadView'; +import MobileWebCameraView from './MobileWebCameraView'; + +/** + * ScanGlobalCreate — global create flow. + * Used when isFromGlobalCreate / archived / CREATE type. + * + * Press handler: shouldUseDefaultExpensePolicy -> determine target -> set participants -> navigate + */ +function ScanGlobalCreate() { + const route = useRoute>(); + const {action, iouType, reportID, transactionID: initialTransactionID, backTo, backToReport} = route.params; + + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [initialTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${initialTransactionID}`); + const [transactions] = useOptimisticDraftTransactions(initialTransaction); + + const defaultExpensePolicy = useDefaultExpensePolicy(); + const personalPolicy = usePersonalPolicy(); + const selfDMReport = useSelfDMReport(); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + + const isMobileWeb = isMobile(); + + const isEditing = action === CONST.IOU.ACTION.EDIT; + const isReplacingReceipt = (isEditing && hasReceipt(initialTransaction)) || (!!initialTransaction?.receipt && !!backTo); + const shouldAcceptMultipleFiles = !isEditing && !backTo; + + const [startLocationPermissionFlow, setStartLocationPermissionFlow] = useState(false); + const [receiptFiles, setReceiptFiles] = useState([]); + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + const getSource = (file: FileObject) => file.uri ?? URL.createObjectURL(file as Blob); + + // The extra params satisfy the MobileWebCameraView prop contract but are not used by this variant + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function navigateToConfirmationStep(files: ReceiptFile[], _locationPermissionGranted = false, _isTestTransaction = false) { + 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]: false}, + }); + + if (backTo) { + Navigation.goBack(backTo); + return; + } + + // Global create flow: determine target based on defaultExpensePolicy + if (shouldUseDefaultExpensePolicy(iouType, defaultExpensePolicy, amountOwed)) { + const isAutoReporting = !!personalPolicy?.autoReporting; + const shouldAutoReport = !!defaultExpensePolicy?.autoReporting || isAutoReporting; + const targetReport = shouldAutoReport ? getPolicyExpenseChat(currentUserPersonalDetails.accountID, defaultExpensePolicy?.id) : selfDMReport; + const transactionReportID = isSelfDM(targetReport) ? CONST.REPORT.UNREPORTED_REPORT_ID : targetReport?.reportID; + const iouTypeTrackOrSubmit = transactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; + + // If the initial transaction has different participants selected, the user changed the participant in the confirmation step + if (initialTransaction?.participants && initialTransaction?.participants?.at(0)?.reportID !== targetReport?.reportID) { + const isTrackExpense = initialTransaction?.participants?.at(0)?.reportID === selfDMReport?.reportID; + + const setParticipantsPromises = files.map((receiptFile) => setMoneyRequestParticipants(receiptFile.transactionID, initialTransaction?.participants)); + Promise.all(setParticipantsPromises).then(() => { + if (isTrackExpense) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.TRACK, initialTransactionID, selfDMReport?.reportID)); + } else { + navigateToConfirmationPage(iouType, initialTransactionID, reportID, backToReport, iouType === CONST.IOU.TYPE.CREATE, initialTransaction?.reportID); + } + }); + return; + } + + const setParticipantsPromises = files.map((receiptFile) => { + setTransactionReport(receiptFile.transactionID, {reportID: transactionReportID}, true); + return setMoneyRequestParticipantsFromReport(receiptFile.transactionID, targetReport, currentUserPersonalDetails.accountID); + }); + Promise.all(setParticipantsPromises).then(() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouTypeTrackOrSubmit, initialTransactionID, targetReport?.reportID)), + ); + } else { + navigateToParticipantPage(iouType, initialTransactionID, reportID); + } + } + + function processReceipts(files: FileObject[], getFileSource: (file: FileObject) => string) { + if (files.length === 0) { + return; + } + + const newReceiptFiles: ReceiptFile[] = []; + + for (const [index, file] of files.entries()) { + const source = getFileSource(file); + const transaction = shouldReuseInitialTransaction(initialTransaction, shouldAcceptMultipleFiles, index, false, transactions) + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID, + }); + + const transactionID = transaction.transactionID ?? initialTransactionID; + newReceiptFiles.push({file, source, transactionID}); + setMoneyRequestReceipt(transactionID, source, file.name ?? '', true, file.type); + } + + // Global create never skips confirmation in this variant + navigateToConfirmationStep(newReceiptFiles, false); + } + + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation((files: FileObject[]) => { + processReceipts(files, getSource); + }); + + // Exposed for test infrastructure via onLayout pattern — will be wired by the router component + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function setTestReceiptAndNavigate() { + setTestReceipt(TestReceipt, 'png', (source, file, filename) => { + setMoneyRequestReceipt(initialTransactionID, source, filename, !isEditing, CONST.TEST_RECEIPT.FILE_TYPE, true); + removeDraftTransactions(true); + navigateToConfirmationStep([{file, source, transactionID: initialTransactionID}], false, true); + }); + } + + // End the create expense span on mount + useEffect(() => { + endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); + }, []); + + // Check if scan file can be read on mount + useEffect(() => { + let isAllScanFilesCanBeRead = true; + + Promise.all( + transactions.map((item) => { + const itemReceiptPath = item.receipt?.source; + const isLocalFile = isLocalFileFileUtils(itemReceiptPath); + + if (!isLocalFile) { + return Promise.resolve(); + } + + const onFailure = () => { + isAllScanFilesCanBeRead = false; + }; + + return checkIfScanFileCanBeRead(item.receipt?.filename, itemReceiptPath, item.receipt?.type, () => {}, onFailure); + }), + ).then(() => { + if (isAllScanFilesCanBeRead) { + return; + } + removeTransactionReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); + removeDraftTransactions(true); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Pre-fetch location on web if permission already granted + useEffect(() => { + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT; + if (!gpsRequired) { + return; + } + + getLocationPermission().then((status) => { + if (status !== RESULTS.GRANTED && status !== RESULTS.LIMITED) { + return; + } + clearUserLocation(); + getCurrentPosition( + (successData) => { + setUserLocation({longitude: successData.coords.longitude, latitude: successData.coords.latitude}); + }, + () => {}, + ); + }); + }, [initialTransaction?.amount, iouType]); + + return ( + <> + {isMobileWeb ? ( + + ) : ( + + )} + {ErrorModal} + {startLocationPermissionFlow && !!receiptFiles.length && ( + setStartLocationPermissionFlow(false)} + onGrant={() => navigateToConfirmationStep(receiptFiles, true)} + onDeny={() => { + updateLastLocationPermissionPrompt(); + navigateToConfirmationStep(receiptFiles, false); + }} + /> + )} + + ); +} + +ScanGlobalCreate.displayName = 'ScanGlobalCreate'; + +export default ScanGlobalCreate; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx new file mode 100644 index 0000000000000..1b441c36f90ef --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -0,0 +1,444 @@ +import {useRoute} from '@react-navigation/native'; +import shouldStartLocationPermissionFlowSelector from '@selectors/LocationPermission'; +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import noop from 'lodash/noop'; +import React, {useEffect, useState} from 'react'; +import {RESULTS} from 'react-native-permissions'; +import TestReceipt from '@assets/images/fake-receipt.png'; +import LocationPermissionModal from '@components/LocationPermissionModal'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useFilesValidation from '@hooks/useFilesValidation'; +import useOnyx from '@hooks/useOnyx'; +import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; +import usePermissions from '@hooks/usePermissions'; +import usePolicy from '@hooks/usePolicy'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import useReportAttributes from '@hooks/useReportAttributes'; +import {createTransaction} from '@libs/actions/IOU/MoneyRequest'; +import setTestReceipt from '@libs/actions/setTestReceipt'; +import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation'; +import {isMobile} from '@libs/Browser'; +import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; +import getCurrentPosition from '@libs/getCurrentPosition'; +import {calculateDefaultReimbursable} from '@libs/IOUUtils'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; +import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; +import {isArchivedReport, isPolicyExpenseChat} from '@libs/ReportUtils'; +import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {getDefaultTaxCode, hasReceipt, shouldReuseInitialTransaction} from '@libs/TransactionUtils'; +import {getLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission'; +import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types'; +import {checkIfScanFileCanBeRead, getMoneyRequestParticipantsFromReport, getPolicyTags, setMoneyRequestReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU'; +import {startSplitBill} from '@userActions/IOU/Split'; +import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import {validTransactionDraftsSelector} from '@src/selectors/TransactionDraft'; +import type {PolicyTagLists} from '@src/types/onyx'; +import type {Receipt} from '@src/types/onyx/Transaction'; +import type Transaction from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; +import DesktopWebUploadView from './DesktopWebUploadView'; +import MobileWebCameraView from './MobileWebCameraView'; + +/** + * ScanSkipConfirmation — skip-confirmation variant. + * Used when !isFromGlobalCreate && shouldSkipConfirmation. + * + * Press handler: directly calls requestMoney/trackExpense/startSplitBill + */ +function ScanSkipConfirmation() { + const route = useRoute>(); + const {action, iouType, reportID, transactionID: initialTransactionID, backTo, backToReport} = route.params; + + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const policy = usePolicy(report?.policyID); + const [initialTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${initialTransactionID}`); + const [transactions] = useOptimisticDraftTransactions(initialTransaction); + + const {isBetaEnabled} = usePermissions(); + const {policyForMovingExpenses} = usePolicyForMovingExpenses(); + const reportAttributesDerived = useReportAttributes(); + + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${initialTransactionID}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); + + const [shouldStartLocationPermissionFlow] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT, { + selector: shouldStartLocationPermissionFlowSelector, + }); + + const isMobileWeb = isMobile(); + + const isEditing = action === CONST.IOU.ACTION.EDIT; + const isArchived = isArchivedReport(reportNameValuePairs); + const isReplacingReceipt = (isEditing && hasReceipt(initialTransaction)) || (!!initialTransaction?.receipt && !!backTo); + const shouldAcceptMultipleFiles = !isEditing && !backTo; + const shouldGenerateTransactionThreadReport = !isBetaEnabled(CONST.BETAS.NO_OPTIMISTIC_TRANSACTION_THREADS); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + + const defaultTaxCode = getDefaultTaxCode(policy, initialTransaction); + const transactionTaxCode = (initialTransaction?.taxCode ? initialTransaction?.taxCode : defaultTaxCode) ?? ''; + const transactionTaxAmount = initialTransaction?.taxAmount ?? 0; + + // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace + // request and the workspace requires a category or a tag + const shouldSkipConfirmation = + !!skipConfirmation && !!report?.reportID && !isArchived && !(isPolicyExpenseChat(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); + + const [startLocationPermissionFlow, setStartLocationPermissionFlow] = useState(false); + const [receiptFiles, setReceiptFiles] = useState([]); + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + const getSource = (file: FileObject) => file.uri ?? URL.createObjectURL(file as Blob); + + // The extra params satisfy the MobileWebCameraView prop contract + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function navigateToConfirmationStep(files: ReceiptFile[], locationPermissionGranted = false, _isTestTransaction = false) { + 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]: false}, + }); + + if (backTo) { + Navigation.goBack(backTo); + return; + } + + // Skip confirmation: directly call requestMoney/trackExpense/startSplitBill + // Inline getMoneyRequestParticipantOptions logic (not exported from MoneyRequest.ts) + const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID); + const participants = selectedParticipants.map((participant) => { + const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; + return participantAccountID + ? getParticipantsOption(participant, personalDetails) + : getReportOption(participant, reportNameValuePairs?.private_isArchived, policy, currentUserPersonalDetails.accountID, personalDetails, reportAttributesDerived); + }); + + cancelSpan(CONST.TELEMETRY.SPAN_SCAN_PROCESS_AND_NAVIGATE); + cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_MOUNT); + cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); + cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY); + cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD); + + // SPLIT branch + const firstReceiptFile = files.at(0); + if (iouType === CONST.IOU.TYPE.SPLIT && firstReceiptFile) { + const splitReceipt: Receipt = firstReceiptFile.file ?? {}; + splitReceipt.source = firstReceiptFile.source; + splitReceipt.state = CONST.IOU.RECEIPT_STATE.SCAN_READY; + const allPolicyTags = getPolicyTags(); + const participantsPolicyTags = participants.reduce>((acc, participant) => { + if (participant.policyID) { + acc[participant.policyID] = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${participant.policyID}`] ?? {}; + } + return acc; + }, {}); + startSplitBill({ + participants, + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + comment: '', + receipt: splitReceipt, + existingSplitChatReportID: reportID, + billable: false, + category: '', + tag: '', + currency: initialTransaction?.currency ?? 'USD', + taxCode: transactionTaxCode, + taxAmount: transactionTaxAmount, + quickAction, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + policyRecentlyUsedTags: undefined, + participantsPolicyTags, + }); + return; + } + + // Normal branch (requestMoney / trackExpense) + const participant = participants.at(0); + if (!participant) { + return; + } + const defaultReimbursable = calculateDefaultReimbursable({ + iouType, + policy, + policyForMovingExpenses, + participant, + transactionReportID: initialTransaction?.reportID, + }); + + // GPS branch + if (locationPermissionGranted) { + getCurrentPosition( + (successData) => { + const policyParams = {policy}; + const gpsPoint = { + lat: successData.coords.latitude, + long: successData.coords.longitude, + }; + createTransaction({ + transactions, + iouType, + report, + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail: currentUserPersonalDetails.login, + backToReport, + shouldGenerateTransactionThreadReport, + isASAPSubmitBetaEnabled, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, + introSelected, + activePolicyID, + files, + participant, + gpsPoint, + policyParams, + billable: false, + reimbursable: defaultReimbursable, + isSelfTourViewed, + allTransactionDrafts, + betas, + personalDetails, + recentWaypoints, + }); + }, + (errorData) => { + Log.info('[ScanSkipConfirmation] getCurrentPosition failed', false, errorData); + createTransaction({ + transactions, + iouType, + report, + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail: currentUserPersonalDetails.login, + backToReport, + shouldGenerateTransactionThreadReport, + isASAPSubmitBetaEnabled, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, + introSelected, + activePolicyID, + files, + participant, + reimbursable: defaultReimbursable, + isSelfTourViewed, + allTransactionDrafts, + betas, + personalDetails, + recentWaypoints, + }); + }, + ); + return; + } + + // No GPS needed + createTransaction({ + transactions, + iouType, + report, + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail: currentUserPersonalDetails.login, + backToReport, + shouldGenerateTransactionThreadReport, + isASAPSubmitBetaEnabled, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, + introSelected, + activePolicyID, + files, + participant, + reimbursable: defaultReimbursable, + isSelfTourViewed, + allTransactionDrafts, + betas, + personalDetails, + recentWaypoints, + }); + } + + function processReceipts(files: FileObject[], getFileSource: (file: FileObject) => string) { + if (files.length === 0) { + return; + } + + const newReceiptFiles: ReceiptFile[] = []; + + for (const [index, file] of files.entries()) { + const source = getFileSource(file); + const transaction = shouldReuseInitialTransaction(initialTransaction, shouldAcceptMultipleFiles, index, false, transactions) + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID, + }); + + const transactionID = transaction.transactionID ?? initialTransactionID; + newReceiptFiles.push({file, source, transactionID}); + setMoneyRequestReceipt(transactionID, source, file.name ?? '', true, file.type); + } + + // Skip confirmation path: handle GPS location permission flow + if (shouldSkipConfirmation) { + setReceiptFiles(newReceiptFiles); + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && files.length > 0; + if (gpsRequired) { + if (shouldStartLocationPermissionFlow) { + setStartLocationPermissionFlow(true); + return; + } + navigateToConfirmationStep(newReceiptFiles, true); + return; + } + } + navigateToConfirmationStep(newReceiptFiles, false); + } + + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation((files: FileObject[]) => { + processReceipts(files, getSource); + }); + + // Exposed for test infrastructure via onLayout pattern — will be wired by the router component + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function setTestReceiptAndNavigate() { + setTestReceipt(TestReceipt, 'png', (source, file, filename) => { + setMoneyRequestReceipt(initialTransactionID, source, filename, !isEditing, CONST.TEST_RECEIPT.FILE_TYPE, true); + removeDraftTransactions(true); + navigateToConfirmationStep([{file, source, transactionID: initialTransactionID}], false, true); + }); + } + + // End the create expense span on mount + useEffect(() => { + endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); + }, []); + + // Check if scan file can be read on mount + useEffect(() => { + let isAllScanFilesCanBeRead = true; + + Promise.all( + transactions.map((item) => { + const itemReceiptPath = item.receipt?.source; + const isLocalFile = isLocalFileFileUtils(itemReceiptPath); + + if (!isLocalFile) { + return Promise.resolve(); + } + + const onFailure = () => { + isAllScanFilesCanBeRead = false; + }; + + return checkIfScanFileCanBeRead(item.receipt?.filename, itemReceiptPath, item.receipt?.type, () => {}, onFailure); + }), + ).then(() => { + if (isAllScanFilesCanBeRead) { + return; + } + removeTransactionReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); + removeDraftTransactions(true); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Pre-fetch location on web if permission already granted + useEffect(() => { + const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT; + if (!gpsRequired) { + return; + } + + getLocationPermission().then((status) => { + if (status !== RESULTS.GRANTED && status !== RESULTS.LIMITED) { + return; + } + clearUserLocation(); + getCurrentPosition( + (successData) => { + setUserLocation({longitude: successData.coords.longitude, latitude: successData.coords.latitude}); + }, + () => {}, + ); + }); + }, [initialTransaction?.amount, iouType]); + + return ( + <> + {isMobileWeb ? ( + + ) : ( + + )} + {ErrorModal} + {startLocationPermissionFlow && !!receiptFiles.length && ( + setStartLocationPermissionFlow(false)} + onGrant={() => navigateToConfirmationStep(receiptFiles, true)} + onDeny={() => { + updateLastLocationPermissionPrompt(); + navigateToConfirmationStep(receiptFiles, false); + }} + /> + )} + + ); +} + +ScanSkipConfirmation.displayName = 'ScanSkipConfirmation'; + +export default ScanSkipConfirmation; From 36c22ec5c637883b45ae99469256a3daa26a1793 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Mon, 16 Mar 2026 17:38:17 +0100 Subject: [PATCH 04/20] feat: rewrite web IOURequestStepScan as thin router Replace the 210-line component + useReceiptScan mega-hook with a thin router that determines the scan variant (ScanEditReceipt, ScanFromReport, ScanGlobalCreate, ScanSkipConfirmation) and renders it with zero props. Router subscribes only to per-key data needed for branching: skipConfirmation, reportNameValuePairs, usePolicy. Each variant reads its own route params and Onyx data. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../request/step/IOURequestStepScan/index.tsx | 214 +++--------------- 1 file changed, 31 insertions(+), 183 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 2824febeae81a..9ec7bdaf5467d 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -1,204 +1,52 @@ -import React, {useCallback, useEffect} from 'react'; -import {RESULTS} from 'react-native-permissions'; -import LocationPermissionModal from '@components/LocationPermissionModal'; +import React from 'react'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; -import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation'; -import {isMobile} from '@libs/Browser'; -import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; -import getCurrentPosition from '@libs/getCurrentPosition'; -import Navigation from '@libs/Navigation/Navigation'; -import {endSpan} from '@libs/telemetry/activeSpans'; +import {isArchivedReport, isPolicyExpenseChat} from '@libs/ReportUtils'; import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; import withWritableReportOrNotFound from '@pages/iou/request/step/withWritableReportOrNotFound'; -import {checkIfScanFileCanBeRead, replaceReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU'; -import {removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {FileObject} from '@src/types/utils/Attachment'; -import DesktopWebUploadView from './components/DesktopWebUploadView'; -import MobileWebCameraView from './components/MobileWebCameraView'; -import useReceiptScan from './hooks/useReceiptScan'; -import {getLocationPermission} from './LocationPermission'; +import ScanEditReceipt from './components/ScanEditReceipt'; +import ScanFromReport from './components/ScanFromReport'; +import ScanGlobalCreate from './components/ScanGlobalCreate'; +import ScanSkipConfirmation from './components/ScanSkipConfirmation'; import type IOURequestStepScanProps from './types'; +/** + * Thin router that determines which scan variant to render based on the scan context. + * Each variant is a self-contained component that reads its own route params and Onyx data. + * The router only subscribes to per-key data needed for branching. + */ function IOURequestStepScan({ report, route: { - params: {action, iouType, reportID, transactionID: initialTransactionID, backTo, backToReport}, + params: {action, iouType, transactionID: initialTransactionID, backTo}, }, transaction: initialTransaction, - currentUserPersonalDetails, - onLayout, - isMultiScanEnabled = false, - isStartingScan = false, - setIsMultiScanEnabled, }: Omit) { - const isMobileWeb = isMobile(); const policy = usePolicy(report?.policyID); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); - - // End the create expense span on mount for web (no camera init tracking needed) - useEffect(() => { - endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); - }, []); - - const navigateBack = useCallback(() => { - Navigation.goBack(backTo); - }, [backTo]); - - const updateScanAndNavigate = useCallback( - (file: FileObject, source: string) => { - replaceReceipt({transactionID: initialTransactionID, file: file as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); - navigateBack(); - }, - [initialTransactionID, navigateBack, policy, policyCategories], - ); - - const getSource = useCallback((file: FileObject) => file.uri ?? URL.createObjectURL(file as Blob), []); - - const { - transactions, - isEditing, - isReplacingReceipt, - shouldAcceptMultipleFiles, - shouldSkipConfirmation, - startLocationPermissionFlow, - setStartLocationPermissionFlow, - receiptFiles, - setReceiptFiles, - navigateToConfirmationStep, - validateFiles, - PDFValidationComponent, - ErrorModal, - setTestReceiptAndNavigate, - } = useReceiptScan({ - report, - reportID, - initialTransactionID, - initialTransaction, - iouType, - action, - currentUserPersonalDetails, - backTo, - backToReport, - isMultiScanEnabled, - isStartingScan, - updateScanAndNavigate, - getSource, - }); - - const handleOnLayout = useCallback(() => { - onLayout?.(setTestReceiptAndNavigate); - }, [onLayout, setTestReceiptAndNavigate]); - - // When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, make the user star scanning flow from scratch. - // This is because until the request is saved, the receipt file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then - // the image ceases to exist. The best way for the user to recover from this is to start over from the start of the request process. - useEffect(() => { - let isAllScanFilesCanBeRead = true; - - Promise.all( - transactions.map((item) => { - const itemReceiptPath = item.receipt?.source; - const isLocalFile = isLocalFileFileUtils(itemReceiptPath); - - if (!isLocalFile) { - return Promise.resolve(); - } - - const onFailure = () => { - isAllScanFilesCanBeRead = false; - }; - - return checkIfScanFileCanBeRead(item.receipt?.filename, itemReceiptPath, item.receipt?.type, () => {}, onFailure); - }), - ).then(() => { - if (isAllScanFilesCanBeRead) { - return; - } - setIsMultiScanEnabled?.(false); - removeTransactionReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); - removeDraftTransactions(true); - }); - // We want this hook to run on mounting only - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // this effect will pre-fetch location in web if the location permission is already granted to optimize the flow - useEffect(() => { - const gpsRequired = initialTransaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT; - if (!gpsRequired) { - return; + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${initialTransactionID}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); + + const isArchived = isArchivedReport(reportNameValuePairs); + const isEditing = action === CONST.IOU.ACTION.EDIT; + const isFromGlobalCreate = !!initialTransaction?.isFromGlobalCreate; + const shouldSkipConfirmation = + !!skipConfirmation && !!report?.reportID && !isArchived && !(isPolicyExpenseChat(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); + + if (backTo || isEditing) { + return ; + } + + if (!isFromGlobalCreate && !isArchived && iouType !== CONST.IOU.TYPE.CREATE) { + if (shouldSkipConfirmation) { + return ; } + return ; + } - getLocationPermission().then((status) => { - if (status !== RESULTS.GRANTED && status !== RESULTS.LIMITED) { - return; - } - - clearUserLocation(); - getCurrentPosition( - (successData) => { - setUserLocation({longitude: successData.coords.longitude, latitude: successData.coords.latitude}); - }, - () => {}, - ); - }); - }, [initialTransaction?.amount, iouType]); - - return ( - <> - {isMobileWeb ? ( - - ) : ( - - )} - {ErrorModal} - {startLocationPermissionFlow && !!receiptFiles.length && ( - setStartLocationPermissionFlow(false)} - onGrant={() => navigateToConfirmationStep(receiptFiles, true)} - onDeny={() => { - updateLastLocationPermissionPrompt(); - navigateToConfirmationStep(receiptFiles, false); - }} - /> - )} - - ); + return ; } const IOURequestStepScanWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepScan); From 5ba0e03f4fcf0dd3afa0da6002b430f4e4cc267e Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Mon, 16 Mar 2026 17:39:46 +0100 Subject: [PATCH 05/20] feat: rewrite native IOURequestStepScan as thin router Replace the 680-line monolithic component (vision-camera, gestures, animations, flash, shutter, multi-scan) with the same thin router pattern as the web version. Router determines the variant and renders it with zero props. Native camera code is now handled by the variant components which render MobileWebCameraView/DesktopWebUploadView (the native-specific Camera extraction is a follow-up). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../step/IOURequestStepScan/index.native.tsx | 680 +----------------- 1 file changed, 28 insertions(+), 652 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index b98d7f855d840..9ec7bdaf5467d 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -1,676 +1,52 @@ -import {useFocusEffect} from '@react-navigation/core'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {Alert, AppState, StyleSheet, View} from 'react-native'; -import ReactNativeBlobUtil from 'react-native-blob-util'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import {RESULTS} from 'react-native-permissions'; -import Animated, {useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, withTiming} from 'react-native-reanimated'; -import type {Camera, PhotoFile, Point} from 'react-native-vision-camera'; -import {useCameraDevice, useCameraFormat} from 'react-native-vision-camera'; -import {scheduleOnRN} from 'react-native-worklets'; -import ActivityIndicator from '@components/ActivityIndicator'; -import AttachmentPicker from '@components/AttachmentPicker'; -import Button from '@components/Button'; -import FeatureTrainingModal from '@components/FeatureTrainingModal'; -import {useFullScreenLoaderActions, useFullScreenLoaderState} from '@components/FullScreenLoaderContext'; -import Icon from '@components/Icon'; -import ImageSVG from '@components/ImageSVG'; -import LocationPermissionModal from '@components/LocationPermissionModal'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Text from '@components/Text'; +import React from 'react'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; -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 getPlatform from '@libs/getPlatform'; -import type Platform from '@libs/getPlatform/types'; -import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; -import Log from '@libs/Log'; -import Navigation from '@libs/Navigation/Navigation'; -import navigationRef from '@libs/Navigation/navigationRef'; -import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; -import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; -import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper'; +import {isArchivedReport, isPolicyExpenseChat} from '@libs/ReportUtils'; import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; import withWritableReportOrNotFound from '@pages/iou/request/step/withWritableReportOrNotFound'; -import {replaceReceipt, setMoneyRequestReceipt, updateLastLocationPermissionPrompt} from '@userActions/IOU'; -import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Route} from '@src/ROUTES'; -import ROUTES from '@src/ROUTES'; -import type {FileObject} from '@src/types/utils/Attachment'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; -import CameraPermission from './CameraPermission'; -import NavigationAwareCamera from './components/NavigationAwareCamera/Camera'; -import ReceiptPreviews from './components/ReceiptPreviews'; -import useMobileReceiptScan from './hooks/useMobileReceiptScan'; -import useReceiptScan from './hooks/useReceiptScan'; +import ScanEditReceipt from './components/ScanEditReceipt'; +import ScanFromReport from './components/ScanFromReport'; +import ScanGlobalCreate from './components/ScanGlobalCreate'; +import ScanSkipConfirmation from './components/ScanSkipConfirmation'; import type IOURequestStepScanProps from './types'; +/** + * Thin router that determines which scan variant to render based on the scan context. + * Each variant is a self-contained component that reads its own route params and Onyx data. + * The router only subscribes to per-key data needed for branching. + */ function IOURequestStepScan({ report, route: { - params: {action, iouType, reportID, transactionID: initialTransactionID, backTo, backToReport}, + params: {action, iouType, transactionID: initialTransactionID, backTo}, }, transaction: initialTransaction, - currentUserPersonalDetails, - onLayout, - isMultiScanEnabled = false, - isStartingScan = false, - setIsMultiScanEnabled, -}: IOURequestStepScanProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - const {isLoaderVisible} = useFullScreenLoaderState(); - const {setIsLoaderVisible} = useFullScreenLoaderActions(); - const device = useCameraDevice('back', { - physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], - }); - const format = useCameraFormat(device, [{photoAspectRatio: 4 / 3}, {videoResolution: 'max'}, {photoResolution: 'max'}]); - // Format dimensions are in landscape orientation, so height/width gives portrait aspect ratio - const cameraAspectRatio = format ? format.photoHeight / format.photoWidth : undefined; - - const navigateBack = () => { - Navigation.goBack(); - }; - const hasFlash = !!device?.hasFlash; - const camera = useRef(null); - const [flash, setFlash] = useState(false); - const lazyIllustrations = useMemoizedLazyIllustrations(['MultiScan', 'Hand', 'Shutter']); - const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'ReceiptMultiple', 'boltSlash']); - const platform = getPlatform(true); - const [mutedPlatforms = getEmptyObject>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); - const isPlatformMuted = mutedPlatforms[platform]; - const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); - const [isAttachmentPickerActive, setIsAttachmentPickerActive] = useState(false); - const [didCapturePhoto, setDidCapturePhoto] = useState(false); +}: Omit) { const policy = usePolicy(report?.policyID); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${initialTransactionID}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); - - // Track camera init telemetry - const cameraInitSpanStarted = useRef(false); - const cameraInitialized = useRef(false); - - // Ref for double-tap protection (doesn't trigger re-render) - const isCapturingPhoto = useRef(false); - - // 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 camera initialized successfully, spans were already ended - if (cameraInitialized.current) { - return; - } - // Cancel camera init span if it was started - if (cameraInitSpanStarted.current) { - cancelSpan(CONST.TELEMETRY.SPAN_CAMERA_INIT); - } - // Always cancel the create expense span if camera never initialized - cancelSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); - }; - }, []); - - const handleCameraInitialized = useCallback(() => { - // Prevent duplicate span endings if callback fires multiple times - if (cameraInitialized.current) { - return; - } - cameraInitialized.current = true; - // Only end camera init span if it was actually started - 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); - }); - }, []); - - const askForPermissions = useCallback(() => { - // There's no way we can check for the BLOCKED status without requesting the permission first - // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 - CameraPermission.requestCameraPermission?.() - .then((status: string) => { - setCameraPermissionStatus(status); - - if (status === RESULTS.BLOCKED) { - showCameraPermissionsAlert(translate); - } - }) - .catch(() => { - setCameraPermissionStatus(RESULTS.UNAVAILABLE); - }); - }, [translate]); - - const focusIndicatorOpacity = useSharedValue(0); - const focusIndicatorScale = useSharedValue(2); - const focusIndicatorPosition = useSharedValue({x: 0, y: 0}); - - const cameraFocusIndicatorAnimatedStyle = useAnimatedStyle(() => ({ - opacity: focusIndicatorOpacity.get(), - transform: [{translateX: focusIndicatorPosition.get().x}, {translateY: focusIndicatorPosition.get().y}, {scale: focusIndicatorScale.get()}], - })); - - const focusCamera = (point: Point) => { - if (!camera.current) { - return; - } - - camera.current.focus(point).catch((error: Record) => { - if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') { - return; - } - Log.warn('Error focusing camera', error); - }); - }; - - const tapGesture = Gesture.Tap() - .enabled(device?.supportsFocus ?? false) - .onStart((ev: {x: number; y: number}) => { - const point = {x: ev.x, y: ev.y}; - - focusIndicatorOpacity.set(withSequence(withTiming(0.8, {duration: 250}), withDelay(1000, withTiming(0, {duration: 250})))); - focusIndicatorScale.set(2); - focusIndicatorScale.set(withSpring(1, {damping: 10, stiffness: 200})); - focusIndicatorPosition.set(point); - - scheduleOnRN(focusCamera, point); - }); - - useFocusEffect( - useCallback(() => { - setDidCapturePhoto(false); - isCapturingPhoto.current = false; - const refreshCameraPermissionStatus = () => { - CameraPermission?.getCameraPermissionStatus?.() - .then(setCameraPermissionStatus) - .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); - }; - - refreshCameraPermissionStatus(); - - // Refresh permission status when app gain focus - const subscription = AppState.addEventListener('change', (appState) => { - if (appState !== 'active') { - return; - } - - refreshCameraPermissionStatus(); - }); - - return () => { - subscription.remove(); - cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); - cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); - - if (isLoaderVisible) { - setIsLoaderVisible(false); - } - }; - }, [isLoaderVisible, setIsLoaderVisible]), - ); - - const updateScanAndNavigate = useCallback( - (file: FileObject, source: string) => { - // Fix for the issue where the navigation state is lost after returning from device settings https://github.com/Expensify/App/issues/65992 - const navigationState = navigationRef.current?.getState(); - const reportsSplitNavigator = navigationState?.routes?.findLast((route) => route.name === 'ReportsSplitNavigator'); - const hasLostNavigationsState = reportsSplitNavigator && !reportsSplitNavigator.state; - if (hasLostNavigationsState) { - if (backTo) { - Navigation.navigate(backTo as Route); - } else { - Navigation.navigate(ROUTES.INBOX); - } - } else { - navigateBack(); - } - replaceReceipt({transactionID: initialTransactionID, file: file as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); - }, - [initialTransactionID, policy, policyCategories, backTo], - ); + const isArchived = isArchivedReport(reportNameValuePairs); + const isEditing = action === CONST.IOU.ACTION.EDIT; + const isFromGlobalCreate = !!initialTransaction?.isFromGlobalCreate; + const shouldSkipConfirmation = + !!skipConfirmation && !!report?.reportID && !isArchived && !(isPolicyExpenseChat(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); - const getSource = useCallback((file: FileObject) => file.uri ?? '', []); - - const { - isEditing, - shouldAcceptMultipleFiles, - shouldSkipConfirmation, - startLocationPermissionFlow, - setStartLocationPermissionFlow, - receiptFiles, - setReceiptFiles, - navigateToConfirmationStep, - validateFiles, - PDFValidationComponent, - ErrorModal, - setTestReceiptAndNavigate, - } = useReceiptScan({ - report, - reportID, - initialTransactionID, - initialTransaction, - iouType, - action, - currentUserPersonalDetails, - backTo, - backToReport, - isMultiScanEnabled, - isStartingScan, - updateScanAndNavigate, - getSource, - }); - - const {canUseMultiScan, shouldShowMultiScanEducationalPopup, submitReceipts, submitMultiScanReceipts, toggleMultiScan, dismissMultiScanEducationalPopup, blinkStyle, showBlink} = - useMobileReceiptScan({ - initialTransaction, - iouType, - isMultiScanEnabled, - isStartingScan, - receiptFiles, - navigateToConfirmationStep, - shouldSkipConfirmation, - setStartLocationPermissionFlow, - setIsMultiScanEnabled, - }); - - const maybeCancelShutterSpan = useCallback(() => { - if (isMultiScanEnabled) { - return; - } - - cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); - cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); - }, [isMultiScanEnabled]); - - const capturePhoto = useCallback(() => { - if (!isMultiScanEnabled) { - startSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION, { - name: CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION, - op: CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION, - attributes: {[CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: 'native'}, - }); - } - - if (!camera.current && (cameraPermissionStatus === RESULTS.DENIED || cameraPermissionStatus === RESULTS.BLOCKED)) { - maybeCancelShutterSpan(); - askForPermissions(); - return; - } - - const showCameraAlert = () => { - Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); - }; - - if (!camera.current) { - maybeCancelShutterSpan(); - showCameraAlert(); - return; - } + if (backTo || isEditing) { + return ; + } - if (isCapturingPhoto.current) { - maybeCancelShutterSpan(); - return; + if (!isFromGlobalCreate && !isArchived && iouType !== CONST.IOU.TYPE.CREATE) { + if (shouldSkipConfirmation) { + return ; } - - startSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { - name: CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, - op: CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, - parentSpan: getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION), - attributes: {[CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: 'native'}, - }); - - isCapturingPhoto.current = true; - showBlink(); - - const path = getReceiptsUploadFolderPath(); - - camera?.current - ?.takePhoto({ - flash: flash && hasFlash ? 'on' : 'off', - enableShutterSound: !isPlatformMuted, - path, - }) - .then((photo: PhotoFile) => { - setDidCapturePhoto(true); - - const transaction = - isMultiScanEnabled && initialTransaction?.receipt?.source - ? buildOptimisticTransactionAndCreateDraft({ - initialTransaction, - currentUserPersonalDetails, - reportID, - }) - : initialTransaction; - const transactionID = transaction?.transactionID ?? initialTransactionID; - const source = getPhotoSource(photo.path); - const filename = photo.path; - endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); - - const cameraFile = { - uri: source, - name: filename, - type: 'image/jpeg', - source, - }; - - setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); - - if (isEditing) { - updateScanAndNavigate(cameraFile as FileObject, source); - return; - } - - const newReceiptFiles = [...receiptFiles, {file: cameraFile as FileObject, source, transactionID}]; - setReceiptFiles(newReceiptFiles); - - if (isMultiScanEnabled) { - setDidCapturePhoto(false); - isCapturingPhoto.current = false; - return; - } - - submitReceipts(newReceiptFiles); - }) - .catch((error: string) => { - isCapturingPhoto.current = false; - cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); - maybeCancelShutterSpan(); - showCameraAlert(); - Log.warn('Error taking photo', error); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- askForPermissions is not needed - }, [ - cameraPermissionStatus, - isMultiScanEnabled, - translate, - showBlink, - flash, - hasFlash, - isPlatformMuted, - initialTransaction, - currentUserPersonalDetails, - reportID, - initialTransactionID, - isEditing, - receiptFiles, - submitReceipts, - updateScanAndNavigate, - askForPermissions, - ]); - - const cameraLoadingReasonAttributes: SkeletonSpanReasonAttributes = { - context: 'IOURequestStepScan', - cameraPermissionGranted: cameraPermissionStatus === RESULTS.GRANTED, - deviceAvailable: device != null, - }; - - // Wait for camera permission status to render - if (cameraPermissionStatus == null) { - return null; + return ; } - return ( - - { - if (!onLayout) { - return; - } - onLayout(setTestReceiptAndNavigate); - }} - > - {PDFValidationComponent} - - {cameraPermissionStatus !== RESULTS.GRANTED && ( - - - - {translate('receipt.takePhoto')} - {translate('receipt.cameraAccess')} -