diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index c5fc3b3280dfc..ec85c9757a370 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; @@ -235,6 +238,7 @@ function MoneyRequestConfirmationList({ isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest = false, + isLoadingReceipt = false, isGPSDistanceRequest, isPerDiemRequest = false, isPolicyExpenseChat = false, @@ -1303,6 +1307,7 @@ function MoneyRequestConfirmationList({ isDistanceRequest={isDistanceRequest} isManualDistanceRequest={isManualDistanceRequest} isOdometerDistanceRequest={isOdometerDistanceRequest} + isLoadingReceipt={isLoadingReceipt} isGPSDistanceRequest={isGPSDistanceRequest} isPerDiemRequest={isPerDiemRequest} isTimeRequest={isTimeRequest} diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 7819c64f58ad4..b20c287efd788 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -55,6 +55,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'; @@ -139,6 +140,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; @@ -281,6 +285,7 @@ function MoneyRequestConfirmationListFooter({ isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest = false, + isLoadingReceipt = false, isGPSDistanceRequest, isPerDiemRequest, isTimeRequest, @@ -1269,7 +1274,21 @@ function MoneyRequestConfirmationListFooter({ )} + {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && !hasReceiptImageOrThumbnail && isLoadingReceipt && ( + + + + )} {(!shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && + !isLoadingReceipt && (hasReceiptImageOrThumbnail ? receiptThumbnailContent : showReceiptEmptyState && ( diff --git a/src/libs/stitchOdometerImages/constants.ts b/src/libs/stitchOdometerImages/constants.ts new file mode 100644 index 0000000000000..cdee9e92d0375 --- /dev/null +++ b/src/libs/stitchOdometerImages/constants.ts @@ -0,0 +1,3 @@ +const STITCHED_ODOMETER_FILENAME_PREFIX = 'stitched_odometer'; + +export default STITCHED_ODOMETER_FILENAME_PREFIX; diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index ed02089e95ce1..4bef73052c8f8 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -2,6 +2,7 @@ import {Skia} from '@shopify/react-native-skia'; import RNFS from 'react-native-fs'; import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; +import STITCHED_ODOMETER_FILENAME_PREFIX from './constants'; import calculateStitchLayout from './stitchLayout'; async function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { @@ -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 f388673f5bffc..51bfac1c8a1d5 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 19b2c38cf087f..75e0b8508d705 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'; @@ -65,6 +66,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'; @@ -285,6 +287,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) { @@ -383,6 +387,58 @@ function IOURequestStepConfirmation({ } }, [isOffline, policy?.pendingAction, policyExpenseChatPolicyID, senderPolicyID]); + const odometerStartImage = transaction?.comment?.odometerStartImage; + const odometerEndImage = transaction?.comment?.odometerEndImage; + + useEffect(() => { + if (!isOdometerDistanceRequest) { + return; + } + + if (!odometerStartImage || !odometerEndImage) { + const singleImage = odometerStartImage ?? odometerEndImage; + + if (!singleImage) { + return; + } + + const getImageUri = (img: typeof singleImage): string => (typeof img === 'string' ? img : (img.uri ?? '')); + const getImageName = (img: typeof singleImage): string => (typeof img === 'string' ? (img.split('/').pop() ?? '') : (img.name ?? '')); + + setMoneyRequestReceipt(currentTransactionID, getImageUri(singleImage), getImageName(singleImage), shouldUseTransactionDraft(action)); + return; + } + + let ignore = false; + setIsStitchingReceipt(true); + setStitchError(''); + + stitchOdometerImages(odometerStartImage, odometerEndImage) + .then((stitchedImage) => { + if (ignore || !stitchedImage) { + return; + } + setMoneyRequestReceipt(currentTransactionID, stitchedImage.uri ?? '', stitchedImage.name ?? '', shouldUseTransactionDraft(action)); + }) + .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]); + const defaultBillable = !!policy?.defaultBillable; useEffect(() => { if (isMovingTransactionFromTrackExpense) { @@ -1593,6 +1649,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); @@ -370,7 +367,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); @@ -378,25 +375,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()) ?? - ''; - setMoneyRequestReceipt(transactionID, uri, name, isTransactionDraft); - } - if (isEditing) { // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value if (isEditingSplit && transaction) { @@ -497,10 +475,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')); @@ -533,13 +507,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({ @@ -668,7 +636,6 @@ function IOURequestStepDistanceOdometer({ success allowBubble={!isEditing} pressOnEnter - isLoading={isSubmitting} medium={isExtraSmallScreenHeight} large={!isExtraSmallScreenHeight} style={[styles.w100]}