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]}