Skip to content

Commit 7502ddb

Browse files
authored
Merge pull request Expensify#89293 from callstack-internal/decompose-scan-pr3-variants-router
Decompose scan pr3 variants router
2 parents 229b282 + 7ca2575 commit 7502ddb

24 files changed

Lines changed: 1204 additions & 1391 deletions
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import React from 'react';
2+
import type {OnyxEntry} from 'react-native-onyx';
3+
import useOnyx from '@hooks/useOnyx';
4+
import useReportIsArchived from '@hooks/useReportIsArchived';
5+
import {isPolicyExpenseChat} from '@libs/ReportUtils';
6+
import CONST from '@src/CONST';
7+
import type {IOUAction, IOUType} from '@src/CONST';
8+
import ONYXKEYS from '@src/ONYXKEYS';
9+
import type {Route} from '@src/ROUTES';
10+
import type {Policy, Report} from '@src/types/onyx';
11+
import type Transaction from '@src/types/onyx/Transaction';
12+
import MultiScanGate from './components/MultiScanGate';
13+
import ScanEditReceipt from './components/ScanEditReceipt';
14+
import ScanFromReport from './components/ScanFromReport';
15+
import ScanGlobalCreate from './components/ScanGlobalCreate';
16+
import ScanSkipConfirmation from './components/ScanSkipConfirmation';
17+
18+
type ScanRouterProps = {
19+
report: OnyxEntry<Report>;
20+
action: IOUAction;
21+
iouType: IOUType;
22+
reportID: string;
23+
transactionID: string;
24+
transaction: OnyxEntry<Transaction>;
25+
backTo: Route | undefined;
26+
backToReport: string | undefined;
27+
};
28+
29+
type NonGlobalCreateProps = {
30+
report: OnyxEntry<Report>;
31+
iouType: IOUType;
32+
reportID: string;
33+
transactionID: string;
34+
transaction: OnyxEntry<Transaction>;
35+
backToReport: string | undefined;
36+
};
37+
38+
type NewReceiptProps = NonGlobalCreateProps;
39+
40+
const policyRequiresTagOrCategorySelector = (policy: OnyxEntry<Policy>) => !!policy?.requiresCategory || !!policy?.requiresTag;
41+
42+
/**
43+
* Owns the policy + skip-confirmation subscriptions so the edit and global-create branches don't pay for them.
44+
* Decides between SkipConfirmation (quick action) and FromReport (report (+) entry).
45+
*/
46+
function ScanNonGlobalCreate({report, iouType, reportID, transactionID, transaction, backToReport}: NonGlobalCreateProps) {
47+
const [policyRequiresTagOrCategory] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {selector: policyRequiresTagOrCategorySelector});
48+
const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`);
49+
const shouldSkipConfirmation = !!skipConfirmation && !!report?.reportID && !(isPolicyExpenseChat(report) && policyRequiresTagOrCategory);
50+
51+
if (shouldSkipConfirmation) {
52+
return (
53+
<ScanSkipConfirmation
54+
report={report}
55+
iouType={iouType}
56+
reportID={reportID}
57+
transactionID={transactionID}
58+
transaction={transaction}
59+
backToReport={backToReport}
60+
/>
61+
);
62+
}
63+
64+
return (
65+
<ScanFromReport
66+
report={report}
67+
iouType={iouType}
68+
reportID={reportID}
69+
transactionID={transactionID}
70+
transaction={transaction}
71+
backToReport={backToReport}
72+
/>
73+
);
74+
}
75+
76+
ScanNonGlobalCreate.displayName = 'ScanNonGlobalCreate';
77+
78+
/**
79+
* Splits new-receipt flows: global-create (FAB) vs. report-scoped (FromReport / SkipConfirm).
80+
* The archived-report check lives here so neither global-create nor non-global-create variants need to subscribe.
81+
*/
82+
function ScanNewReceipt({report, iouType, reportID, transactionID, transaction, backToReport}: NewReceiptProps) {
83+
const isArchived = useReportIsArchived(report?.reportID);
84+
const isFromGlobalCreate = !!transaction?.isFromGlobalCreate;
85+
86+
if (!isFromGlobalCreate && !isArchived && iouType !== CONST.IOU.TYPE.CREATE) {
87+
return (
88+
<ScanNonGlobalCreate
89+
report={report}
90+
iouType={iouType}
91+
reportID={reportID}
92+
transactionID={transactionID}
93+
transaction={transaction}
94+
backToReport={backToReport}
95+
/>
96+
);
97+
}
98+
99+
return (
100+
<ScanGlobalCreate
101+
iouType={iouType}
102+
reportID={reportID}
103+
transactionID={transactionID}
104+
transaction={transaction}
105+
backToReport={backToReport}
106+
/>
107+
);
108+
}
109+
110+
ScanNewReceipt.displayName = 'ScanNewReceipt';
111+
112+
/**
113+
* ScanRouter — selects the appropriate scan variant based on route params and transaction state.
114+
*
115+
* Edit branch is a fast-path that subscribes to nothing extra. Non-edit branches go through MultiScanGate
116+
* and the layered ScanNewReceipt/ScanNonGlobalCreate components, which scope their subscriptions to the
117+
* narrowest variant that needs them.
118+
*/
119+
function ScanRouter({report, action, iouType, reportID, transactionID, transaction, backTo, backToReport}: ScanRouterProps) {
120+
const isEditing = action === CONST.IOU.ACTION.EDIT;
121+
122+
if (backTo || isEditing) {
123+
return (
124+
<ScanEditReceipt
125+
report={report}
126+
transactionID={transactionID}
127+
backTo={backTo}
128+
isEditing={isEditing}
129+
/>
130+
);
131+
}
132+
133+
return (
134+
<MultiScanGate>
135+
<ScanNewReceipt
136+
report={report}
137+
iouType={iouType}
138+
reportID={reportID}
139+
transactionID={transactionID}
140+
transaction={transaction}
141+
backToReport={backToReport}
142+
/>
143+
</MultiScanGate>
144+
);
145+
}
146+
147+
ScanRouter.displayName = 'ScanRouter';
148+
149+
export default ScanRouter;

src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpa
1919
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
2020
import {useMultiScanActions, useMultiScanState} from '@pages/iou/request/step/IOURequestStepScan/components/MultiScanContext';
2121
import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera';
22+
import ReceiptPreviews from '@pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews';
2223
import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
2324
import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio';
2425
import variables from '@styles/variables';
2526
import CONST from '@src/CONST';
26-
import type {FileObject} from '@src/types/utils/Attachment';
2727
import {isEmptyObject} from '@src/types/utils/EmptyObject';
2828
import type {CameraProps} from './types';
2929

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

107107
const originalFileName = `receipt_${Date.now()}.png`;
108108
const originalFile = base64ToFile(imageBase64 ?? '', originalFileName);
109+
110+
if (originalFile.size === 0) {
111+
cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE);
112+
cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
113+
return;
114+
}
115+
109116
const imageObject: ImageObject = {file: originalFile, filename: originalFile.name, source: URL.createObjectURL(originalFile)};
110117
// Some browsers center-crop the viewfinder inside the video element (due to object-position: center),
111118
// while other browsers let the video element overflow and the container crops it from the top.
@@ -123,13 +130,6 @@ function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}:
123130
capturePhotoWithFlash(getScreenshot);
124131
};
125132

126-
const emitPickedFiles = (files: FileObject[]) => {
127-
for (const file of files) {
128-
const source = file.uri ?? URL.createObjectURL(file as Blob);
129-
onCapture(file, source);
130-
}
131-
};
132-
133133
return (
134134
<View
135135
onLayout={onLayout}
@@ -235,11 +235,7 @@ function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}:
235235
accessibilityLabel={translate(shouldAcceptMultipleFiles ? 'common.chooseFiles' : 'common.chooseFile')}
236236
role={CONST.ROLE.BUTTON}
237237
style={isMultiScanEnabled && styles.opacity0}
238-
onPress={() => {
239-
openPicker({
240-
onPicked: (data) => emitPickedFiles(data),
241-
});
242-
}}
238+
onPress={() => openPicker({onPicked})}
243239
sentryLabel={shouldAcceptMultipleFiles ? CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILES : CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.CHOOSE_FILE}
244240
>
245241
<Icon
@@ -299,6 +295,12 @@ function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}:
299295
)}
300296
</View>
301297
</View>
298+
{canUseMultiScan && !!onMultiScanSubmit && (
299+
<ReceiptPreviews
300+
isMultiScanEnabled={isMultiScanEnabled}
301+
submit={onMultiScanSubmit}
302+
/>
303+
)}
302304
</View>
303305
);
304306
}

src/pages/iou/request/step/IOURequestStepScan/components/Camera/FileUpload.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const panResponder = PanResponder.create({
2424
* FileUpload — desktop web capture variant.
2525
* Renders a drag-and-drop zone + file picker button + receipt alternative methods.
2626
*/
27-
function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isReplacingReceipt = false, isDraggingOverWrapper}: CameraProps) {
27+
function FileUpload({onPicked, shouldAcceptMultipleFiles = false, onLayout, isReplacingReceipt = false}: CameraProps) {
2828
const theme = useTheme();
2929
const styles = useThemeStyles();
3030
const {translate} = useLocalize();
@@ -51,9 +51,7 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl
5151
return file;
5252
});
5353

54-
if (onDrop) {
55-
onDrop(files, Array.from(e.dataTransfer?.items ?? []));
56-
}
54+
onPicked(files, Array.from(e.dataTransfer?.items ?? []));
5755
};
5856

5957
return (
@@ -65,7 +63,7 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl
6563
style={[styles.flex1, styles.chooseFilesView(false)]}
6664
>
6765
<View style={[styles.flex1, styles.alignItemsCenter, styles.justifyContentCenter]}>
68-
{!(isDraggingOver ?? isDraggingOverWrapper) && (
66+
{!isDraggingOver && (
6967
<View
7068
style={[styles.alignItemsCenter, styles.justifyContentCenter]}
7169
onLayout={(e) => setUploadViewHeight(e.nativeEvent.layout.height)}
@@ -78,7 +76,6 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl
7876
<View
7977
style={[styles.uploadFileViewTextContainer, styles.userSelectNone]}
8078
// PanResponder handlers must be spread onto the View for gesture recognition
81-
8279
{...panResponder.panHandlers}
8380
>
8481
<Text style={[styles.textFileUpload, styles.mb2]}>{translate(shouldAcceptMultipleFiles ? 'receipt.uploadMultiple' : 'receipt.upload')}</Text>
@@ -94,11 +91,7 @@ function FileUpload({onDrop, shouldAcceptMultipleFiles = false, onLayout, isRepl
9491
text={translate(shouldAcceptMultipleFiles ? 'common.chooseFiles' : 'common.chooseFile')}
9592
accessibilityLabel={translate(shouldAcceptMultipleFiles ? 'common.chooseFiles' : 'common.chooseFile')}
9693
style={[styles.p5]}
97-
onPress={() => {
98-
openPicker({
99-
onPicked: (data) => onDrop?.(data, []),
100-
});
101-
}}
94+
onPress={() => openPicker({onPicked})}
10295
sentryLabel={CONST.SENTRY_LABEL.IOU_REQUEST_STEP.SCAN_SUBMIT_BUTTON}
10396
/>
10497
)}

0 commit comments

Comments
 (0)