diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index 58d6cc2f57e2..c9792b304789 100644
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -164,6 +164,9 @@ type MoneyRequestConfirmationListProps = {
/** Whether the expense is an odometer distance expense */
isOdometerDistanceRequest?: boolean;
+ /** Whether the odometer receipt is currently being stitched */
+ isLoadingReceipt?: boolean;
+
/** Whether the expense is a GPS distance expense */
isGPSDistanceRequest: boolean;
@@ -237,6 +240,7 @@ function MoneyRequestConfirmationList({
isDistanceRequest,
isManualDistanceRequest,
isOdometerDistanceRequest = false,
+ isLoadingReceipt = false,
isGPSDistanceRequest,
isPerDiemRequest = false,
isPolicyExpenseChat = false,
@@ -1263,7 +1267,7 @@ function MoneyRequestConfirmationList({
enterKeyEventListenerPriority={1}
useKeyboardShortcuts
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- isLoading={isConfirmed || isConfirming}
+ isLoading={isConfirmed || isConfirming || isLoadingReceipt}
sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.CONFIRMATION_SUBMIT_BUTTON}
/>
@@ -1303,6 +1307,7 @@ function MoneyRequestConfirmationList({
styles.productTrainingTooltipWrapper,
shouldShowProductTrainingTooltip,
renderProductTrainingTooltip,
+ isLoadingReceipt,
]);
const isCompactMode = useMemo(() => !showMoreFields && isScanRequest, [isScanRequest, showMoreFields]);
@@ -1341,6 +1346,7 @@ function MoneyRequestConfirmationList({
isDistanceRequest={isDistanceRequest}
isManualDistanceRequest={isManualDistanceRequest}
isOdometerDistanceRequest={isOdometerDistanceRequest}
+ isLoadingReceipt={isLoadingReceipt}
isGPSDistanceRequest={isGPSDistanceRequest}
isPerDiemRequest={isPerDiemRequest}
isTimeRequest={isTimeRequest}
@@ -1436,5 +1442,6 @@ export default memo(
prevProps.isTimeRequest === nextProps.isTimeRequest &&
prevProps.iouTimeCount === nextProps.iouTimeCount &&
prevProps.iouTimeRate === nextProps.iouTimeRate &&
- prevProps.shouldHideToSection === nextProps.shouldHideToSection,
+ prevProps.shouldHideToSection === nextProps.shouldHideToSection &&
+ prevProps.isLoadingReceipt === nextProps.isLoadingReceipt,
);
diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx
index 24c8b8bbebd5..70b8b006da42 100644
--- a/src/components/MoneyRequestConfirmationListFooter.tsx
+++ b/src/components/MoneyRequestConfirmationListFooter.tsx
@@ -58,6 +58,7 @@ import type * as OnyxTypes from '@src/types/onyx';
import type {Attendee, Participant} from '@src/types/onyx/IOU';
import type {Unit} from '@src/types/onyx/Policy';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import ActivityIndicator from './ActivityIndicator';
import Badge from './Badge';
import Button from './Button';
import ConfirmedRoute from './ConfirmedRoute';
@@ -146,6 +147,9 @@ type MoneyRequestConfirmationListFooterProps = {
/** Flag indicating if it is an odometer distance request */
isOdometerDistanceRequest?: boolean;
+ /** Whether the receipt is currently being stitched */
+ isLoadingReceipt?: boolean;
+
/** Flag indicating if it is a GPS distance request */
isGPSDistanceRequest: boolean;
@@ -288,6 +292,7 @@ function MoneyRequestConfirmationListFooter({
isDistanceRequest,
isManualDistanceRequest,
isOdometerDistanceRequest = false,
+ isLoadingReceipt = false,
isGPSDistanceRequest,
isPerDiemRequest,
isTimeRequest,
@@ -1208,91 +1213,92 @@ function MoneyRequestConfirmationListFooter({
return (
- {isLocalFile && Str.isPDF(receiptFilename) ? (
- {
- if (!transactionID) {
- return;
- }
+ {isLoadingReceipt && }
+ {!isLoadingReceipt &&
+ (isLocalFile && Str.isPDF(receiptFilename) ? (
+ {
+ if (!transactionID) {
+ return;
+ }
- Navigation.navigate(
- isReceiptEditable
- ? ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.getRoute(reportID, transactionID, action, iouType)
- : ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID),
- );
- }}
- accessibilityRole={CONST.ROLE.BUTTON}
- accessibilityLabel={translate('accessibilityHints.viewAttachment')}
- sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.PDF_RECEIPT_THUMBNAIL}
- disabled={!shouldDisplayReceipt}
- disabledStyle={styles.cursorDefault}
- style={styles.h100}
- >
-
-
- ) : (
- {
- if (!transactionID) {
- return;
- }
+ >
+
+
+ ) : (
+ {
+ if (!transactionID) {
+ return;
+ }
- Navigation.navigate(
- isReceiptEditable
- ? ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.getRoute(reportID, transactionID, action, iouType)
- : ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID),
- );
- }}
- disabled={!shouldDisplayReceipt || isThumbnail}
- accessibilityRole={CONST.ROLE.BUTTON}
- accessibilityLabel={translate('accessibilityHints.viewAttachment')}
- sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.RECEIPT_THUMBNAIL}
- disabledStyle={styles.cursorDefault}
- style={receiptThumbnailStyle}
- >
-
-
- )}
+ Navigation.navigate(
+ isReceiptEditable
+ ? ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.getRoute(reportID, transactionID, action, iouType)
+ : ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID),
+ );
+ }}
+ disabled={!shouldDisplayReceipt || isThumbnail}
+ accessibilityRole={CONST.ROLE.BUTTON}
+ accessibilityLabel={translate('accessibilityHints.viewAttachment')}
+ sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.RECEIPT_THUMBNAIL}
+ disabledStyle={styles.cursorDefault}
+ style={receiptThumbnailStyle}
+ >
+
+
+ ))}
);
}, [
isCompactMode,
compactReceiptContainerStyle,
styles.expenseViewImageSmall,
- styles.moneyRequestImage,
- styles.flex1,
styles.h100,
+ styles.flex1,
+ styles.moneyRequestImage,
+ styles.justifyContentCenter,
+ styles.alignItemsCenter,
styles.cursorDefault,
+ isLoadingReceipt,
+ handleCompactReceiptContainerLayout,
isLocalFile,
receiptFilename,
- transactionID,
- isReceiptEditable,
- reportID,
- action,
- iouType,
translate,
shouldDisplayReceipt,
resolvedReceiptImage,
@@ -1303,9 +1309,13 @@ function MoneyRequestConfirmationListFooter({
receiptThumbnail,
fileExtension,
isDistanceRequest,
- isOdometerDistanceRequest,
handleReceiptLoad,
- handleCompactReceiptContainerLayout,
+ isOdometerDistanceRequest,
+ transactionID,
+ isReceiptEditable,
+ reportID,
+ action,
+ iouType,
]);
const visibleFields = fields.filter((field) => field.shouldShow);
@@ -1396,7 +1406,7 @@ function MoneyRequestConfirmationListFooter({
)}
{(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) &&
- (hasReceiptImageOrThumbnail
+ (hasReceiptImageOrThumbnail || isLoadingReceipt
? receiptThumbnailContent
: showReceiptEmptyState && (
{
@@ -46,13 +47,13 @@ async function stitchOdometerImages(image1: FileObject | string | undefined, ima
// Delete any previously stitched files before creating a new one
try {
const tempDirContents = await RNFS.readDir(RNFS.TemporaryDirectoryPath);
- const oldStitchedFiles = tempDirContents.filter((f) => f.name.startsWith('stitched_odometer_') && f.name.endsWith('.jpg'));
+ const oldStitchedFiles = tempDirContents.filter((f) => f.name.startsWith(`${STITCHED_ODOMETER_FILENAME_PREFIX}_`) && f.name.endsWith('.jpg'));
await Promise.all(oldStitchedFiles.map((f) => RNFS.unlink(f.path)));
} catch (error) {
Log.warn('stitchOdometerImages (native) failed to clean up old stitched files', {error});
}
- const filename = `stitched_odometer_${Date.now()}.jpg`;
+ const filename = `${STITCHED_ODOMETER_FILENAME_PREFIX}_${Date.now()}.jpg`;
const tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`;
await RNFS.writeFile(tempPath, base64, 'base64');
diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts
index f388673f5bff..51bfac1c8a1d 100644
--- a/src/libs/stitchOdometerImages/index.ts
+++ b/src/libs/stitchOdometerImages/index.ts
@@ -1,4 +1,5 @@
import type {FileObject} from '@src/types/utils/Attachment';
+import STITCHED_ODOMETER_FILENAME_PREFIX from './constants';
import calculateStitchLayout from './stitchLayout';
// Tracks the single active stitched blob URL so that we can revoke it on the next call so at most one blob URL exists at a time
@@ -45,7 +46,7 @@ function stitchOdometerImages(image1: FileObject | string | undefined, image2: F
}
const uri = URL.createObjectURL(blob);
previousBlobUrl = uri;
- resolve({uri, name: 'stitched_odometer.jpg', type: 'image/jpeg'});
+ resolve({uri, name: `${STITCHED_ODOMETER_FILENAME_PREFIX}.jpg`, type: 'image/jpeg'});
}, 'image/jpeg');
});
});
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index ba8756d38e78..9a001fdc6bef 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -5,6 +5,7 @@ import {View} from 'react-native';
import DragAndDropConsumer from '@components/DragAndDrop/Consumer';
import DragAndDropProvider from '@components/DragAndDrop/Provider';
import DropZoneUI from '@components/DropZone/DropZoneUI';
+import FormHelpMessage from '@components/FormHelpMessage';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import LocationPermissionModal from '@components/LocationPermissionModal';
@@ -38,7 +39,7 @@ import {getCurrencySymbol} from '@libs/CurrencyUtils';
import DateUtils from '@libs/DateUtils';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
-import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils';
+import {getMimeTypeFromUri, isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils';
import validateReceiptFile from '@libs/fileDownload/validateReceiptFile';
import getCurrentPosition from '@libs/getCurrentPosition';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
@@ -66,6 +67,7 @@ import {
isReportOutstanding,
isSelectedManagerMcTest,
} from '@libs/ReportUtils';
+import stitchOdometerImages from '@libs/stitchOdometerImages';
import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
import getSubmitExpenseScenario from '@libs/telemetry/getSubmitExpenseScenario';
import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd';
@@ -315,6 +317,8 @@ function IOURequestStepConfirmation({
const gpsRequired = transaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && Object.values(receiptFiles).length && !isTestTransaction && isScanRequest(transaction);
const [isConfirmed, setIsConfirmed] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
+ const [isStitchingReceipt, setIsStitchingReceipt] = useState(false);
+ const [stitchError, setStitchError] = useState('');
const headerTitle = useMemo(() => {
if (isCategorizingTrackExpense) {
@@ -356,7 +360,7 @@ function IOURequestStepConfirmation({
const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT);
- useFetchRoute(transaction, transaction?.comment?.waypoints, action, shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT);
+ useFetchRoute(transaction, transaction?.comment?.waypoints, action, shouldUseTransactionDraft(action, iouType) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT);
useEffect(() => {
endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE);
@@ -413,6 +417,66 @@ function IOURequestStepConfirmation({
}
}, [isOffline, policy?.pendingAction, policyExpenseChatPolicyID, senderPolicyID]);
+ const odometerStartImage = transaction?.comment?.odometerStartImage;
+ const odometerEndImage = transaction?.comment?.odometerEndImage;
+
+ useEffect(() => {
+ if (!isOdometerDistanceRequest) {
+ return;
+ }
+
+ const getImageUri = (img: FileObject | string | null | undefined): string => (typeof img === 'string' ? img : (img?.uri ?? ''));
+ const getImageName = (img: FileObject | string | null | undefined): string => (typeof img === 'string' ? (img.split('/').pop() ?? '') : (img?.name ?? ''));
+ const getImageType = (img: FileObject | string | null | undefined): string | undefined =>
+ typeof img === 'string' ? getMimeTypeFromUri(img) : (img?.type ?? getMimeTypeFromUri(img?.uri ?? ''));
+
+ if (!odometerStartImage || !odometerEndImage) {
+ const singleImage = odometerStartImage ?? odometerEndImage;
+
+ if (!singleImage) {
+ return;
+ }
+
+ setMoneyRequestReceipt(currentTransactionID, getImageUri(singleImage), getImageName(singleImage), shouldUseTransactionDraft(action, iouType), getImageType(singleImage));
+ return;
+ }
+
+ let ignore = false;
+ setIsStitchingReceipt(true);
+ setStitchError('');
+
+ stitchOdometerImages(odometerStartImage, odometerEndImage)
+ .then((stitchedImage) => {
+ if (ignore || !stitchedImage) {
+ return;
+ }
+ setMoneyRequestReceipt(
+ currentTransactionID,
+ getImageUri(stitchedImage),
+ getImageName(stitchedImage),
+ shouldUseTransactionDraft(action, iouType),
+ getImageType(stitchedImage),
+ );
+ })
+ .catch((error: unknown) => {
+ if (ignore) {
+ return;
+ }
+ Log.warn('stitchOdometerImages failed', {error});
+ setStitchError(translate('iou.error.stitchOdometerImagesFailed'));
+ })
+ .finally(() => {
+ if (ignore) {
+ return;
+ }
+ setIsStitchingReceipt(false);
+ });
+
+ return () => {
+ ignore = true;
+ };
+ }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate, iouType]);
+
const defaultBillable = !!policy?.defaultBillable;
useEffect(() => {
if (isMovingTransactionFromTrackExpense) {
@@ -1609,6 +1673,7 @@ function IOURequestStepConfirmation({
}}
/>
)}
+ {!!stitchError && }
('');
const [endReading, setEndReading] = useState('');
const [formError, setFormError] = useState('');
- const [isSubmitting, setIsSubmitting] = useState(false);
// Key to force TextInput remount when resetting state after tab switch
const [inputKey, setInputKey] = useState(0);
@@ -377,7 +373,7 @@ function IOURequestStepDistanceOdometer({
const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS);
const [betas] = useOnyx(ONYXKEYS.BETAS);
// Navigate to next page following Manual tab pattern
- const navigateToNextPage = async () => {
+ const navigateToNextPage = () => {
const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit));
const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit));
setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft);
@@ -385,30 +381,6 @@ function IOURequestStepDistanceOdometer({
const calculatedDistance = roundToTwoDecimalPlaces(distance);
setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft, unit);
- let stitchedImage: FileObject | null = null;
- try {
- stitchedImage = await stitchOdometerImages(odometerStartImage, odometerEndImage);
- } catch (error) {
- Log.warn('stitchOdometerImages failed', {error});
- setFormError(translate('iou.error.stitchOdometerImagesFailed'));
- return;
- }
-
- if (stitchedImage ?? odometerStartImage ?? odometerEndImage) {
- const uri = stitchedImage?.uri ?? startImageSource ?? endImageSource ?? '';
- const name =
- stitchedImage?.name ??
- (typeof odometerStartImage !== 'string' ? odometerStartImage?.name : odometerStartImage?.split('/').pop()) ??
- (typeof odometerEndImage !== 'string' ? odometerEndImage?.name : odometerEndImage?.split('/').pop()) ??
- '';
- const type =
- stitchedImage?.type ??
- (typeof odometerStartImage !== 'string' ? odometerStartImage?.type : getMimeTypeFromUri(odometerStartImage)) ??
- (typeof odometerEndImage !== 'string' ? odometerEndImage?.type : getMimeTypeFromUri(odometerEndImage)) ??
- 'image/jpeg';
- setMoneyRequestReceipt(transactionID, uri, name, isTransactionDraft, type);
- }
-
if (isEditing) {
// In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value
if (isEditingSplit && transaction) {
@@ -510,10 +482,6 @@ function IOURequestStepDistanceOdometer({
// Handle form submission with validation
const handleNext = () => {
- if (isSubmitting) {
- return;
- }
-
// Validation: Start and end readings must not be empty
if (!startReading || !endReading) {
setFormError(translate('iou.error.invalidReadings'));
@@ -546,13 +514,7 @@ function IOURequestStepDistanceOdometer({
}
// When validation passes, call navigateToNextPage
- setIsSubmitting(true);
- navigateToNextPage()
- .catch((error) => {
- Log.warn('navigateToNextPage failed', {error});
- setFormError(translate('common.genericErrorMessage'));
- })
- .finally(() => setIsSubmitting(false));
+ navigateToNextPage();
};
useDiscardChangesConfirmation({
@@ -681,7 +643,6 @@ function IOURequestStepDistanceOdometer({
success
allowBubble={!isEditing}
pressOnEnter
- isLoading={isSubmitting}
medium={isExtraSmallScreenHeight}
large={!isExtraSmallScreenHeight}
style={[styles.w100]}
diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx
index 9b896e347205..94e73084d48c 100644
--- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx
+++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx
@@ -202,7 +202,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
const receiptPath = transaction?.receipt?.source;
useEffect(() => {
- if (!isDraftTransaction || !iouType || !transaction) {
+ if (!isDraftTransaction || !iouType || !transaction || isOdometerImage) {
return;
}
@@ -267,8 +267,13 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
return;
}
removeMoneyRequestOdometerImage(transaction.transactionID, imageType, isDraftTransaction);
- navigation.goBack();
- }, [transaction?.transactionID, imageType, isDraftTransaction, navigation]);
+ const odometerGoBackRoute =
+ isOdometerImage &&
+ (isEditingConfirmation === true
+ ? ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(action ?? CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)
+ : ROUTES.DISTANCE_REQUEST_CREATE_TAB_ODOMETER.getRoute(action ?? CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport));
+ Navigation.goBack(odometerGoBackRoute || undefined);
+ }, [transaction?.transactionID, imageType, isOdometerImage, isDraftTransaction, isEditingConfirmation, action, iouType, transactionID, reportID, backToReport]);
const onDownloadAttachment = useDownloadAttachment({
isAuthTokenRequired,