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}) => (
+
+
+ );
+
+ return (
+
+ {(isDraggingOverWrapper) => (
+
+ {!(isDraggingOver ?? isDraggingOverWrapper) && desktopUploadView()}
+
+
+
+ {ErrorModal}
+
+ )}
+
+ );
+}
+
+IOURequestStepOdometerImage.displayName = 'IOURequestStepOdometerImage';
+
+// eslint-disable-next-line rulesdir/no-negated-variables
+const IOURequestStepOdometerImageWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepOdometerImage);
+
+export default IOURequestStepOdometerImageWithFullTransactionOrNotFound;
diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx
index 4e5057b3307f..1c1701575b24 100644
--- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx
+++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx
@@ -52,6 +52,7 @@ type MoneyRequestRouteName =
| typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE
| typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_MANUAL
| typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_ODOMETER
+ | typeof SCREENS.MONEY_REQUEST.ODOMETER_IMAGE
| typeof SCREENS.MONEY_REQUEST.STEP_TIME_RATE
| typeof SCREENS.MONEY_REQUEST.STEP_HOURS
| typeof SCREENS.MONEY_REQUEST.STEP_HOURS_EDIT;
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index 2d150cf9bc7e..f452c9dfe5e6 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 */