diff --git a/assets/images/gallery-plus.svg b/assets/images/gallery-plus.svg new file mode 100644 index 000000000000..008912ce0c73 --- /dev/null +++ b/assets/images/gallery-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/odometer-end.svg b/assets/images/odometer-end.svg new file mode 100644 index 000000000000..beef1b152efc --- /dev/null +++ b/assets/images/odometer-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/odometer-start.svg b/assets/images/odometer-start.svg new file mode 100644 index 000000000000..8b7fac2b7aa9 --- /dev/null +++ b/assets/images/odometer-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2f19a900f330..1546941ff700 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2983,6 +2983,10 @@ const CONST = { DISTANCE_ODOMETER: 'distance-odometer', TIME: 'time', }, + ODOMETER_IMAGE_TYPE: { + START: 'start', + END: 'end', + }, EXPENSE_TYPE: { DISTANCE: 'distance', MANUAL: 'manual', @@ -8026,6 +8030,10 @@ const CONST = { NEW_WORKSPACE: 'FABMenu-NewWorkspace', QUICK_ACTION: 'FABMenu-QuickAction', }, + ODOMETER_EXPENSE: { + CAPTURE_IMAGE_START: 'IOURequestStepDistanceOdometer-CaptureStartImage', + CAPTURE_IMAGE_END: 'IOURequestStepDistanceOdometer-CaptureEndImage', + }, ATTACHMENT_CAROUSEL: { PREVIOUS_BUTTON: 'AttachmentCarousel-PreviousButton', NEXT_BUTTON: 'AttachmentCarousel-NextButton', @@ -8297,13 +8305,26 @@ type Country = keyof typeof CONST.ALL_COUNTRIES; type IOUType = ValueOf; type IOUAction = ValueOf; type IOURequestType = ValueOf; +type OdometerImageType = ValueOf; type FeedbackSurveyOptionID = ValueOf, 'ID'>>; type IOUActionParams = ValueOf; type SubscriptionType = ValueOf; type CancellationType = ValueOf; -export type {Country, IOUAction, IOUType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType, OnboardingInvite, OnboardingAccounting, IOUActionParams}; +export type { + Country, + IOUAction, + IOUType, + IOURequestType, + OdometerImageType, + SubscriptionType, + FeedbackSurveyOptionID, + CancellationType, + OnboardingInvite, + OnboardingAccounting, + IOUActionParams, +}; export {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS, TASK_TO_FEATURE, FRAUD_PROTECTION_EVENT, COUNTRIES_US_BANK_FLOW}; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 13f056926a3d..f13fb09dee72 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -7,7 +7,7 @@ import type {TupleToUnion, ValueOf} from 'type-fest'; import type {UpperCaseCharacters} from 'type-fest/source/internal'; import type {SearchFilterKey, SearchQueryString, UserFriendlyKey} from './components/Search/types'; import type CONST from './CONST'; -import type {IOUAction, IOUType} from './CONST'; +import type {IOUAction, IOUType, OdometerImageType} from './CONST'; import type {ReplacementReason} from './libs/actions/Card'; import type {IOURequestType} from './libs/actions/IOU'; import Log from './libs/Log'; @@ -1431,6 +1431,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: ':action/:iouType/odometer-image/:transactionID/:readingType', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, readingType: OdometerImageType) => + `${action as string}/${iouType as string}/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', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 084f011c8fdc..96fd8179e354 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -365,6 +365,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', STEP_TIME_RATE: 'Money_Request_Step_Time_Rate', STEP_HOURS: 'Money_Request_Step_Hours', diff --git a/src/components/DropZone/DropZoneUI.tsx b/src/components/DropZone/DropZoneUI.tsx index a9079aeb8d88..38d0baba1b0f 100644 --- a/src/components/DropZone/DropZoneUI.tsx +++ b/src/components/DropZone/DropZoneUI.tsx @@ -10,6 +10,12 @@ type DropZoneUIProps = { /** Icon to display in the drop zone */ icon: IconAsset; + /** Icon width to display in the drop zone */ + iconWidth?: number; + + /** Icon height to display in the drop zone */ + iconHeight?: number; + /** Title to display in the drop zone */ dropTitle?: string; @@ -26,7 +32,7 @@ type DropZoneUIProps = { dropWrapperStyles?: StyleProp; }; -function DropZoneUI({icon, dropTitle, dropStyles, dropTextStyles, dropWrapperStyles, dashedBorderStyles}: DropZoneUIProps) { +function DropZoneUI({icon, iconWidth = 100, iconHeight = 100, dropTitle, dropStyles, dropTextStyles, dropWrapperStyles, dashedBorderStyles}: DropZoneUIProps) { const styles = useThemeStyles(); return ( @@ -36,8 +42,8 @@ function DropZoneUI({icon, dropTitle, dropStyles, dropTextStyles, dropWrapperSty {dropTitle} diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index e302fbb3d07b..60746b906dfc 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -61,6 +61,7 @@ import FlagLevelThree from '@assets/images/flag_level_03.svg'; import Folder from '@assets/images/folder.svg'; import Fullscreen from '@assets/images/fullscreen.svg'; import GalleryNotFound from '@assets/images/gallery-not-found.svg'; +import GalleryPlus from '@assets/images/gallery-plus.svg'; import Gallery from '@assets/images/gallery.svg'; import Gear from '@assets/images/gear.svg'; import Heart from '@assets/images/heart.svg'; @@ -326,4 +327,5 @@ export { BillComSquare, CertiniaSquare, ZenefitsSquare, + GalleryPlus, }; diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index c14487ce667b..8255f17c6f24 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -161,6 +161,8 @@ import Fingerprint from '@assets/images/multifactorAuthentication/fingerprint.sv import Mute from '@assets/images/mute.svg'; import NewWindow from '@assets/images/new-window.svg'; import NewWorkspace from '@assets/images/new-workspace.svg'; +import OdometerEnd from '@assets/images/odometer-end.svg'; +import OdometerStart from '@assets/images/odometer-start.svg'; import OfflineCloud from '@assets/images/offline-cloud.svg'; import Offline from '@assets/images/offline.svg'; import Paperclip from '@assets/images/paperclip.svg'; @@ -372,6 +374,8 @@ const Expensicons = { NotificationsAvatar, Offline, OfflineCloud, + OdometerStart, + OdometerEnd, Paperclip, Pause, Pencil, diff --git a/src/languages/de.ts b/src/languages/de.ts index 0d7c52143b5c..0546d70d62c2 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7425,6 +7425,10 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und endReading: 'Lesen beenden', saveForLater: 'Für später speichern', totalDistance: 'Gesamtdistanz', + startTitle: 'Foto des Kilometerzähler-Starts', + endTitle: 'Kilometerzähler-Endfoto', + startMessageWeb: 'Füge ein Foto deines Kilometerzählers vom Beginn deiner Fahrt hinzu. Ziehe eine Datei hierher oder wähle eine zum Hochladen aus.', + endMessageWeb: 'Fügen Sie ein Foto Ihres Kilometerzählers vom Ende Ihrer Fahrt hinzu. Ziehen Sie eine Datei hierher oder wählen Sie eine zum Hochladen aus.', }, }, gps: { diff --git a/src/languages/en.ts b/src/languages/en.ts index f797a1e62716..463cd08e7f3b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7345,6 +7345,10 @@ const translations = { endReading: 'End reading', saveForLater: 'Save for later', totalDistance: 'Total distance', + startTitle: 'Odometer start photo', + endTitle: 'Odometer end photo', + startMessageWeb: 'Add a photo of your odometer from the start of your trip. Drag a file here or choose one to upload.', + endMessageWeb: 'Add a photo of your odometer from the end of your trip. Drag a file here or choose one to upload.', }, }, gps: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 0bcd0578cdcb..8f578981bb0a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7558,6 +7558,10 @@ ${amount} para ${merchant} - ${date}`, endReading: 'Lectura final', saveForLater: 'Guardar para después', totalDistance: 'Distancia total', + startTitle: 'Foto inicial del odómetro', + endTitle: 'Foto final del odómetro', + startMessageWeb: 'Añade una foto de tu odómetro al inicio de tu viaje. Arrastra un archivo aquí o elige uno para subirlo.', + endMessageWeb: 'Añade una foto de tu odómetro al final de tu viaje. Arrastra un archivo aquí o elige uno para subirlo.', }, }, reportCardLostOrDamaged: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 8227beb6b5bf..e78a57104cf0 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7441,6 +7441,11 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip endReading: 'Fin de lecture', saveForLater: 'Enregistrer pour plus tard', totalDistance: 'Distance totale', + startTitle: 'Photo du compteur au départ', + endTitle: 'Photo de fin d’odomètre', + startMessageWeb: + 'Ajoutez une photo de votre compteur kilométrique prise au début de votre trajet. Faites glisser un fichier ici ou choisissez-en un à télécharger.', + endMessageWeb: 'Ajoutez une photo du compteur kilométrique prise à la fin de votre trajet. Faites glisser un fichier ici ou choisissez-en un à télécharger.', }, }, gps: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 6169ff2dfc3f..6fb3f66bf21c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7408,6 +7408,10 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo endReading: 'Fine lettura', saveForLater: 'Salva per dopo', totalDistance: 'Distanza totale', + startTitle: 'Foto iniziale del contachilometri', + endTitle: 'Foto del contachilometri finale', + startMessageWeb: 'Aggiungi una foto del contachilometri all’inizio del tuo viaggio. Trascina qui un file oppure scegli un file da caricare.', + endMessageWeb: 'Aggiungi una foto del contachilometri scattata alla fine del viaggio. Trascina qui un file oppure scegli un file da caricare.', }, }, gps: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7497d8df7f67..5c3bd82f715e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7347,6 +7347,10 @@ ${reportName} endReading: '読み終える', saveForLater: '後で保存', totalDistance: '合計距離', + startTitle: '走行距離計の開始写真', + endTitle: '走行距離計終了時の写真', + startMessageWeb: '旅行の開始時のオドメーターの写真を追加してください。ここにファイルをドラッグするか、またはアップロードするファイルを選択してください。', + endMessageWeb: '旅行の終了時の走行距離計の写真を追加してください。ここにファイルをドラッグするか、アップロードするファイルを選択してください。', }, }, gps: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f9423d3a515b..d97e7b7684cc 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7396,6 +7396,10 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar endReading: 'Lezen beëindigen', saveForLater: 'Bewaren voor later', totalDistance: 'Totale afstand', + startTitle: 'Foto beginstand kilometerteller', + endTitle: 'Eindfoto kilometerteller', + startMessageWeb: 'Voeg een foto toe van de kilometerteller aan het begin van je reis. Sleep een bestand hierheen of kies er een om te uploaden.', + endMessageWeb: 'Voeg een foto toe van je kilometerteller aan het einde van je reis. Sleep hier een bestand naartoe of kies er één om te uploaden.', }, }, gps: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ce9ca4863cd6..a71fc46dd48c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7378,6 +7378,10 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i endReading: 'Zakończ czytanie', saveForLater: 'Zapisz na później', totalDistance: 'Całkowity dystans', + startTitle: 'Zdjęcie początku stanu licznika', + endTitle: 'Zdjęcie końcowe licznika przebiegu', + startMessageWeb: 'Dodaj zdjęcie licznika kilometrów z początku swojej podróży. Przeciągnij tutaj plik lub wybierz go, aby przesłać.', + endMessageWeb: 'Dodaj zdjęcie licznika przebiegu z końca swojej podróży. Przeciągnij tutaj plik lub wybierz jeden, aby go przesłać.', }, }, gps: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 0781ad77b079..dff6b4030883 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7378,6 +7378,10 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e endReading: 'Finalizar leitura', saveForLater: 'Salvar para depois', totalDistance: 'Distância total', + startTitle: 'Foto inicial do hodômetro', + endTitle: 'Foto do hodômetro final', + startMessageWeb: 'Adicione uma foto do hodômetro do início da sua viagem. Arraste um arquivo aqui ou escolha um para enviar.', + endMessageWeb: 'Adicione uma foto do hodômetro do final da sua viagem. Arraste um arquivo aqui ou escolha um para enviar.', }, }, gps: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e1a3b2f98c0f..31ad80024224 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7237,6 +7237,10 @@ ${reportName} endReading: '结束阅读', saveForLater: '稍后保存', totalDistance: '总距离', + startTitle: '里程表起始照片', + endTitle: '里程表结束照片', + startMessageWeb: '从行程开始时添加一张里程表照片。将文件拖到此处或选择一个文件上传。', + endMessageWeb: '在行程结束时添加一张里程表照片。将文件拖到此处或选择一个文件上传。', }, }, gps: { diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 183ff5891626..663d1360b366 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1057,6 +1057,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, @@ -1166,6 +1168,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 e05ad2748ead..29917f116273 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -188,6 +188,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, [SCREENS.MONEY_REQUEST.STEP_TIME_RATE]: () => require('../../../../pages/iou/request/step/IOURequestStepTimeRate').default, [SCREENS.MONEY_REQUEST.STEP_HOURS]: () => require('../../../../pages/iou/request/step/IOURequestStepHours').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 6d7076997711..caa868b3050d 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1615,6 +1615,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 8a76694d8fff..d257be8e92c1 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -20,7 +20,7 @@ import type {ReimbursementAccountStepToOpen} from '@libs/ReimbursementAccountUti import type {AvatarSource} from '@libs/UserAvatarUtils'; import type {AttachmentModalContainerModalProps} from '@pages/media/AttachmentModalScreen/types'; import type CONST from '@src/CONST'; -import type {Country, IOUAction, IOUType} from '@src/CONST'; +import type {Country, IOUAction, IOUType, OdometerImageType} from '@src/CONST'; import type NAVIGATORS from '@src/NAVIGATORS'; import type {Route as ExpensifyRoute, Route as Routes} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -1880,6 +1880,12 @@ type MoneyRequestNavigatorParamList = { backToReport?: string; reportActionID?: string; }; + [SCREENS.MONEY_REQUEST.ODOMETER_IMAGE]: { + action: IOUAction; + iouType: IOUType; + transactionID: string; + readingType: OdometerImageType; + }; [SCREENS.MONEY_REQUEST.CREATE]: { iouType: IOUType; reportID: string; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index ddef31ce6a6d..53077d47a551 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -238,7 +238,7 @@ import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeRecentWaypoints} from import {removeDraftTransaction, removeDraftTransactions} from '@userActions/TransactionEdit'; import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow'; import type {OnboardingCompanySize} from '@userActions/Welcome/OnboardingFlow'; -import type {IOUAction, IOUActionParams, IOUType} from '@src/CONST'; +import type {IOUAction, IOUActionParams, IOUType, OdometerImageType} from '@src/CONST'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -1245,6 +1245,8 @@ function initMoneyRequest({ if (newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { comment.odometerStart = undefined; comment.odometerEnd = undefined; + comment.odometerStartImage = undefined; + comment.odometerEndImage = undefined; } } @@ -1649,6 +1651,38 @@ 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: OdometerImageType, file: File | string, isDraft: boolean) { + const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.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: OdometerImageType, file: File | string, isDraft: boolean) { + const imageKey = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.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 @@ -13453,6 +13487,8 @@ export { setMoneyRequestDistance, setMoneyRequestDistanceRate, setMoneyRequestOdometerReading, + setMoneyRequestOdometerImage, + removeMoneyRequestOdometerImage, setMoneyRequestMerchant, setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index e4a1bf1e9f7b..dca318368e35 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -1,10 +1,12 @@ -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'; import FormHelpMessage from '@components/FormHelpMessage'; +import {GalleryPlus} 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 +18,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 { @@ -39,12 +42,15 @@ import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getPolicyExpenseChat, isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {OdometerImageType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type Transaction from '@src/types/onyx/Transaction'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import DiscardChangesConfirmation from './DiscardChangesConfirmation'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; @@ -69,8 +75,8 @@ function IOURequestStepDistanceOdometer({ const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); + const StyleUtils = useStyleUtils(); const {isExtraSmallScreenHeight} = useResponsiveLayout(); - const isFocused = useIsFocused(); const startReadingInputRef = useRef(null); const endReadingInputRef = useRef(null); @@ -85,12 +91,12 @@ function IOURequestStepDistanceOdometer({ const initialStartReadingRef = useRef(''); const initialEndReadingRef = useRef(''); const hasInitializedRefs = useRef(false); - // Track previous transaction values to detect when transaction is cleared (e.g., tab switch) - const prevTransactionStartRef = useRef(undefined); - const prevTransactionEndRef = useRef(undefined); // 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 prevSelectedTabRef = useRef(undefined); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`, {canBeMissing: true}); const isArchived = isArchivedReport(reportNameValuePairs); @@ -110,6 +116,8 @@ function IOURequestStepDistanceOdometer({ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`, {canBeMissing: true}); const personalPolicy = usePersonalPolicy(); const defaultExpensePolicy = useDefaultExpensePolicy(); + const [selectedTab, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.DISTANCE_REQUEST_TYPE}`, {canBeMissing: true}); + const isLoadingSelectedTab = isLoadingOnyxValue(selectedTabResult); // isEditing: we're changing an already existing odometer expense; isEditingConfirmation: we navigated here by pressing 'Distance' field from the confirmation step during the creation of a new odometer expense to adjust the input before submitting const isEditing = action === CONST.IOU.ACTION.EDIT; @@ -132,51 +140,33 @@ function IOURequestStepDistanceOdometer({ setShouldEnableDiscardConfirmation(!isEditingConfirmation && !isEditing); }, [isEditing, isEditingConfirmation]); - // 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 + // 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 switching away from the odometer tab useEffect(() => { - if (!isFocused) { + if (isLoadingSelectedTab) { return; } - const currentStart = transaction?.comment?.odometerStart; - const currentEnd = transaction?.comment?.odometerEnd; - - // Check if transaction was cleared (had values before, now null/undefined) - // This happens when switching tabs, not during normal typing - const wasCleared = - (prevTransactionStartRef.current !== null && prevTransactionStartRef.current !== undefined && (currentStart === null || currentStart === undefined)) || - (prevTransactionEndRef.current !== null && prevTransactionEndRef.current !== undefined && (currentEnd === null || currentEnd === undefined)); - - const hasTransactionData = (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined); - - // Reset if transaction was cleared (had values before, now null) - // This happens when switching tabs - transaction data is cleared but local state persists - // Also reset if transaction is empty (component remounted after tab switch) - // Don't reset in edit mode as we want to preserve user's changes - const shouldReset = - hasInitializedRefs.current && - !isEditing && - !isEditingConfirmation && - !hasTransactionData && - (wasCleared || (prevTransactionStartRef.current === undefined && prevTransactionEndRef.current === undefined)); - - if (shouldReset) { + const prevSelectedTab = prevSelectedTabRef.current; + if (prevSelectedTab === CONST.TAB_REQUEST.DISTANCE_ODOMETER && selectedTab !== CONST.TAB_REQUEST.DISTANCE_ODOMETER) { setStartReading(''); setEndReading(''); startReadingRef.current = ''; endReadingRef.current = ''; initialStartReadingRef.current = ''; initialEndReadingRef.current = ''; + initialStartImageRef.current = undefined; + initialEndImageRef.current = undefined; setFormError(''); // Force TextInput remount to reset label position setInputKey((prev) => prev + 1); } - // Update refs to track previous values - prevTransactionStartRef.current = currentStart; - prevTransactionEndRef.current = currentEnd; - }, [isFocused, isEditing, isEditingConfirmation, transaction?.comment?.odometerStart, transaction?.comment?.odometerEnd]); + prevSelectedTabRef.current = selectedTab; + }, [selectedTab, isLoadingSelectedTab]); // Initialize initial values refs on mount for DiscardChangesConfirmation // These should never be updated after mount - they represent the "baseline" state @@ -190,8 +180,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) @@ -235,6 +227,48 @@ 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; + } + + // Native: URI string, use directly + if (typeof image === 'string') { + return image; + } + // Web: File object, reuse existing blob URL if present + if (image instanceof File) { + if (typeof image.uri === 'string' && image.uri.length > 0) { + return image.uri; + } + return URL.createObjectURL(image); + } + // Native: Object with uri property (fallback) + return image?.uri; + }, []); + + const startImageSource = useMemo(() => getImageSource(odometerStartImage), [getImageSource, odometerStartImage]); + const endImageSource = useMemo(() => getImageSource(odometerEndImage), [getImageSource, odometerEndImage]); + + useEffect(() => { + return () => { + if (!startImageSource?.startsWith('blob:')) { + return; + } + URL.revokeObjectURL(startImageSource); + }; + }, [startImageSource]); + + useEffect(() => { + return () => { + if (!endImageSource?.startsWith('blob:')) { + return; + } + URL.revokeObjectURL(endImageSource); + }; + }, [endImageSource]); + const buttonText = (() => { if (shouldSkipConfirmation) { return translate('iou.createExpense'); @@ -279,6 +313,13 @@ function IOURequestStepDistanceOdometer({ } }; + const handleCaptureImage = useCallback( + (imageType: OdometerImageType) => { + Navigation.navigate(ROUTES.ODOMETER_IMAGE.getRoute(action, iouType, transactionID, imageType)); + }, + [action, iouType, transactionID], + ); + // Navigate to confirmation page helper - following Manual tab pattern const navigateToConfirmationPage = () => { if (!transactionID || !reportID) { @@ -534,6 +575,31 @@ function IOURequestStepDistanceOdometer({ inputMode={CONST.INPUT_MODE.DECIMAL} /> + handleCaptureImage(CONST.IOU.ODOMETER_IMAGE_TYPE.START)} + style={[ + StyleUtils.getWidthAndHeightStyle(variables.inputHeight, variables.inputHeight), + StyleUtils.getBorderRadiusStyle(variables.componentBorderRadiusMedium), + styles.overflowHidden, + StyleUtils.getBackgroundColorStyle(theme.border), + ]} + > + + {/* End Reading */} @@ -549,6 +615,31 @@ function IOURequestStepDistanceOdometer({ inputMode={CONST.INPUT_MODE.DECIMAL} /> + handleCaptureImage(CONST.IOU.ODOMETER_IMAGE_TYPE.END)} + style={[ + StyleUtils.getWidthAndHeightStyle(variables.inputHeight, variables.inputHeight), + StyleUtils.getBorderRadiusStyle(variables.componentBorderRadiusMedium), + styles.overflowHidden, + StyleUtils.getBackgroundColorStyle(theme.border), + ]} + > + + {/* Total Distance Display - always shown, updated live */} @@ -585,7 +676,9 @@ function IOURequestStepDistanceOdometer({ isEnabled={shouldEnableDiscardConfirmation} getHasUnsavedChanges={() => { const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; - return hasReadingChanges; + const hasImageChanges = + transaction?.comment?.odometerStartImage !== initialStartImageRef.current || transaction?.comment?.odometerEndImage !== initialEndImageRef.current; + return hasReadingChanges || hasImageChanges; }} /> diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx new file mode 100644 index 000000000000..e69de29bb2d1 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..90b42f906233 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx @@ -0,0 +1,181 @@ +import React, {useCallback, useContext, useEffect, useRef} 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 RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; +import useFilesValidation from '@hooks/useFilesValidation'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +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 variables from '@styles/variables'; +import {setMoneyRequestOdometerImage} from '@userActions/IOU'; +import CONST from '@src/CONST'; +import type {IOUAction, IOUType} 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, action, iouType}, + }, +}: IOURequestStepOdometerImageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {isDraggingOver} = useContext(DragAndDropContext); + const lazyIcons = useMemoizedLazyExpensifyIcons(['OdometerStart', 'OdometerEnd']); + const actionValue: IOUAction = action ?? CONST.IOU.ACTION.CREATE; + const iouTypeValue: IOUType = iouType ?? CONST.IOU.TYPE.REQUEST; + const isTransactionDraft = shouldUseTransactionDraft(actionValue, iouTypeValue); + const dropBlobUrlsRef = useRef([]); + const shouldRevokeOnUnmountRef = useRef(true); + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout because drag and drop is not supported on mobile. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + const title = readingType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.startTitle') : translate('distance.odometer.endTitle'); + const message = readingType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.startMessageWeb') : translate('distance.odometer.endMessageWeb'); + const icon = readingType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? lazyIcons.OdometerStart : lazyIcons.OdometerEnd; + const messageHTML = `${message}`; + + const navigateBack = useCallback(() => { + Navigation.goBack(); + }, []); + + const revokeDropBlobUrls = useCallback(() => { + for (const url of dropBlobUrlsRef.current) { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + } + dropBlobUrlsRef.current = []; + }, []); + + const handleImageSelected = useCallback( + (file: FileObject) => { + setMoneyRequestOdometerImage(transactionID, readingType, file as File, isTransactionDraft); + shouldRevokeOnUnmountRef.current = false; + 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) { + return; + } + revokeDropBlobUrls(); + const blobUrls: string[] = []; + for (const file of files) { + const blobUrl = URL.createObjectURL(file); + blobUrls.push(blobUrl); + // eslint-disable-next-line no-param-reassign + file.uri = blobUrl; + } + dropBlobUrlsRef.current = blobUrls; + validateFiles(files as FileObject[], Array.from(event.dataTransfer?.items ?? [])); + }, + [revokeDropBlobUrls, validateFiles], + ); + + useEffect(() => { + return () => { + if (!shouldRevokeOnUnmountRef.current) { + return; + } + revokeDropBlobUrls(); + }; + }, [revokeDropBlobUrls]); + + const desktopUploadView = () => ( + + + {translate('receipt.upload')} + + + + + {({openPicker}) => ( +