From 9a9c7096a07da5503d157cd646c194b7c0c0b51d Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov Date: Fri, 13 Mar 2026 23:45:33 +0500 Subject: [PATCH 01/31] Add manual distance entry tab for editing distance requests --- src/CONST/index.ts | 1 + src/libs/ReportUtils.ts | 3 +- .../request/step/IOURequestStepDistance.tsx | 272 ++++++++++++++---- 3 files changed, 226 insertions(+), 50 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f2d66c6656c6..d80b3aa3d3f2 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5984,6 +5984,7 @@ const CONST = { RECEIPT_TAB_ID: 'ReceiptTab', IOU_REQUEST_TYPE: 'iouRequestType', DISTANCE_REQUEST_TYPE: 'distanceRequestType', + DISTANCE_EDIT_TYPE: 'distanceEditType', SPLIT_EXPENSE_TAB_TYPE: 'splitExpenseTabType', SPLIT: { AMOUNT: 'amount', diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 96d8f512959e..6b2259028644 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4655,7 +4655,6 @@ function getTransactionDetails( } const report = getReportOrDraftReport(transaction?.reportID, undefined, 'report' in transaction ? transaction.report : undefined); - const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || isPaidGroupPolicyPolicyUtils(policy); return { @@ -4681,7 +4680,7 @@ function getTransactionDetails( convertedAmount: getConvertedAmount(transaction, isFromExpenseReport, transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID, allowNegativeAmount, disableOppositeConversion), postedDate: getFormattedPostedDate(transaction), transactionID: transaction.transactionID, - ...(isManualDistanceRequest && {distance: transaction.comment?.customUnit?.quantity ?? undefined}), + ...(isDistanceRequest(transaction) && {distance: transaction.comment?.customUnit?.quantity ?? undefined}), }; } diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 8e60839aaba5..025ee1dcab15 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -6,11 +6,18 @@ import {View} from 'react-native'; import type {ScrollView as RNScrollView} from 'react-native'; import type {RenderItemParams} from 'react-native-draggable-flatlist/lib/typescript/types'; import type {OnyxEntry} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; import DistanceRequestFooter from '@components/DistanceRequest/DistanceRequestFooter'; import DistanceRequestRenderItem from '@components/DistanceRequest/DistanceRequestRenderItem'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import DraggableList from '@components/DraggableList'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import NumberWithSymbolForm from '@components/NumberWithSymbolForm'; +import type {NumberWithSymbolFormRef} from '@components/NumberWithSymbolForm'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TabSelector from '@components/TabSelector/TabSelector'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; @@ -28,22 +35,26 @@ import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaypointItems from '@hooks/useWaypointItems'; -import {getIOURequestPolicyID, setMoneyRequestAmount, updateMoneyRequestDistance} from '@libs/actions/IOU'; +import {getIOURequestPolicyID, setMoneyRequestAmount, setMoneyRequestDistance, updateMoneyRequestDistance} from '@libs/actions/IOU'; import {handleMoneyRequestStepDistanceNavigation} from '@libs/actions/IOU/MoneyRequest'; import {setDraftSplitTransaction, setSplitShares} from '@libs/actions/IOU/Split'; import {init, stop} from '@libs/actions/MapboxToken'; import {openReport} from '@libs/actions/Report'; import {openDraftDistanceExpense, removeWaypoint, updateWaypoints as updateWaypointsUtil} from '@libs/actions/Transaction'; import {createBackupTransaction, removeBackupTransaction, restoreOriginalTransactionFromBackup} from '@libs/actions/TransactionEdit'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import type {MileageRate} from '@libs/DistanceRequestUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; +import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {getPolicy} from '@libs/PolicyUtils'; import {isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; import {getDistanceInMeters, getRateID, getRequestType, hasRoute, isCustomUnitRateIDForP2P, isWaypointNullIsland} from '@libs/TransactionUtils'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -178,6 +189,37 @@ function IOURequestStepDistance({ const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + // Defer rendering of OnyxTabNavigator by one frame to avoid interfering with the push animation. + // On second mount, the Onyx selected tab value is cached, causing OnyxTabNavigator + // to render its nested MaterialTopTabNavigator immediately. This interferes with + // the parent stack's push animation, making the screen appear instantly instead of sliding in. + const [isReadyForTabs, setIsReadyForTabs] = useState(false); + useEffect(() => { + const id = requestAnimationFrame(() => { + setIsReadyForTabs(true); + }); + return () => cancelAnimationFrame(id); + }, []); + + // Manual distance editing state + const manualNumberFormRef = useRef(null); + const manualTextInputRef = useRef(null); + const [manualFormError, setManualFormError] = useState(''); + + const mileageRate = DistanceRequestUtils.getRate({ + transaction, + policy, + useTransactionDistanceUnit: false, + }); + const distanceUnit = mileageRate.unit; + const distanceRate = mileageRate.rate ?? 0; + const distanceInMeters = getDistanceInMeters(transaction, transaction?.comment?.customUnit?.distanceUnit ? transaction.comment.customUnit.distanceUnit : distanceUnit); + const currentDistance = useMemo( + () => + typeof transaction?.comment?.customUnit?.quantity === 'number' ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, distanceUnit)) : undefined, + [distanceInMeters, distanceUnit, transaction?.comment?.customUnit?.quantity], + ); + // Sets `amount` and `split` share data before moving to the next step to avoid briefly showing `0.00` as the split share for participants const setDistanceRequestData = useCallback( (participants: Participant[]) => { @@ -192,16 +234,15 @@ function IOURequestStepDistance({ const IOUpolicy = getPolicy(report?.policyID ?? IOUpolicyID); const policyCurrency = policy?.outputCurrency ?? personalPolicy?.outputCurrency ?? CONST.CURRENCY.USD; - const mileageRates = DistanceRequestUtils.getMileageRates(IOUpolicy); + const policyMileageRates = DistanceRequestUtils.getMileageRates(IOUpolicy); const defaultMileageRate = DistanceRequestUtils.getDefaultMileageRate(IOUpolicy); - const mileageRate: MileageRate | undefined = isCustomUnitRateIDForP2P(transaction) + const selectedMileageRate: MileageRate | undefined = isCustomUnitRateIDForP2P(transaction) ? DistanceRequestUtils.getRateForP2P(policyCurrency, transaction) - : // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (customUnitRateID && mileageRates?.[customUnitRateID]) || defaultMileageRate; + : (customUnitRateID && policyMileageRates?.[customUnitRateID]) || defaultMileageRate; - const {unit, rate} = mileageRate ?? {}; + const {unit, rate} = selectedMileageRate ?? {}; const distance = getDistanceInMeters(transaction, unit); - const currency = mileageRate?.currency ?? policyCurrency; + const currency = selectedMileageRate?.currency ?? policyCurrency; const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); setMoneyRequestAmount(transactionID, amount, currency); @@ -525,6 +566,72 @@ function IOURequestStepDistance({ parentReportNextStep, ]); + const submitManualDistance = useCallback(() => { + const value = manualNumberFormRef.current?.getNumber() ?? ''; + if (!value.length || parseFloat(value) <= 0) { + setManualFormError(translate('iou.error.invalidDistance')); + return; + } + if (!DistanceRequestUtils.isDistanceAmountWithinLimit(parseFloat(value), distanceRate)) { + setManualFormError(translate('iou.error.distanceAmountTooLargeReduceDistance')); + return; + } + + const distanceAsFloat = roundToTwoDecimalPlaces(parseFloat(value)); + setMoneyRequestDistance(transactionID, distanceAsFloat, shouldUseTransactionDraft(action), distanceUnit); + + if (isEditingSplit && transaction) { + setDraftSplitTransaction(transaction.transactionID, splitDraftTransaction, {distance: distanceAsFloat}, policy); + navigateBack(); + return; + } + + const transactionDistanceUnit = transaction?.comment?.customUnit?.distanceUnit; + const isDistanceChanged = currentDistance !== distanceAsFloat; + const isDistanceUnitChanged = transactionDistanceUnit && transactionDistanceUnit !== distanceUnit; + + if (isDistanceChanged || isDistanceUnitChanged) { + updateMoneyRequestDistance({ + transactionID: transaction?.transactionID, + transactionThreadReport: report, + parentReport, + distance: distanceAsFloat, + transactionBackup: undefined, + policy, + policyTagList: policyTags, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + parentReportNextStep, + recentWaypoints, + }); + } + transactionWasSaved.current = true; + navigateBack(); + }, [ + translate, + distanceRate, + transactionID, + action, + distanceUnit, + isEditingSplit, + transaction, + splitDraftTransaction, + policy, + navigateBack, + currentDistance, + report, + parentReport, + policyTags, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + parentReportNextStep, + recentWaypoints, + ]); + const renderItem = useCallback( ({item, drag, isActive, getIndex}: RenderItemParams) => ( + + + } + /> + + + {((shouldShowAtLeastTwoDifferentWaypointsError && atLeastTwoDifferentWaypointsError) || duplicateWaypointsError || hasRouteError) && ( + + )} +