Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
80d2049
feat: add shared scan utilities
rinej Apr 30, 2026
44124b2
feat: add separate variants
rinej Apr 30, 2026
d18c133
refactor: decompose IOURequestStepScan into variant router
rinej Apr 30, 2026
3803d61
fix: address CI failures in scan decomposition PR
rinej May 4, 2026
d6d415b
Merge remote-tracking branch 'upstream/main' into decompose-scan-pr3-…
rinej May 4, 2026
cff9c8b
chore: lint fixes
rinej May 4, 2026
0005ef1
fix: adjust ScanGlobalCreate report logic
rinej May 4, 2026
6bc2d67
fix: scanGlobalCreate cleanup
rinej May 4, 2026
06af066
add support for camera permission issue
rinej May 5, 2026
aa8ed07
chore: consts refactor
rinej May 5, 2026
7c817b2
fix: restore nav state loss handling, wire multi-scan cleanup, add lo…
rinej May 5, 2026
4e7da49
fix: restore multi-scan, thumbnail pregen and draft cleanup
rinej May 6, 2026
02f2987
refactor: split ScanRouter, dedupe scan variants, complete Camera pro…
rinej May 6, 2026
f63f86a
fix: spellcheck fix
rinej May 6, 2026
89926ee
fix: write optimistic receipt fields before replaceReceipt in edit
rinej May 6, 2026
6f05e33
fix lint
rinej May 6, 2026
60c2147
Merge remote-tracking branch 'upstream/main' into decompose-scan-pr3-…
rinej May 7, 2026
1fdd062
fix: update scan start span, close span on empty size
rinej May 8, 2026
c9bd8a1
refactor: simplify onPicked logic
rinej May 11, 2026
f9f869c
refactor: reuse camera telemetry hook and subcomponents in Camera.native
rinej May 11, 2026
06589b8
Merge remote-tracking branch 'upstream/main' into decompose-scan-pr3-…
rinej May 11, 2026
220377f
fix: restore multi-scan receipt preview ribbon
rinej May 11, 2026
d311279
fix: add multiscan parity
rinej May 12, 2026
c9a42f9
Merge remote-tracking branch 'upstream/main' into decompose-scan-pr3-…
rinej May 12, 2026
6a7122b
Merge remote-tracking branch 'upstream/main' into decompose-scan-pr3-…
rinej May 18, 2026
4a1c356
fix: removed onCameraInitialized from the destructured props
rinej May 18, 2026
108aaa6
fix: adjust startScanProcessSpan in navigateGlobalCreate
rinej May 18, 2026
7495f6d
refactor: submitWithGpsCheck to avoid duplication
rinej May 18, 2026
2795ed1
refactor: rename onMultiScanSubmit
rinej May 18, 2026
e51ae36
refactor: remove isDraggingOverWrapper
rinej May 18, 2026
8798027
fix: multiscan back flow upload fix
rinej May 19, 2026
592c0c2
fix: add skipConfirmation for CREATE iouType in scan router
rinej May 19, 2026
6989719
Revert "fix: add skipConfirmation for CREATE iouType in scan router"
rinej May 20, 2026
8efe2f4
fix: cleanup for leftovers for scan drafts
rinej May 20, 2026
c66a536
refactor: use draftTransactionIDsToCleanUp cleanup
rinej May 21, 2026
ac7d1d6
Merge branch 'main' into decompose-scan-pr3-variants-router
rinej May 21, 2026
cb84388
Merge branch 'main' into decompose-scan-pr3-variants-router
rinej May 21, 2026
d290b7c
ts fixes after main changes
rinej May 21, 2026
e4925fc
fix: add local replace logic for drafts
rinej May 22, 2026
fd45aad
fix: use action to check saved vs draft receipt replace
rinej May 25, 2026
d915ab0
Merge remote-tracking branch 'upstream/main' into decompose-scan-pr3-…
rinej May 25, 2026
a2da36a
Merge remote-tracking branch 'upstream/main' into decompose-scan-pr3-…
rinej May 26, 2026
7ca2575
Merge branch 'main' into decompose-scan-pr3-variants-router
rinej May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import React from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import useOnyx from '@hooks/useOnyx';
import useReportIsArchived from '@hooks/useReportIsArchived';
import {isPolicyExpenseChat} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import type {IOUAction, IOUType} from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import type {Policy, Report} from '@src/types/onyx';
import type Transaction from '@src/types/onyx/Transaction';
import MultiScanGate from './components/MultiScanGate';
import ScanEditReceipt from './components/ScanEditReceipt';
import ScanFromReport from './components/ScanFromReport';
import ScanGlobalCreate from './components/ScanGlobalCreate';
import ScanSkipConfirmation from './components/ScanSkipConfirmation';

type ScanRouterProps = {
report: OnyxEntry<Report>;
action: IOUAction;
iouType: IOUType;
reportID: string;
transactionID: string;
transaction: OnyxEntry<Transaction>;
backTo: Route | undefined;
backToReport: string | undefined;
};

type NonGlobalCreateProps = {
report: OnyxEntry<Report>;
iouType: IOUType;
reportID: string;
transactionID: string;
transaction: OnyxEntry<Transaction>;
backToReport: string | undefined;
};

type NewReceiptProps = NonGlobalCreateProps;

const policyRequiresTagOrCategorySelector = (policy: OnyxEntry<Policy>) => !!policy?.requiresCategory || !!policy?.requiresTag;

/**
* Owns the policy + skip-confirmation subscriptions so the edit and global-create branches don't pay for them.
* Decides between SkipConfirmation (quick action) and FromReport (report (+) entry).
*/
function ScanNonGlobalCreate({report, iouType, reportID, transactionID, transaction, backToReport}: NonGlobalCreateProps) {
const [policyRequiresTagOrCategory] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {selector: policyRequiresTagOrCategorySelector});
const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's not a big deal, but we subscribe to policy, archived state, and skipConfirmation for edit case too. I'm thinking of this plan:

  1. Create a ScanNewReceipt component in this file (ScanRouter) that handles rendering the non-edit case
  2. Then, in ScanNewReceipt, we can separate again into 2 components, 1 for the global create case, and 1 for the non-global create case (ScanNonGlobalCreate). The subscription for policy and skipConfirmation will be done in ScanNonGlobalCreate.
  3. Instead of subscribing to the whole policy, we can use selector to return the requiresCategory and requiresTag only.
the code example
const policyRequiresTagOrCategorySelector = (policy: OnyxEntry<Policy>) => policy?.requiresCategory || policy?.requiresTag;

function ScanNonGlobalCreate({iouType, report, reportID, transaction, transactionID, backToReport}) {
    const [policyRequiresTagOrCategory] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {selector: policyRequiresTagOrCategorySelector});
    const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`);
    const shouldSkipConfirmation = !!skipConfirmation && !!report?.reportID && !(isPolicyExpenseChat(report) && policyRequiresTagOrCategory);

    if (shouldSkipConfirmation) {
        return (
            <ScanSkipConfirmation
                report={report}
                iouType={iouType}
                reportID={reportID}
                transactionID={transactionID}
                transaction={transaction}
                backToReport={backToReport}
            />
        );
    }

    return (
        <ScanFromReport
            report={report}
            iouType={iouType}
            reportID={reportID}
            transactionID={transactionID}
            transaction={transaction}
            backToReport={backToReport}
        />
    );
}

function ScanNewReceipt({iouType, report, reportID, transaction, transactionID, backToReport}) {
    const isArchived = useReportIsArchived(report?.reportID);
    const isFromGlobalCreate = !!transaction?.isFromGlobalCreate;

    if (!isFromGlobalCreate && !isArchived && iouType !== CONST.IOU.TYPE.CREATE) {
        return (
            <ScanNonGlobalCreate iouType={iouType} report={report} reportID={reportID} transaction={transaction} transactionID={transactionID} backToReport={backToReport} />
        );
    }

    return (
        <ScanGlobalCreate
            iouType={iouType}
            reportID={reportID}
            transactionID={transactionID}
            transaction={transaction}
            backToReport={backToReport}
        />
    );
}

/**
 * ScanRouter — thin routing layer that selects the appropriate scan variant
 * based on route params and transaction state.
 *
 * MultiScanGate wraps non-edit variants so they can access MultiScanContext.
 *
 * 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 isEditing = action === CONST.IOU.ACTION.EDIT;

    // Edit/replace receipt flow — no multi-scan needed
    if (backTo || isEditing) {
        return (
            <ScanEditReceipt
                report={report}
                transactionID={transactionID}
                backTo={backTo}
            />
        );
    }

    // Non-edit variants are wrapped in MultiScanGate so they can read multi-scan state
    return (
        <MultiScanGate>
            <ScanNewReceipt iouType={iouType} report={report} reportID={reportID} transaction={transaction} transactionID={transactionID} backToReport={backToReport} />
        </MultiScanGate>
    );
}

const shouldSkipConfirmation = !!skipConfirmation && !!report?.reportID && !(isPolicyExpenseChat(report) && policyRequiresTagOrCategory);

if (shouldSkipConfirmation) {
return (
<ScanSkipConfirmation
report={report}
iouType={iouType}
reportID={reportID}
transactionID={transactionID}
transaction={transaction}
backToReport={backToReport}
/>
);
}

return (
<ScanFromReport
report={report}
iouType={iouType}
reportID={reportID}
transactionID={transactionID}
transaction={transaction}
backToReport={backToReport}
/>
);
}

ScanNonGlobalCreate.displayName = 'ScanNonGlobalCreate';

/**
* Splits new-receipt flows: global-create (FAB) vs. report-scoped (FromReport / SkipConfirm).
* The archived-report check lives here so neither global-create nor non-global-create variants need to subscribe.
*/
function ScanNewReceipt({report, iouType, reportID, transactionID, transaction, backToReport}: NewReceiptProps) {
const isArchived = useReportIsArchived(report?.reportID);
const isFromGlobalCreate = !!transaction?.isFromGlobalCreate;

if (!isFromGlobalCreate && !isArchived && iouType !== CONST.IOU.TYPE.CREATE) {
return (
<ScanNonGlobalCreate
report={report}
iouType={iouType}
reportID={reportID}
transactionID={transactionID}
transaction={transaction}
backToReport={backToReport}
/>
);
}

return (
<ScanGlobalCreate
iouType={iouType}
reportID={reportID}
transactionID={transactionID}
transaction={transaction}
backToReport={backToReport}
/>
);
}

ScanNewReceipt.displayName = 'ScanNewReceipt';

/**
* ScanRouter — selects the appropriate scan variant based on route params and transaction state.
*
* Edit branch is a fast-path that subscribes to nothing extra. Non-edit branches go through MultiScanGate
* and the layered ScanNewReceipt/ScanNonGlobalCreate components, which scope their subscriptions to the
* narrowest variant that needs them.
*/
function ScanRouter({report, action, iouType, reportID, transactionID, transaction, backTo, backToReport}: ScanRouterProps) {
const isEditing = action === CONST.IOU.ACTION.EDIT;

if (backTo || isEditing) {
return (
<ScanEditReceipt
report={report}
transactionID={transactionID}
backTo={backTo}
isEditing={isEditing}
/>
);
}

return (
<MultiScanGate>
<ScanNewReceipt
report={report}
iouType={iouType}
reportID={reportID}
transactionID={transactionID}
transaction={transaction}
backToReport={backToReport}
/>
</MultiScanGate>
);
}

ScanRouter.displayName = 'ScanRouter';

export default ScanRouter;
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpa
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
import {useMultiScanActions, useMultiScanState} from '@pages/iou/request/step/IOURequestStepScan/components/MultiScanContext';
import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera';
import ReceiptPreviews from '@pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews';
import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {FileObject} from '@src/types/utils/Attachment';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type {CameraProps} from './types';

Expand All @@ -34,7 +34,7 @@ const BLINK_DURATION_MS = 80;
* Renders a camera viewfinder, shutter button, flash toggle and gallery picker.
* Calls `onCapture(file, source)` for each photo taken or file picked from the gallery.
*/
function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}: CameraProps) {
function CameraCapture({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayout, onMultiScanSubmit}: CameraProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
Expand Down Expand Up @@ -106,6 +106,13 @@ function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}:

const originalFileName = `receipt_${Date.now()}.png`;
const originalFile = base64ToFile(imageBase64 ?? '', originalFileName);

if (originalFile.size === 0) {
cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);
cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
return;
}

const imageObject: ImageObject = {file: originalFile, filename: originalFile.name, source: URL.createObjectURL(originalFile)};
// Some browsers center-crop the viewfinder inside the video element (due to object-position: center),
// while other browsers let the video element overflow and the container crops it from the top.
Expand All @@ -123,13 +130,6 @@ function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}:
capturePhotoWithFlash(getScreenshot);
};

const emitPickedFiles = (files: FileObject[]) => {
for (const file of files) {
const source = file.uri ?? URL.createObjectURL(file as Blob);
onCapture(file, source);
}
};

return (
<View
onLayout={onLayout}
Expand Down Expand Up @@ -235,11 +235,7 @@ function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}:
accessibilityLabel={translate(shouldAcceptMultipleFiles ? 'common.chooseFiles' : 'common.chooseFile')}
role={CONST.ROLE.BUTTON}
style={isMultiScanEnabled && styles.opacity0}
onPress={() => {
openPicker({
onPicked: (data) => emitPickedFiles(data),
});
}}
onPress={() => openPicker({onPicked})}
sentryLabel={shouldAcceptMultipleFiles ? CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILES : CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILE}
>
<Icon
Expand Down Expand Up @@ -299,6 +295,12 @@ function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}:
)}
</View>
</View>
{canUseMultiScan && !!onMultiScanSubmit && (
<ReceiptPreviews
isMultiScanEnabled={isMultiScanEnabled}
submit={onMultiScanSubmit}
/>
)}
</View>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const panResponder = PanResponder.create({
* FileUpload — desktop web capture variant.
* Renders a drag-and-drop zone + file picker button + receipt alternative methods.
*/
function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isReplacingReceipt = false, isDraggingOverWrapper}: CameraProps) {
function FileUpload({onPicked, shouldAcceptMultipleFiles = false, onLayout, isReplacingReceipt = false}: CameraProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
Expand All @@ -51,9 +51,7 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl
return file;
});

if (onDrop) {
onDrop(files, Array.from(e.dataTransfer?.items ?? []));
}
onPicked(files, Array.from(e.dataTransfer?.items ?? []));
};

return (
Expand All @@ -65,7 +63,7 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl
style={[styles.flex1, styles.chooseFilesView(false)]}
>
<View style={[styles.flex1, styles.alignItemsCenter, styles.justifyContentCenter]}>
{!(isDraggingOver ?? isDraggingOverWrapper) && (
{!isDraggingOver && (
<View
style={[styles.alignItemsCenter, styles.justifyContentCenter]}
onLayout={(e) => setUploadViewHeight(e.nativeEvent.layout.height)}
Expand All @@ -78,7 +76,6 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl
<View
style={[styles.uploadFileViewTextContainer, styles.userSelectNone]}
// PanResponder handlers must be spread onto the View for gesture recognition

{...panResponder.panHandlers}
>
<Text style={[styles.textFileUpload, styles.mb2]}>{translate(shouldAcceptMultipleFiles ? 'receipt.uploadMultiple' : 'receipt.upload')}</Text>
Expand All @@ -94,11 +91,7 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl
text={translate(shouldAcceptMultipleFiles ? 'common.chooseFiles' : 'common.chooseFile')}
accessibilityLabel={translate(shouldAcceptMultipleFiles ? 'common.chooseFiles' : 'common.chooseFile')}
style={[styles.p5]}
onPress={() => {
openPicker({
onPicked: (data) => onDrop?.(data, []),
});
}}
onPress={() => openPicker({onPicked})}
sentryLabel={CONST.SENTRY_LABEL.IOU_REQUEST_STEP.SCAN_SUBMIT_BUTTON}
/>
)}
Expand Down
Loading
Loading