From 5c6640400b56c94883e4bdf4a71f51c8e5ae911c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 14 Jan 2026 15:22:57 +0100 Subject: [PATCH 01/27] Added image displays for odometer --- src/ROUTES.ts | 9 +- src/SCREENS.ts | 1 + src/libs/DebugUtils.ts | 4 + .../ModalStackNavigators/index.tsx | 1 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/Navigation/types.ts | 5 + .../iou/request/DistanceRequestStartPage.tsx | 22 ++-- .../step/IOURequestStepDistanceOdometer.tsx | 122 +++++++++++++++++- .../step/IOURequestStepOdometerImage.tsx | 0 src/types/onyx/Transaction.ts | 7 + 10 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 src/pages/iou/request/step/IOURequestStepOdometerImage.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 16c126be5dfe..f065cd9c95d9 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1319,6 +1319,11 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/distance-new${backToReport ? `/${backToReport}` : ''}/distance-odometer` as const, }, + ODOMETER_IMAGE: { + route: 'odometer-image/:transactionID/:readingType', + getRoute: (transactionID: string, readingType: 'start' | 'end') => + `odometer-image/${transactionID}/${readingType}` as const, + }, IOU_SEND_ADD_BANK_ACCOUNT: 'pay/new/add-bank-account', IOU_SEND_ADD_DEBIT_CARD: 'pay/new/add-debit-card', IOU_SEND_ENABLE_PAYMENTS: 'pay/new/enable-payments', @@ -2830,14 +2835,14 @@ const ROUTES = { TRANSACTION_RECEIPT: { route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?', - getRoute: (reportID: string | undefined, transactionID: string | undefined, readonly = false, mergeTransactionID?: string) => { + getRoute: (reportID: string | undefined, transactionID: string | undefined, readonly = false, mergeTransactionID?: string, imageType?: 'start' | 'end',) => { if (!reportID) { Log.warn('Invalid reportID is used to build the TRANSACTION_RECEIPT route'); } if (!transactionID) { Log.warn('Invalid transactionID is used to build the TRANSACTION_RECEIPT route'); } - return `r/${reportID}/transaction/${transactionID}/receipt?readonly=${readonly}${mergeTransactionID ? `&mergeTransactionID=${mergeTransactionID}` : ''}` as const; + return `r/${reportID}/transaction/${transactionID}/receipt?readonly=${readonly}${mergeTransactionID ? `&mergeTransactionID=${mergeTransactionID}` : ''}${imageType ? `&imageType=${imageType}` : ''}` as const; }, }, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 989a9ae3fe92..68e857bea88b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -332,6 +332,7 @@ const SCREENS = { STEP_DISTANCE_MANUAL: 'Money_Request_Step_Distance_Manual', STEP_DISTANCE_GPS: 'Money_Request_Step_Distance_GPS', STEP_DISTANCE_ODOMETER: 'Money_Request_Step_Distance_Odometer', + ODOMETER_IMAGE: 'Money_Request_Odometer_Image', RECEIPT_PREVIEW: 'Money_Request_Receipt_preview', }, diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 8d7af62b6891..de8d98605536 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1056,6 +1056,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) distanceUnit: CONST.RED_BRICK_ROAD_PENDING_ACTION, odometerStart: CONST.RED_BRICK_ROAD_PENDING_ACTION, odometerEnd: CONST.RED_BRICK_ROAD_PENDING_ACTION, + odometerStartImage: CONST.RED_BRICK_ROAD_PENDING_ACTION, + odometerEndImage: CONST.RED_BRICK_ROAD_PENDING_ACTION, attendees: CONST.RED_BRICK_ROAD_PENDING_ACTION, amount: CONST.RED_BRICK_ROAD_PENDING_ACTION, taxAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION, @@ -1164,6 +1166,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) splitsEndDate: 'string', odometerStart: 'number', odometerEnd: 'number', + odometerStartImage: 'object', + odometerEndImage: 'object', }); case 'accountant': return validateObject>(value, { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 8e70d3686499..1685c146e600 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -184,6 +184,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepDistanceManual').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_GPS]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceGPS').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_ODOMETER]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceOdometer').default, + [SCREENS.MONEY_REQUEST.ODOMETER_IMAGE]: () => require('../../../../pages/iou/request/step/IOURequestStepOdometerImage').default, [SCREENS.SET_DEFAULT_WORKSPACE]: () => require('../../../../pages/SetDefaultWorkspacePage').default, }); diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 5dee953b2270..f4e908968024 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1494,6 +1494,7 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MANUAL]: ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.route, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_ODOMETER]: ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.route, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE]: ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.route, + [SCREENS.MONEY_REQUEST.ODOMETER_IMAGE]: ROUTES.ODOMETER_IMAGE.route, [SCREENS.MONEY_REQUEST.HOLD]: ROUTES.MONEY_REQUEST_HOLD_REASON.route, [SCREENS.MONEY_REQUEST.REJECT]: ROUTES.REJECT_MONEY_REQUEST_REASON.route, [SCREENS.MONEY_REQUEST.STEP_MERCHANT]: ROUTES.MONEY_REQUEST_STEP_MERCHANT.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 7854b8717bcd..63145001977d 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1749,6 +1749,10 @@ type MoneyRequestNavigatorParamList = { backToReport?: string; reportActionID?: string; }; + [SCREENS.MONEY_REQUEST.ODOMETER_IMAGE]: { + transactionID: string; + readingType: 'start' | 'end'; + }; [SCREENS.MONEY_REQUEST.CREATE]: { iouType: IOUType; reportID: string; @@ -2698,6 +2702,7 @@ type AttachmentModalScreensParamList = { action?: IOUAction; iouType?: IOUType; mergeTransactionID?: string; + imageType?: 'start' | 'end'; }; [SCREENS.MONEY_REQUEST.RECEIPT_PREVIEW]: AttachmentModalContainerModalProps & { reportID: string; diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index e2bd8a4a32ea..ad7eaf660798 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -221,18 +221,16 @@ function DistanceRequestStartPage({ )} )} - {false && ( - - {() => ( - - - - )} - - )} + + {() => ( + + + + )} + diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 939856581cb8..6091b27567ee 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -1,6 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; import reportsSelector from '@selectors/Attributes'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; @@ -46,6 +46,11 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type Transaction from '@src/types/onyx/Transaction'; +import useStyleUtils from '@hooks/useStyleUtils'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import ReceiptImage from '@components/ReceiptImage'; +import variables from '@styles/variables'; +import {Gallery} from '@components/Icon/Expensicons'; import DiscardChangesConfirmation from './DiscardChangesConfirmation'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; @@ -69,6 +74,7 @@ function IOURequestStepDistanceOdometer({ const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); + const StyleUtils = useStyleUtils(); const {isExtraSmallScreenHeight} = useResponsiveLayout(); const isFocused = useIsFocused(); @@ -91,6 +97,8 @@ function IOURequestStepDistanceOdometer({ // Track local state via refs to avoid including them in useEffect dependencies const startReadingRef = useRef(''); const endReadingRef = useRef(''); + const initialStartImageRef = useRef(undefined); + const initialEndImageRef = useRef(undefined); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`, {canBeMissing: true}); const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, {canBeMissing: true}); @@ -126,6 +134,11 @@ function IOURequestStepDistanceOdometer({ const shouldSkipConfirmation: boolean = !skipConfirmation || !report?.reportID ? false : !(isArchivedReport(reportNameValuePairs) || isPolicyExpenseChatUtils(report)); + // Get odometer images from transaction (only for display, not for initialization) + const odometerStartImage = transaction?.comment?.odometerStartImage; + const odometerEndImage = transaction?.comment?.odometerEndImage; + + // Reset component state when transaction has no odometer data (happens when switching tabs) // In Phase 1, we don't persist data from transaction since users can't save and exit useEffect(() => { @@ -162,6 +175,8 @@ function IOURequestStepDistanceOdometer({ endReadingRef.current = ''; initialStartReadingRef.current = ''; initialEndReadingRef.current = ''; + initialStartImageRef.current = undefined; + initialEndImageRef.current = undefined; setFormError(''); // Force TextInput remount to reset label position setInputKey((prev) => prev + 1); @@ -184,8 +199,10 @@ function IOURequestStepDistanceOdometer({ const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; + initialStartImageRef.current = transaction?.comment?.odometerStartImage; + initialEndImageRef.current = transaction?.comment?.odometerEndImage; hasInitializedRefs.current = true; - }, [transaction?.comment?.odometerStart, transaction?.comment?.odometerEnd]); + }, [transaction?.comment?.odometerStart, transaction?.comment?.odometerEnd, transaction?.comment?.odometerStartImage, transaction?.comment?.odometerEndImage]); // Initialize values from transaction when editing or when transaction has data (but not when switching tabs) // This updates the current state, but NOT the initial refs (those are set only once on mount) @@ -229,6 +246,29 @@ function IOURequestStepDistanceOdometer({ return distance <= 0 ? 0 : distance; })(); + // Get image source for web (blob URL) or native (URI string) + const getImageSource = useCallback((image: File | string | {uri?: string} | undefined): string | undefined => { + if (!image) { + return undefined; + } + // Web: File object, create blob URL + if (typeof image !== 'string' && image instanceof File) { + return URL.createObjectURL(image); + } + // Native: URI string, use directly + if (typeof image === 'string') { + return image; + } + // Native: Object with uri property (fallback for compatibility) + if (typeof image === 'object' && 'uri' in image && typeof image.uri === 'string') { + return image.uri; + } + return undefined; + }, []); + + const startImageSource = getImageSource(odometerStartImage); + const endImageSource = getImageSource(odometerEndImage); + const buttonText = (() => { if (shouldSkipConfirmation) { return translate('iou.createExpense'); @@ -273,6 +313,24 @@ function IOURequestStepDistanceOdometer({ } }; + const handleCaptureImage = useCallback( + (imageType: 'start' | 'end') => { + Navigation.navigate(ROUTES.ODOMETER_IMAGE.getRoute(transactionID, imageType)); + }, + [transactionID], + ); + + const handleViewOdometerImage = useCallback( + (imageType: 'start' | 'end') => { + if (!reportID || !transactionID) { + return; + } + // Navigate to receipt modal with imageType parameter + Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(reportID, transactionID, false, undefined, imageType)); + }, + [reportID, transactionID], + ); + // Navigate to confirmation page helper - following Manual tab pattern const navigateToConfirmationPage = () => { if (!transactionID || !reportID) { @@ -515,6 +573,36 @@ function IOURequestStepDistanceOdometer({ inputMode={CONST.INPUT_MODE.DECIMAL} /> + { + if (odometerStartImage) { + handleViewOdometerImage('start'); + } else { + handleCaptureImage('start'); + } + }} + style={[ + StyleUtils.getWidthAndHeightStyle(variables.h40, variables.w40), + StyleUtils.getBorderRadiusStyle(variables.componentBorderRadiusMedium), + styles.overflowHidden, + StyleUtils.getBackgroundColorStyle(theme.border), + ]} + > + + {/* End Reading */} @@ -530,6 +618,36 @@ function IOURequestStepDistanceOdometer({ inputMode={CONST.INPUT_MODE.DECIMAL} /> + { + if (odometerEndImage) { + handleViewOdometerImage('end'); + } else { + handleCaptureImage('end'); + } + }} + style={[ + StyleUtils.getWidthAndHeightStyle(variables.h40, variables.w40), + StyleUtils.getBorderRadiusStyle(variables.componentBorderRadiusMedium), + styles.overflowHidden, + StyleUtils.getBackgroundColorStyle(theme.border), + ]} + > + + {/* Total Distance Display - always shown, updated live */} diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index aa97dbbde5fe..d8e11a2f3021 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -125,6 +125,13 @@ type Comment = { /** Odometer end reading for distance expenses */ odometerEnd?: number; + + /** Both image fields are needed only locally because server receives only one merged image as receipt */ + /** Odometer start image (File object on web, URI string on native) */ + odometerStartImage?: File | string; + + /** Odometer end image (File object on web, URI string on native) */ + odometerEndImage?: File | string; }; /** Model of transaction custom unit */ From a41240630cf3abf7984974c7241346e1e44d9eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 14 Jan 2026 15:34:11 +0100 Subject: [PATCH 02/27] Prettier run --- src/ROUTES.ts | 5 ++--- .../request/step/IOURequestStepDistanceOdometer.tsx | 11 +++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f065cd9c95d9..1cf1d5c98af1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1321,8 +1321,7 @@ const ROUTES = { }, ODOMETER_IMAGE: { route: 'odometer-image/:transactionID/:readingType', - getRoute: (transactionID: string, readingType: 'start' | 'end') => - `odometer-image/${transactionID}/${readingType}` as const, + getRoute: (transactionID: string, readingType: 'start' | 'end') => `odometer-image/${transactionID}/${readingType}` as const, }, IOU_SEND_ADD_BANK_ACCOUNT: 'pay/new/add-bank-account', IOU_SEND_ADD_DEBIT_CARD: 'pay/new/add-debit-card', @@ -2835,7 +2834,7 @@ const ROUTES = { TRANSACTION_RECEIPT: { route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?', - getRoute: (reportID: string | undefined, transactionID: string | undefined, readonly = false, mergeTransactionID?: string, imageType?: 'start' | 'end',) => { + getRoute: (reportID: string | undefined, transactionID: string | undefined, readonly = false, mergeTransactionID?: string, imageType?: 'start' | 'end') => { if (!reportID) { Log.warn('Invalid reportID is used to build the TRANSACTION_RECEIPT route'); } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 6091b27567ee..f6997ba29dd5 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -5,6 +5,9 @@ import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; +import {Gallery} from '@components/Icon/Expensicons'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import ReceiptImage from '@components/ReceiptImage'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; @@ -16,6 +19,7 @@ import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import { @@ -40,17 +44,13 @@ import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {getPolicyExpenseChat, isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type Transaction from '@src/types/onyx/Transaction'; -import useStyleUtils from '@hooks/useStyleUtils'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import ReceiptImage from '@components/ReceiptImage'; -import variables from '@styles/variables'; -import {Gallery} from '@components/Icon/Expensicons'; import DiscardChangesConfirmation from './DiscardChangesConfirmation'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; @@ -138,7 +138,6 @@ function IOURequestStepDistanceOdometer({ const odometerStartImage = transaction?.comment?.odometerStartImage; const odometerEndImage = transaction?.comment?.odometerEndImage; - // Reset component state when transaction has no odometer data (happens when switching tabs) // In Phase 1, we don't persist data from transaction since users can't save and exit useEffect(() => { From d79e152ccad236df588dc121c69e78898b072286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 14 Jan 2026 16:21:27 +0100 Subject: [PATCH 03/27] Added IOURequestStepOdometerImage screen for web --- src/languages/en.ts | 4 + src/libs/actions/IOU/index.ts | 37 ++++ .../index.native.tsx} | 0 .../IOURequestStepOdometerImage/index.tsx | 161 ++++++++++++++++++ .../step/withFullTransactionOrNotFound.tsx | 3 +- 5 files changed, 204 insertions(+), 1 deletion(-) rename src/pages/iou/request/step/{IOURequestStepOdometerImage.tsx => IOURequestStepOdometerImage/index.native.tsx} (100%) create mode 100644 src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx diff --git a/src/languages/en.ts b/src/languages/en.ts index 4eba58e91e09..63d73fd7a5e3 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7036,6 +7036,10 @@ const translations = { endReading: 'End reading', saveForLater: 'Save for later', totalDistance: 'Total distance', + startTitle: 'Odometer start photo', + endTitle: 'Odometer end photo', + startMessage: 'Snap a photo of your odometer at the start of your trip', + endMessage: 'Snap a photo of your odometer at the end of your trip', }, }, gps: { diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index c7197fa36f72..f2eb3db694a3 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1135,6 +1135,8 @@ function initMoneyRequest({ if (newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { comment.odometerStart = undefined; comment.odometerEnd = undefined; + comment.odometerStartImage = undefined; + comment.odometerEndImage = undefined; } } @@ -1502,6 +1504,39 @@ function setMoneyRequestOdometerReading(transactionID: string, startReading: num }); } +/** + * Set odometer image for a transaction + * @param transactionID - The transaction ID + * @param imageType - 'start' or 'end' + * @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: 'start' | 'end', file: File | string, isDraft: boolean) { + const imageKey = imageType === 'start' ? 'odometerStartImage' : 'odometerEndImage'; + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + comment: { + [imageKey]: file, + }, + }); +} + +/** + * Remove odometer image from a transaction + * @param transactionID - The transaction ID + * @param imageType - 'start' or 'end' + * @param file - The image file (File object on web, URI string on native) + * @param isDraft - Whether this is a draft transaction + */ +function removeMoneyRequestOdometerImage(transactionID: string, imageType: 'start' | 'end', file: File | string, isDraft: boolean) { + const imageKey = imageType === 'start' ? 'odometerStartImage' : 'odometerEndImage'; + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + comment: { + [imageKey]: null, + }, + }); +} + + /** * Set the distance rate of a transaction. * Used when creating a new transaction or moving an existing one from Self DM @@ -14680,6 +14715,8 @@ export { setMoneyRequestDistance, setMoneyRequestDistanceRate, setMoneyRequestOdometerReading, + setMoneyRequestOdometerImage, + removeMoneyRequestOdometerImage, setMoneyRequestMerchant, setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport, diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx similarity index 100% rename from src/pages/iou/request/step/IOURequestStepOdometerImage.tsx rename to src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx new file mode 100644 index 000000000000..7fb6bbdb0f5a --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -0,0 +1,161 @@ +import React, {useCallback, useContext} from 'react'; +import {View} from 'react-native'; +import AttachmentPicker from '@components/AttachmentPicker'; +import Button from '@components/Button'; +import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; +import {DragAndDropContext} from '@components/DragAndDrop/Provider'; +import DropZoneUI from '@components/DropZone/DropZoneUI'; +import Icon from '@components/Icon'; +import Text from '@components/Text'; +import useFilesValidation from '@hooks/useFilesValidation'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {isMobile} from '@libs/Browser'; +import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import StepScreenDragAndDropWrapper from '@pages/iou/request/step/StepScreenDragAndDropWrapper'; +import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; +import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound'; +import {setMoneyRequestOdometerImage} from '@userActions/IOU'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import type {FileObject} from '@src/types/utils/Attachment'; + +type IOURequestStepOdometerImageProps = WithFullTransactionOrNotFoundProps; + +function IOURequestStepOdometerImage({ + route: { + params: {transactionID, readingType}, + }, +}: IOURequestStepOdometerImageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {isDraggingOver} = useContext(DragAndDropContext); + const lazyIllustrations = useMemoizedLazyIllustrations(['ReceiptUpload']); + const lazyIcons = useMemoizedLazyExpensifyIcons(['ReceiptScan']); + const isTransactionDraft = shouldUseTransactionDraft(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.REQUEST); + + const title = readingType === 'start' ? translate('distance.odometer.startTitle') : translate('distance.odometer.endTitle'); + const message = readingType === 'start' ? translate('distance.odometer.startMessage') : translate('distance.odometer.endMessage'); + + const navigateBack = useCallback(() => { + Navigation.goBack(); + }, []); + + const handleImageSelected = useCallback( + (file: FileObject) => { + setMoneyRequestOdometerImage(transactionID, readingType, file as File, isTransactionDraft); + navigateBack(); + }, + [transactionID, readingType, isTransactionDraft, navigateBack], + ); + + const {validateFiles, ErrorModal} = useFilesValidation((files: FileObject[]) => { + if (files.length === 0) { + return; + } + const file = files.at(0); + if (!file) { + return; + } + // For file selection, source is the blob URL + handleImageSelected(file); + }); + + const handleDrop = useCallback( + (event: DragEvent) => { + const files = Array.from(event.dataTransfer?.files ?? []); + if (files.length > 0) { + validateFiles(files as FileObject[]); + } + }, + [validateFiles], + ); + + const desktopUploadView = () => ( + + + {translate('receipt.upload')} + {message} + + + {({openPicker}) => ( +