Skip to content

Conversation

@Julesssss
Copy link
Contributor

@Julesssss Julesssss commented Jan 6, 2026

This is a clone @jakubkalinski0's original PR here. Comments on original PR have been addressed there.

The Odometer tab is disabled with these changes and won't appear to users. It will be enabled in later Odometer PRs. It is necessary that we merge to main early and often to prevent conflicts, especially as the GPS tab is being worked on simultaneously.

Here are some images from testing:

  • Screenshot 2026-01-07 at 14 56 03
  • Screenshot 2026-01-07 at 14 56 07
  • Screenshot 2026-01-07 at 14 56 17

Original PR description:

Explanation of Change

This PR adds an Odometer option to the distance request flow allowing user to add odometer readings at the beginning and end of the trip. This explanation and implementation is mapped according to docs (only Phase 1; selected items from 1.A–1.J). Some code fragments are added in the explanation below for clarity. This PR introduces end-to-end handling of this feature allowing to create an actual distance-odometer expense. This PR only allows to add odometer readings without capturing and merging images or the 'Save for later' functionality.

  • 1.A/1.B (new tab + odometer page): New distance-odometer tab and odometer step route. TabSelector uses the meter icon, and DistanceRequestStartPage renders the odometer screen:
    • Tab selector entry:
case CONST.TAB_REQUEST.DISTANCE_ODOMETER:
    return {icon: icons.Meter, title: translate('tabSelector.odometer'), testID: 'distanceOdometer'};
  • Odometer screen mounted in distance start tabs:
<TopTab.Screen name={CONST.TAB_REQUEST.DISTANCE_ODOMETER}>
    {() => (
        <TabScreenWithFocusTrapWrapper>
            <IOURequestStepDistanceOdometer
                route={route}
                navigation={navigation}
            />
        </TabScreenWithFocusTrapWrapper>
    )}
</TopTab.Screen>
  • 1.C/1.F (validation + live total + errors): Odometer step enforces both readings and end > start before navigation:
if (!startReading || !endReading) {
    setFormError(translate('distance.odometer.readingRequired'));
    return;
}
const distance = end - start;
if (distance <= 0) {
    setFormError(translate('distance.odometer.negativeDistanceNotAllowed'));
    return;
}
navigateToNextPage();
  • 1.E (Discard Changes): Reuses DiscardChangesConfirmation to block accidental loss of unsaved odometer inputs when leaving the screen:
<DiscardChangesConfirmation
    isEnabled={shouldEnableDiscardConfirmation}
    getHasUnsavedChanges={() => {
        const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current;
        return hasReadingChanges;
    }}
/>;
  • 1.J (edit implementation): When editing, the button becomes “Save”. We update distance only if readings changed, then return to the prior screen:
if (isEditing) {
    const hasChanges = previousDistance !== calculatedDistance || previousStart !== start || previousEnd !== end;
    if (hasChanges) {
        updateMoneyRequestDistance({ ... });
    }
    if (isEditingConfirmation) {
        allowNavigation();
    }
    Navigation.goBack(confirmationRoute ?? undefined);
    return;
}
  • 1.F/1.J (distance calc + confirm totals): On Next/Save we store odometerStart/odometerEnd into the transaction comment and compute/set total distance into the custom unit; creation/tracking flows pass odometer values through to the backend:
setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft);
const distance = end - start;
const calculatedDistance = roundToTwoDecimalPlaces(distance);
setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft);
odometerStart: isOdometerDistanceRequest ? item.comment?.odometerStart : undefined,
odometerEnd: isOdometerDistanceRequest ? item.comment?.odometerEnd : undefined,
  • 1.J (confirm details): The confirmation list/footer recognizes odometer distance, hides map previews, and routes the distance row back to the odometer step for edits:
listFooterContent={
    <MoneyRequestConfirmationListFooter
        ...
        isOdometerDistanceRequest={isOdometerDistanceRequest}
if (isOdometerDistanceRequest) {
    Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(action, iouType, transactionID, reportID, true) as Route);
    return;
}
  • Default policy handling: For create/track flows on a paid personal policy, we auto-select the default expense policy and rate so odometer distance gets a rate without extra user input:
const shouldUseDefaultExpensePolicy = useMemo(
    () =>
        iouType === CONST.IOU.TYPE.CREATE &&
        isPaidGroupPolicy(defaultExpensePolicy) &&
        defaultExpensePolicy?.isPolicyExpenseChatEnabled &&
        !shouldRestrictUserBillableActions(defaultExpensePolicy.id),
    [iouType, defaultExpensePolicy],
);

Fixed Issues

$ #77191
PROPOSAL: N/A

Tests

Odometer Distance Tests

  1. Global Create -> Track distance -> Odometer tab

    • Open the new Odometer tab.
    • Verify Start/End inputs render and tab icon/title match the translation.
  2. Missing fields validation and End < Start validation

    • Leave either Start or End empty and press Next.
    • Expect “Please enter both start and end readings”; stay on the page.
    • Enter Start=200, End=150, press Next.
    • See “End reading must be greater than start reading”; no navigation.
  3. Correct input 100 -> 160

    • Enter Start=100, End=160; total shows 60 in the selected unit.
    • Press Next: land on recipient page and choose one
    • Land on confirmation page with distance/amount updated.
    • Press Save on confirmation (no edits): no errors, return correctly.
  4. Back/unsaved changes - Discard Changes modal

    • Enter/change readings, hit browser back.
    • Discard Changes modal appears. Choose Discard: readings clear; RHP closes.
  5. Tab switching state

    • Enter readings, switch Map <-> Manual <-> Odometer.
    • Odometer inputs reset only when transaction state is cleared on tab switch; no stale values.
  6. Edit on confirmation (test errors, increase distance, test leave without saving)

    • On confirmation, tap Distance, increase readings, Save.
    • Confirmation shows updated distance/amount and Start/End readings.
    • Once again tap Distance, remove readings, Save.
    • Error shows up.
    • Tap distance, change End > Start, Save.
    • Error shows up.
    • Ta distance, change something, tap outside of RHP or '<' button.
    • Discard changes modal doesn't show up and the changes aren't saved; land on confirmation page.
  7. Existing report (non-global create)

    • Create odometer distance from an existing report (i.e. from a chat).
    • Participants should be auto-selected.
    • Confirmation shows odometer distance.
  8. Skip-confirmation path

    • Create an odometer expense, then FAB -> Track distance (bottom).
    • Enter valid readings, press Next.
    • Expense is created immediately (no confirmation) with odometerStart/End and distance set.
  9. Default expense policy

    • In a paid personal policy with distance rates and auto-reporting, start Odometer via Global Create.
    • After entering readings, confirmation/creation applies the default policy rate automatically (amount matches; no manual rate selection UI).
  • Verify that no errors appear in the JS console

Offline tests

Disconnect internet from your PC or force offline through troubleshoot

  1. create a correct odometer expense through FAB and verify if it is pending
  2. connect to internet and verify that the pending expense is created correctly

Everything else should work same as described in Tests

QA Steps

Verified internally. The feature is disabled currently.

  1. Follow Tests 1–10 for core creation, validation, tab switching, edit loop, translations, and base flow.
  2. Follow Tests 11–18 for skip-confirmation behavior (happy/validation), DiscardChangesConfirmation behavior, default-policy rate application, edit-from-confirmation, map suppression for odometer, and distance regression coverage (map/manual).
  3. Regression: Verify map/manual distance flows still create expenses and work properly.
  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

Comment on lines +1735 to +1736
// eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md
backTo: Routes;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are already a huge amount of changes for this feature. We will replace backTo as the next task, here is the issue.

@OSBotify

This comment was marked as outdated.

@Julesssss
Copy link
Contributor Author

@DylanDylann @cristipaval I have fixed expense editing, and the UI issue.

I asked @francoisl to review the final few commits I made this morning to address Dylan's feedback.

Copy link
Contributor Author

@Julesssss Julesssss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving commits prior to mine ✅

And vouching for my changes. The feature is not available to users, and this is the first of many PRs to be implemented.

@Julesssss
Copy link
Contributor Author

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
Android: mWeb Chrome
iOS: HybridApp
iOS: mWeb Safari
MacOS: Chrome / Safari

@Julesssss Julesssss merged commit b89dd96 into main Jan 8, 2026
37 checks passed
@Julesssss Julesssss deleted the jakub-odometerExpenseFlow branch January 8, 2026 22:12
@Julesssss Julesssss changed the title [Odometer] Create NewDot Odometer expense flow [NO QA][Odometer] Create NewDot Odometer expense flow Jan 8, 2026
@OSBotify
Copy link
Contributor

OSBotify commented Jan 8, 2026

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify
Copy link
Contributor

OSBotify commented Jan 9, 2026

🚀 Deployed to staging by https://github.com/Julesssss in version: 9.2.97-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@OSBotify
Copy link
Contributor

🚀 Deployed to staging by https://github.com/Julesssss in version: 9.2.99-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@OSBotify
Copy link
Contributor

🚀 Deployed to production by https://github.com/francoisl in version: 9.2.99-8 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@mkzie2
Copy link
Contributor

mkzie2 commented Jan 13, 2026

@jakubkalinski0 Hi, I see that you use ref value here instead of their corresponding state:

const hasLocalState = startReadingRef.current || endReadingRef.current;

Is it possible that we use the state instead? Im asking because I'm resolving conflicts of my PR that's having changes to DiscardChangesConfirmation component, and we're not using the ref approach anymore, so I'm thinking we can remove usage of these refs.

@jakubkalinski0
Copy link
Contributor

jakubkalinski0 commented Jan 13, 2026

@mkzie2 I am not sure if you are referring to the correct code here. Those refs I use to determine whether I have to initialize the local state. If you are introducing changes to DiscardChangesConfirmation then I suppose it would be those refs that we should be concerned about:

<DiscardChangesConfirmation
isEnabled={shouldEnableDiscardConfirmation}
getHasUnsavedChanges={() => {
const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current;
return hasReadingChanges;
}}
/>

Am I right? Nevertheless if you are removing the ref approach then I guess I would have no other choice but to adjust the code here. Although it will cause more rerenders since I used the refs to avoid using them as useEffect dependencies, but if the ref approach is removed the I have no other choice I guess

@mkzie2
Copy link
Contributor

mkzie2 commented Jan 13, 2026

@jakubkalinski0 We don't use refs inside DiscardChangesConfirmation anymore, not entirely remove them. The problem is that usePreventRemove hook uses a boolean value to check if we should prevent navigation, and ref don't rerender the hook. I'm asking about this line especially, since startReadingRef and other similar refs is not used in DiscardChangesConfirmation anymore and that line is now it's only usage.

If we can't replace that line then we can leave it as is.

@jakubkalinski0
Copy link
Contributor

@mkzie2 I don't think I understand what you mean. How is that possible that this line which is completely unrelated to DiscardChangesConfirmation is now it's only usage?

And what exactly do you mean by that:

since startReadingRef and other similar refs is not used in DiscardChangesConfirmation anymore

With this new implementation of DiscardChangesConfirmation this will no longer work?

<DiscardChangesConfirmation
isEnabled={shouldEnableDiscardConfirmation}
getHasUnsavedChanges={() => {
const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current;
return hasReadingChanges;
}}
/>

@mkzie2
Copy link
Contributor

mkzie2 commented Jan 13, 2026

@jakubkalinski0 You can take a look at my PR for more details. The changes are:

  1. DiscardChangesConfirmation doesn't take a function getHasUnsavedChanges anymore, but uses a boolean variable hasUnsavedChanges.
  2. hasUnsavedChanges requires live update so we can't use refs. So we need to migrate startReadingRef, initialStartReadingRef, etc... to states.
  3. Once we apply the point 2 above, this line will be the only usage of startReadingRef and endReadingRef, so I'm asking if we can change that and retire these refs.

@jakubkalinski0
Copy link
Contributor

@mkzie2 Oh now i get you. Yeah, if you are migrating those refs to states anyway then in that case I see no reason why we shouldn't be able to get rid of those refs

@jakubkalinski0
Copy link
Contributor

@mkzie2 Do you need my assistance with migrating those refs to states?

@mkzie2
Copy link
Contributor

mkzie2 commented Jan 14, 2026

@jakubkalinski0 Sorry I missed your comms, and thanks for the offer! I think I’m all set for now, but I’ll definitely let you know if I hit any blockers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants