diff --git a/cspell.json b/cspell.json index 4e4f03a01f23c..1e1382d4174f9 100644 --- a/cspell.json +++ b/cspell.json @@ -843,6 +843,7 @@ "xmlgateway", "Xours", "Xtheirs", + "XYWH", "yalc", "Yapl", "YAPL", diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index f74d6111cccc3..b4f879deacce9 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -1151,6 +1151,7 @@ function MoneyRequestConfirmationListFooter({ shouldUseInitialObjectPosition={isDistanceRequest} shouldUseFullHeight={isCompactMode} onLoad={handleReceiptLoad} + resizeMode={isOdometerDistanceRequest ? 'contain' : undefined} /> )} @@ -1181,6 +1182,7 @@ function MoneyRequestConfirmationListFooter({ receiptThumbnail, fileExtension, isDistanceRequest, + isOdometerDistanceRequest, handleReceiptLoad, handleCompactReceiptContainerLayout, ]); diff --git a/src/languages/de.ts b/src/languages/de.ts index ac5090cf337af..919586c18d9c8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1451,6 +1451,7 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'Das Enddatum darf nicht mit dem Startdatum übereinstimmen', manySplitsProvided: `Die maximale Anzahl zulässiger Aufteilungen beträgt ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `Der Datumsbereich darf ${CONST.IOU.SPLITS_LIMIT} Tage nicht überschreiten.`, + stitchOdometerImagesFailed: 'Kilometerzählerbilder konnten nicht zusammengeführt werden. Bitte versuchen Sie es später noch einmal.', }, dismissReceiptError: 'Fehler ausblenden', dismissReceiptErrorConfirmation: 'Achtung! Wenn du diesen Fehler schließt, wird deine hochgeladene Quittung vollständig entfernt. Bist du sicher?', diff --git a/src/languages/en.ts b/src/languages/en.ts index 2f7e97db1d289..9b1c79efdd25c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1452,6 +1452,7 @@ const translations = { distanceAmountTooLargeReduceDistance: 'The total amount is too large. Reduce the distance.', distanceAmountTooLargeReduceRate: 'The total amount is too large. Lower the rate.', odometerReadingTooLarge: (formattedMax: string) => `Odometer readings cannot exceed ${formattedMax}.`, + stitchOdometerImagesFailed: 'Failed to combine odometer images. Please try again later.', invalidIntegerAmount: 'Please enter a whole dollar amount before continuing', invalidTaxAmount: (amount: string) => `Maximum tax amount is ${amount}`, invalidSplit: 'The sum of splits must equal the total amount', diff --git a/src/languages/es.ts b/src/languages/es.ts index be67749dfcd57..beec70f1ad854 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1293,6 +1293,7 @@ const translations: TranslationDeepObject = { distanceAmountTooLargeReduceDistance: 'El importe total es demasiado alto. Reduce la distancia.', distanceAmountTooLargeReduceRate: 'El importe total es demasiado alto. Disminuye la tarifa.', odometerReadingTooLarge: (formattedMax: string) => `Las lecturas del odómetro no pueden superar ${formattedMax}.`, + stitchOdometerImagesFailed: 'No se pudieron combinar las imágenes del odómetro. Por favor, inténtalo de nuevo más tarde.', invalidIntegerAmount: 'Por favor, introduce una cantidad entera en dólares antes de continuar', invalidTaxAmount: (amount) => `El importe máximo del impuesto es ${amount}`, invalidSplit: 'La suma de las partes debe ser igual al importe total', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 3a34c0c1947c1..a30fca9a145eb 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1455,6 +1455,7 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'La date de fin ne peut pas être identique à la date de début', manySplitsProvided: `Le nombre maximal de répartitions autorisées est de ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `La plage de dates ne peut pas dépasser ${CONST.IOU.SPLITS_LIMIT} jours.`, + stitchOdometerImagesFailed: 'Échec de la combinaison des images de l’odomètre. Veuillez réessayer plus tard.', }, dismissReceiptError: 'Ignorer l’erreur', dismissReceiptErrorConfirmation: 'Attention ! Ignorer cette erreur supprimera complètement votre reçu téléversé. Êtes-vous sûr ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index e65e7db28213a..33d30460fd7c4 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1448,6 +1448,7 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'La data di fine non può essere uguale alla data di inizio', manySplitsProvided: `Il numero massimo di suddivisioni consentite è ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `L’intervallo di date non può superare ${CONST.IOU.SPLITS_LIMIT} giorni.`, + stitchOdometerImagesFailed: 'Impossibile combinare le immagini del contachilometri. Riprova più tardi.', }, dismissReceiptError: 'Ignora errore', dismissReceiptErrorConfirmation: 'Attenzione! Chiudere questo errore rimuoverà completamente la ricevuta che hai caricato. Sei sicuro?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9ab90d2d8dd3d..5bcd91901137b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1439,6 +1439,7 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: '終了日は開始日と同じにはできません', manySplitsProvided: `分割できる最大数は${CONST.IOU.SPLITS_LIMIT}件です。`, dateRangeExceedsMaxDays: `日付範囲は${CONST.IOU.SPLITS_LIMIT}日を超えることはできません。`, + stitchOdometerImagesFailed: '走行距離計の画像を結合できませんでした。後でもう一度お試しください。', }, dismissReceiptError: 'エラーを閉じる', dismissReceiptErrorConfirmation: 'ご注意ください!このエラーを閉じると、アップロード済みのレシートが完全に削除されます。本当に続行しますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 1ade218c60dfc..171e3a6fc8218 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1446,6 +1446,7 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'De einddatum mag niet gelijk zijn aan de startdatum', manySplitsProvided: `Het maximale aantal toegestane splitsingen is ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `Het datumbereik mag niet meer dan ${CONST.IOU.SPLITS_LIMIT} dagen zijn.`, + stitchOdometerImagesFailed: 'Odometerafbeeldingen combineren mislukt. Probeer het later opnieuw.', }, dismissReceiptError: 'Foutmelding sluiten', dismissReceiptErrorConfirmation: 'Let op! Dit foutbericht negeren verwijdert je geüploade bon volledig. Weet je het zeker?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index dea6dcd3a6ea8..12f514227c79a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1445,6 +1445,7 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'Data zakończenia nie może być taka sama jak data rozpoczęcia', manySplitsProvided: `Maksymalna dozwolona liczba podziałów to ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `Zakres dat nie może przekraczać ${CONST.IOU.SPLITS_LIMIT} dni.`, + stitchOdometerImagesFailed: 'Nie udało się połączyć zdjęć licznika kilometrów. Spróbuj ponownie później.', }, dismissReceiptError: 'Odrzuć błąd', dismissReceiptErrorConfirmation: 'Uwaga! Zamknięcie tego błędu spowoduje całkowite usunięcie przesłanego paragonu. Czy na pewno chcesz kontynuować?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index be11c98aa0865..4c6b597090eb2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1443,6 +1443,7 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'A data de término não pode ser igual à data de início', manySplitsProvided: `O número máximo de divisões permitido é ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `O intervalo de datas não pode exceder ${CONST.IOU.SPLITS_LIMIT} dias.`, + stitchOdometerImagesFailed: 'Falha ao combinar imagens do hodômetro. Tente novamente mais tarde.', }, dismissReceiptError: 'Dispensar erro', dismissReceiptErrorConfirmation: 'Atenção! Ignorar este erro removerá completamente o comprovante que você enviou. Tem certeza?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index f87e2bac47587..f5b3e63f4ac2d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1416,6 +1416,7 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: '结束日期不能与开始日期相同', manySplitsProvided: `允许的最大拆分数为 ${CONST.IOU.SPLITS_LIMIT}。`, dateRangeExceedsMaxDays: `日期范围不能超过 ${CONST.IOU.SPLITS_LIMIT} 天。`, + stitchOdometerImagesFailed: '合并里程表图片失败。请稍后重试。', }, dismissReceiptError: '忽略错误', dismissReceiptErrorConfirmation: '提醒:关闭此错误将彻底删除你上传的收据。确定要继续吗?', diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 034af497c9907..66f24336d65ae 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1670,13 +1670,13 @@ function revokeOdometerImageUri(image: FileObject | string | null | undefined, n * @param file - The image file (File object on web, URI string on native) * @param isDraft - Whether this is a draft transaction */ -function setMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, file: File | string, isDraft: boolean) { +function setMoneyRequestOdometerImage(transactionID: string, imageType: OdometerImageType, file: FileObject | string, isDraft: boolean) { const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? 'odometerStartImage' : 'odometerEndImage'; const normalizedFile: FileObject | string = typeof file === 'string' ? file : { - uri: file.uri ?? (typeof URL !== 'undefined' ? URL.createObjectURL(file) : undefined), + uri: file.uri ?? (typeof URL !== 'undefined' ? URL.createObjectURL(file as Blob) : undefined), name: file.name, type: file.type, size: file.size, diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts new file mode 100644 index 0000000000000..ed02089e95ce1 --- /dev/null +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -0,0 +1,68 @@ +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 calculateStitchLayout from './stitchLayout'; + +async function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { + const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); + const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); + + if (!source1 || !source2) { + return null; + } + + let skImage1 = null; + let skImage2 = null; + let surface = null; + let snapshot = null; + + try { + const [buffer1, buffer2] = await Promise.all([fetch(source1).then((r) => r.arrayBuffer()), fetch(source2).then((r) => r.arrayBuffer())]); + + skImage1 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer1))); + skImage2 = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(new Uint8Array(buffer2))); + + if (!skImage1 || !skImage2) { + throw new Error('Failed to decode odometer images'); + } + + const {width, height, img1Dest, img2Dest} = calculateStitchLayout(skImage1.width(), skImage1.height(), skImage2.width(), skImage2.height()); + + surface = Skia.Surface.MakeOffscreen(width, height); + if (!surface) { + throw new Error('Failed to create Skia surface'); + } + + const canvas = surface.getCanvas(); + const paint = Skia.Paint(); + canvas.drawImageRect(skImage1, Skia.XYWHRect(0, 0, skImage1.width(), skImage1.height()), Skia.XYWHRect(img1Dest.x, img1Dest.y, img1Dest.w, img1Dest.h), paint); + canvas.drawImageRect(skImage2, Skia.XYWHRect(0, 0, skImage2.width(), skImage2.height()), Skia.XYWHRect(img2Dest.x, img2Dest.y, img2Dest.w, img2Dest.h), paint); + surface.flush(); + + snapshot = surface.makeImageSnapshot(); + const base64 = snapshot.encodeToBase64(); + + // 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')); + 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 tempPath = `${RNFS.TemporaryDirectoryPath}/${filename}`; + await RNFS.writeFile(tempPath, base64, 'base64'); + + return {uri: `file://${tempPath}`, name: filename, type: 'image/jpeg'}; + } finally { + skImage1?.dispose?.(); + skImage2?.dispose?.(); + snapshot?.dispose?.(); + surface?.dispose?.(); + } +} + +export default stitchOdometerImages; diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts new file mode 100644 index 0000000000000..f388673f5bffc --- /dev/null +++ b/src/libs/stitchOdometerImages/index.ts @@ -0,0 +1,54 @@ +import type {FileObject} from '@src/types/utils/Attachment'; +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 +let previousBlobUrl: string | null = null; + +function stitchOdometerImages(image1: FileObject | string | undefined, image2: FileObject | string | undefined): Promise { + const source1 = typeof image1 === 'string' ? image1 : (image1?.uri ?? null); + const source2 = typeof image2 === 'string' ? image2 : (image2?.uri ?? null); + + if (!source1 || !source2) { + return Promise.resolve(null); + } + + const loadImage = (src: string): Promise => + new Promise((resolve, reject) => { + const img = new window.Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + + return Promise.all([loadImage(source1), loadImage(source2)]).then(([img1, img2]) => { + const {width, height, img1Dest, img2Dest} = calculateStitchLayout(img1.width, img1.height, img2.width, img2.height); + + const offscreenCanvas = document.createElement('canvas'); + offscreenCanvas.width = width; + offscreenCanvas.height = height; + const ctx = offscreenCanvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + ctx.drawImage(img1, img1Dest.x, img1Dest.y, img1Dest.w, img1Dest.h); + ctx.drawImage(img2, img2Dest.x, img2Dest.y, img2Dest.w, img2Dest.h); + + return new Promise((resolve, reject) => { + offscreenCanvas.toBlob((blob) => { + if (!blob) { + reject(new Error('Canvas toBlob returned null')); + return; + } + if (previousBlobUrl) { + URL.revokeObjectURL(previousBlobUrl); + } + const uri = URL.createObjectURL(blob); + previousBlobUrl = uri; + resolve({uri, name: 'stitched_odometer.jpg', type: 'image/jpeg'}); + }, 'image/jpeg'); + }); + }); +} + +export default stitchOdometerImages; diff --git a/src/libs/stitchOdometerImages/stitchLayout.ts b/src/libs/stitchOdometerImages/stitchLayout.ts new file mode 100644 index 0000000000000..01ada70095a31 --- /dev/null +++ b/src/libs/stitchOdometerImages/stitchLayout.ts @@ -0,0 +1,50 @@ +type ImageRect = {x: number; y: number; w: number; h: number}; + +type StitchLayout = { + width: number; + height: number; + horizontal: boolean; + img1Dest: ImageRect; + img2Dest: ImageRect; +}; + +/** + * Calculates the layout for stitching two odometer images into a single combined image. + * + * Stitching rules: + * - Images are merged horizontally (side-by-side) by default + * - If either image is landscape, images are merged vertically (stacked) + * - The smaller image is resized so the shared edge matches the length of the larger image's shared edge + */ + +function calculateStitchLayout(w1: number, h1: number, w2: number, h2: number): StitchLayout { + const horizontal = !(w1 > h1 || w2 > h2); + + if (horizontal) { + // Side-by-side: scale both images to the same height so the shared vertical edges match + const targetH = Math.max(h1, h2); + const scaledW1 = Math.round((w1 * targetH) / h1); + const scaledW2 = Math.round((w2 * targetH) / h2); + return { + width: scaledW1 + scaledW2, + height: targetH, + horizontal: true, + img1Dest: {x: 0, y: 0, w: scaledW1, h: targetH}, + img2Dest: {x: scaledW1, y: 0, w: scaledW2, h: targetH}, + }; + } + + // Stacked: scale both images to the same width so the shared horizontal edges match + const targetW = Math.max(w1, w2); + const scaledH1 = Math.round((h1 * targetW) / w1); + const scaledH2 = Math.round((h2 * targetW) / w2); + return { + width: targetW, + height: scaledH1 + scaledH2, + horizontal: false, + img1Dest: {x: 0, y: 0, w: targetW, h: scaledH1}, + img2Dest: {x: 0, y: scaledH1, w: targetW, h: scaledH2}, + }; +} + +export default calculateStitchLayout; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 696e841e81b05..421d7a07cf129 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -25,16 +25,18 @@ import useSelfDMReport from '@hooks/useSelfDMReport'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setMoneyRequestDistance, setMoneyRequestOdometerReading, updateMoneyRequestDistance} from '@libs/actions/IOU'; +import {setMoneyRequestDistance, setMoneyRequestOdometerReading, setMoneyRequestReceipt, updateMoneyRequestDistance} from '@libs/actions/IOU'; import {handleMoneyRequestStepDistanceNavigation} from '@libs/actions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; +import stitchOdometerImages from '@libs/stitchOdometerImages'; import {getRateID} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -80,6 +82,7 @@ function IOURequestStepDistanceOdometer({ const [startReading, setStartReading] = useState(''); 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); @@ -367,20 +370,33 @@ function IOURequestStepDistanceOdometer({ const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); const [betas] = useOnyx(ONYXKEYS.BETAS); // Navigate to next page following Manual tab pattern - const navigateToNextPage = () => { + const navigateToNextPage = async () => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); - - // Store odometer readings in transaction.comment.odometerStart/odometerEnd setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); - - // Calculate total distance (endReading - startReading) const distance = end - start; const calculatedDistance = roundToTwoDecimalPlaces(distance); - - // Store total distance in transaction.comment.customUnit.quantity 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) { @@ -481,6 +497,10 @@ 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')); @@ -513,7 +533,13 @@ function IOURequestStepDistanceOdometer({ } // When validation passes, call navigateToNextPage - navigateToNextPage(); + setIsSubmitting(true); + navigateToNextPage() + .catch((error) => { + Log.warn('navigateToNextPage failed', {error}); + setFormError(translate('common.genericErrorMessage')); + }) + .finally(() => setIsSubmitting(false)); }; useDiscardChangesConfirmation({ @@ -642,6 +668,7 @@ function IOURequestStepDistanceOdometer({ success allowBubble={!isEditing} pressOnEnter + isLoading={isSubmitting} medium={isExtraSmallScreenHeight} large={!isExtraSmallScreenHeight} style={[styles.w100]} diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx index 4efd0282eab84..3d09cf677e930 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -229,11 +229,11 @@ function IOURequestStepOdometerImage({ const viewFinderHeight = viewfinderLayout.current?.height ?? NaN; const shouldAlignTop = videoHeight > viewFinderHeight; cropImageToAspectRatio(imageObject, viewfinderLayout.current?.width, viewfinderLayout.current?.height, shouldAlignTop) - .then(({source}) => { + .then(({file, source}) => { if (source !== imageObject.source) { URL.revokeObjectURL(imageObject.source); } - setMoneyRequestOdometerImage(transactionID, imageType, source, isTransactionDraft); + setMoneyRequestOdometerImage(transactionID, imageType, file ?? source, isTransactionDraft); navigateBack(); }) .catch((error: unknown) => { diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 415e3e611bd95..f0b5b1deb2778 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -22,7 +22,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import {getReportAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; import {canEditFieldOfMoneyRequest, isMoneyRequestReport, isTrackExpenseReport} from '@libs/ReportUtils'; -import {getRequestType, hasEReceipt, hasMissingSmartscanFields, hasReceipt, hasReceiptSource, isReceiptBeingScanned} from '@libs/TransactionUtils'; +import {getRequestType, hasEReceipt, hasMissingSmartscanFields, hasReceipt, hasReceiptSource, isOdometerDistanceRequest, isReceiptBeingScanned} from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import type {AttachmentModalBaseContentProps, ThreeDotsMenuItemFactory} from '@pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types'; import AttachmentModalContainer from '@pages/media/AttachmentModalScreen/AttachmentModalContainer'; @@ -122,23 +122,24 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre // Use odometer image if imageType is provided (it's present only when we display odometer image) otherwise use receipt const receiptSource = isDraftTransaction ? transactionDraft?.receipt?.source : tryResolveUrlFromApiRoot(receiptURIs.image ?? ''); - const source = isOdometerImage && odometerImage ? (odometerImageSource ?? receiptSource) : receiptSource; + const source = isOdometerImage ? odometerImageSource : receiptSource; const [sourceUri, setSourceUri] = useState(''); const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); const canEditReceipt = canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT); const canDeleteReceipt = canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT, true); - const shouldShowReplaceReceiptButton = ((canEditReceipt && !readonly) || isDraftTransaction) && !transaction?.receipt?.isTestDriveReceipt; + const receiptFilename = transaction?.receipt?.filename; + const isImage = !!receiptFilename && Str.isImage(receiptFilename); + const isStitchedOdometerReceipt = isOdometerDistanceRequest(transaction) && !imageType; + + const shouldShowReplaceReceiptButton = ((canEditReceipt && !readonly) || isDraftTransaction) && !transaction?.receipt?.isTestDriveReceipt && !isStitchedOdometerReceipt; const shouldShowDeleteReceiptButton = canDeleteReceipt && !readonly && !isDraftTransaction && !transaction?.receipt?.isTestDriveReceipt; const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); const isTrackExpenseActionValue = isTrackExpenseAction(parentReportAction); const iouType = useMemo(() => iouTypeParam ?? (isTrackExpenseActionValue ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT), [isTrackExpenseActionValue, iouTypeParam]); - const receiptFilename = transaction?.receipt?.filename; - const isImage = !!receiptFilename && Str.isImage(receiptFilename); - const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false); const [isRotating, setIsRotating] = useState(false); const [isCropping, setIsCropping] = useState(false);