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,