From 896c0765647bb84cba1d9435fe8fc9e0fb7e5646 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 6 May 2026 16:11:12 +0200 Subject: [PATCH 01/26] Edit stop --- src/CONST/index.ts | 2 + src/ROUTES.ts | 5 + src/SCREENS.ts | 1 + src/languages/de.ts | 2 + src/languages/en.ts | 2 + src/languages/es.ts | 2 + src/languages/fr.ts | 2 + src/languages/it.ts | 2 + src/languages/ja.ts | 2 + src/languages/nl.ts | 2 + src/languages/pl.ts | 2 + src/languages/pt-BR.ts | 2 + src/languages/zh-hans.ts | 2 + src/libs/GPSDraftDetailsUtils.ts | 93 +++++++++- .../ModalStackNavigators/index.tsx | 1 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/Navigation/types.ts | 7 + src/libs/actions/GPSDraftDetails.ts | 48 ++++- .../Waypoints/DistanceCounter.tsx | 3 +- .../Waypoints/EditGPSTripButton.tsx | 53 ++++++ .../Waypoints/index.tsx | 22 ++- .../index.native.tsx | 18 +- .../useGPSWaypointMarkers.tsx | 102 +++++++---- .../step/IOURequestStepGPSTripEdit/Slider.tsx | 75 ++++++++ .../step/IOURequestStepGPSTripEdit/index.tsx | 173 ++++++++++++++++++ src/styles/index.ts | 6 + src/types/onyx/GpsDraftDetails.ts | 23 ++- 27 files changed, 598 insertions(+), 55 deletions(-) create mode 100644 src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/EditGPSTripButton.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx create mode 100644 src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 25b5feb3dfe5..eacd88d8b14e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9232,7 +9232,9 @@ const CONST = { DISTANCE_ODOMETER_SAVE_FOR_LATER_BUTTON: 'IOURequestStep-DistanceOdometerSaveForLaterButton', ODOMETER_CHOOSE_FILE_BUTTON: 'IOURequestStep-OdometerChooseFileButton', GPS_START_STOP_BUTTON: 'IOURequestStep-GPSStartStopButton', + GPS_EDIT_BUTTON: 'IOURequestStep-GPSEditButton', GPS_DISCARD_BUTTON: 'IOURequestStep-GPSDiscardButton', + GPS_SAVE_EDIT_BUTTON: 'IOURequestStep-GPSSaveEditButton', GPS_NEXT_BUTTON: 'IOURequestStep-GPSNextButton', GPS_OPEN_MOBILE_BUTTON: 'IOURequestStep-GPSOpenMobileButton', WAYPOINT_REMOVE_BUTTON: 'IOURequestStep-WaypointRemoveButton', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e0c6d437a70f..31f926308926 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1809,6 +1809,11 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, imageType: OdometerImageType, isEditingConfirmation?: boolean, backToReport?: string) => `${action as string}/${iouType as string}/odometer-image/${transactionID}/${reportID}/${imageType}${backToReport ? `/${backToReport}` : ''}${isEditingConfirmation ? '?isEditingConfirmation=true' : ''}` as const, }, + GPS_TRIP_EDIT: { + route: ':action/:iouType/gps-trip-edit/:transactionID/:reportID/:backToReport?', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => + `${action as string}/${iouType as string}/gps-trip-edit/${transactionID}/${reportID}/${backToReport ? `/${backToReport}` : ''}` 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 a7d812a73f59..3fc08300db2d 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -400,6 +400,7 @@ const SCREENS = { STEP_DISTANCE_GPS: 'Money_Request_Step_Distance_GPS', STEP_DISTANCE_ODOMETER: 'Money_Request_Step_Distance_Odometer', ODOMETER_IMAGE: 'Money_Request_Odometer_Image', + GPS_TRIP_EDIT: 'Money_Request_GPS_Trip_Edit', RECEIPT_PREVIEW: 'Money_Request_Receipt_preview', ODOMETER_PREVIEW: 'Money_Request_Odometer_Preview', STEP_TIME_RATE: 'Money_Request_Step_Time_Rate', diff --git a/src/languages/de.ts b/src/languages/de.ts index ad500d493bc0..c346ed02c70e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8236,6 +8236,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc save: 'Speichern', resume: 'Fortsetzen', discard: 'Verwerfen', + edit: 'Bearbeiten', + editStop: 'Haltepunkt bearbeiten', discardDistanceTrackingModal: { title: 'Entfernungsverfolgung verwerfen', prompt: 'Bist du sicher? Dadurch wird dein aktueller Ablauf verworfen und das kann nicht rückgängig gemacht werden.', diff --git a/src/languages/en.ts b/src/languages/en.ts index ee663dd82a61..b715080ffa58 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8220,6 +8220,8 @@ const translations = { save: 'Save', resume: 'Resume', discard: 'Discard', + edit: 'Edit', + editStop: 'Edit stop', discardDistanceTrackingModal: { title: 'Discard distance tracking', prompt: "Are you sure? This will discard your current journey and can't be undone.", diff --git a/src/languages/es.ts b/src/languages/es.ts index 9898e47be09c..d7246bdf2639 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9459,6 +9459,8 @@ ${amount} para ${merchant} - ${date}`, save: 'Guardar', resume: 'Reanudar', discard: 'Descartar', + edit: 'Editar', + editStop: 'Editar parada', discardDistanceTrackingModal: { title: 'Descartar seguimiento de distancia', prompt: '¿Estás seguro? Esto descartará tu trayecto actual y no se puede deshacer.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 06f11b6c5038..31be5a7d8f20 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8259,6 +8259,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e save: 'Enregistrer', resume: 'Reprendre', discard: 'Ignorer', + edit: 'Modifier', + editStop: "Modifier l'arrêt", discardDistanceTrackingModal: { title: 'Abandonner le suivi de distance', prompt: 'Êtes-vous sûr(e) ? Cela supprimera votre parcours en cours et ne pourra pas être annulé.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 5e1d7ce1291e..6569fe13bc2e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8226,6 +8226,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, save: 'Salva', resume: 'Riprendi', discard: 'Scarta', + edit: 'Modifica', + editStop: 'Modifica fermata', discardDistanceTrackingModal: { title: 'Annulla rilevamento distanza', prompt: 'Sei sicuro? Questo eliminerà il tuo percorso attuale e non potrà essere annullato.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 213c22d25e2a..2b52feb4a6dc 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8126,6 +8126,8 @@ ${reportName} save: '保存', resume: '再開', discard: '破棄', + edit: '編集', + editStop: '停止地点を編集', discardDistanceTrackingModal: { title: '移動距離の追跡を破棄', prompt: '本当によろしいですか?現在の操作は破棄され、元に戻すことはできません。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c9664d3a2a04..13eb397b9099 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8200,6 +8200,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, save: 'Opslaan', resume: 'Hervatten', discard: 'Weggooien', + edit: 'Bewerken', + editStop: 'Stop bewerken', discardDistanceTrackingModal: { title: 'Afstandsregistratie negeren', prompt: 'Weet je het zeker? Hiermee wordt je huidige proces afgebroken en dit kan niet ongedaan worden gemaakt.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8576ad7df5e6..6eec8147a65c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8189,6 +8189,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, save: 'Zapisz', resume: 'Wznów', discard: 'Odrzuć', + edit: 'Edytuj', + editStop: 'Edytuj przystanek', discardDistanceTrackingModal: { title: 'Odrzuć śledzenie dystansu', prompt: 'Na pewno? Spowoduje to porzucenie Twojej bieżącej ścieżki i nie będzie można tego cofnąć.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index dc5325376624..6655ce1c530c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8192,6 +8192,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, save: 'Salvar', resume: 'Currículo', discard: 'Descartar', + edit: 'Editar', + editStop: 'Editar parada', discardDistanceTrackingModal: { title: 'Descartar rastreamento de distância', prompt: 'Tem certeza? Isso vai descartar sua jornada atual e não poderá ser desfeito.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 6f04a5eecde7..94d2b7827f53 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7990,6 +7990,8 @@ ${reportName} save: '保存', resume: '简历', discard: '丢弃', + edit: '编辑', + editStop: '编辑停止点', discardDistanceTrackingModal: { title: '放弃距离跟踪', prompt: '确定要继续吗?这将放弃你当前的行程,且无法撤销。', diff --git a/src/libs/GPSDraftDetailsUtils.ts b/src/libs/GPSDraftDetailsUtils.ts index 180656131c60..1f651858db30 100644 --- a/src/libs/GPSDraftDetailsUtils.ts +++ b/src/libs/GPSDraftDetailsUtils.ts @@ -3,9 +3,10 @@ import type {SetRequired} from 'type-fest'; import {BACKGROUND_LOCATION_TRACKING_TASK_NAME} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; import {stopGpsTripNotification} from '@pages/iou/request/step/IOURequestStepDistanceGPS/GPSNotifications'; import type {GpsDraftDetails} from '@src/types/onyx'; -import type {GPSPoint} from '@src/types/onyx/GpsDraftDetails'; +import type {GPSPoint, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; import type {Unit} from '@src/types/onyx/Policy'; import type {Routes, Waypoint} from '@src/types/onyx/Transaction'; +import geodesicDistance from '@src/utils/geodesicDistance'; import {removeLastSegment, setEndWaypointAddress, setIsTracking} from './actions/GPSDraftDetails'; import DistanceRequestUtils from './DistanceRequestUtils'; import {roundToTwoDecimalPlaces} from './NumberUtils'; @@ -21,21 +22,38 @@ function getGPSWaypoint(gpsPoint: GPSPoint, waypointIndex: number): GPSWaypointC }; } -function getGPSWaypoints(gpsDraftDetails: GpsDraftDetails | undefined): GPSWaypointCollection { +function getEffectiveDistance(gpsDraftDetails: GpsDraftDetails | undefined): number { + return gpsDraftDetails?.modifiedDistance ?? gpsDraftDetails?.distanceInMeters ?? 0; +} + +function getEffectiveEndPoint(gpsDraftDetails: GpsDraftDetails | undefined): GPSPoint | undefined { + return gpsDraftDetails?.trimmedEndPoint ?? gpsDraftDetails?.gpsPoints?.at(-1)?.at(-1); +} + +function getGPSWaypoints(gpsDraftDetails: GpsDraftDetails | undefined, trimmedEndPointProp?: TrimmedGPSPoint): GPSWaypointCollection { const gpsCoordinates = getGpsPoints(gpsDraftDetails); + // const trimmedEndPoint = trimmedEndPointProp ?? gpsDraftDetails?.trimmedEndPoint; + const trimmedEndPoint = trimmedEndPointProp; const waypointCollection: GPSWaypointCollection = {}; let waypointsCounter = 0; - for (const segment of gpsCoordinates) { - const segmentFirstPoint = segment.at(0); - const segmentLastPoint = segment.at(-1); - if (!segmentFirstPoint || !segmentLastPoint) { + const gpsTrip = trimmedEndPoint ? getTrimmedGpsRoute(gpsCoordinates, trimmedEndPoint) : gpsCoordinates; + + for (const segment of gpsTrip) { + if (segment.length <= 1) { + continue; + } + + const firstSegmentPoint = segment.at(0); + const lastSegmentPoint = segment.at(-1); + + if (!firstSegmentPoint || !lastSegmentPoint) { continue; } - waypointCollection[`waypoint${waypointsCounter}`] = getGPSWaypoint(segmentFirstPoint, waypointsCounter); - waypointCollection[`waypoint${waypointsCounter + 1}`] = getGPSWaypoint(segmentLastPoint, waypointsCounter + 1); + waypointCollection[`waypoint${waypointsCounter}`] = getGPSWaypoint(firstSegmentPoint, waypointsCounter); + waypointCollection[`waypoint${waypointsCounter + 1}`] = getGPSWaypoint(lastSegmentPoint, waypointsCounter + 1); waypointsCounter += 2; } @@ -180,6 +198,61 @@ function getLastGpsPoint(gpsDraftDetails: GpsDraftDetails | undefined): GPSPoint return gpsDraftDetails?.gpsPoints?.at(-1)?.at(-1); } +function calculateTrimmedEndPoint(gpsPoints: GpsDraftDetails['gpsPoints'], targetDistanceMeters: number): TrimmedGPSPoint | null { + let cumulativeDistance = 0; + + for (let segmentIndex = 0; segmentIndex < gpsPoints.length; segmentIndex++) { + const segment = gpsPoints.at(segmentIndex); + + if (!segment) { + continue; + } + + for (let pointIndex = 1; pointIndex < segment.length; pointIndex++) { + const prev = segment.at(pointIndex - 1); + const curr = segment.at(pointIndex); + + if (!prev || !curr) { + continue; + } + const segmentDistance = geodesicDistance(prev, curr); + + if (cumulativeDistance + segmentDistance >= targetDistanceMeters) { + const t = segmentDistance === 0 ? 0 : (targetDistanceMeters - cumulativeDistance) / segmentDistance; + const interpolated = { + lat: prev.lat + t * (curr.lat - prev.lat), + long: prev.long + t * (curr.long - prev.long), + }; + + return {...interpolated, segmentIndex, precedingPointIndex: pointIndex - 1}; + } + + cumulativeDistance += segmentDistance; + } + } + + return null; +} + +function getTrimmedGpsRoute(gpsPoints: GpsDraftDetails['gpsPoints'], trimmedEndPoint: TrimmedGPSPoint | undefined): GpsDraftDetails['gpsPoints'] { + if (!trimmedEndPoint) { + return gpsPoints; + } + + const trimmedEndPointSegment = trimmedEndPoint.segmentIndex; + const trimmedEndPointPrecedingPointIndex = trimmedEndPoint.precedingPointIndex; + + const segment = gpsPoints.at(trimmedEndPointSegment); + + if (!segment) { + return [[]]; + } + + const updatedSegment = [...segment.slice(0, trimmedEndPointPrecedingPointIndex + 1), trimmedEndPoint]; + + return gpsPoints.slice(0, trimmedEndPointSegment).concat([updatedSegment]); +} + export { getGPSRoutes, getGPSWaypoints, @@ -196,4 +269,8 @@ export { getGpsPoints, getFirstGpsPoint, getLastGpsPoint, + getTrimmedGpsRoute, + calculateTrimmedEndPoint, + getEffectiveDistance, + getEffectiveEndPoint, }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index b10ec42b3f04..de1bb094a547 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -218,6 +218,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepDistanceMap').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MANUAL]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceManual').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_GPS]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceGPS').default, + [SCREENS.MONEY_REQUEST.GPS_TRIP_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepGPSTripEdit').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_ODOMETER]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceOdometer').default, [SCREENS.MONEY_REQUEST.ODOMETER_IMAGE]: () => require('../../../../pages/iou/request/step/IOURequestStepOdometerImage').default, [SCREENS.SET_DEFAULT_WORKSPACE]: () => require('../../../../pages/SetDefaultWorkspacePage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index be700a3b15c9..d8113da2556e 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1736,6 +1736,7 @@ const config: LinkingOptions['config'] = { [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.GPS_TRIP_EDIT]: ROUTES.GPS_TRIP_EDIT.route, [SCREENS.MONEY_REQUEST.HOLD]: ROUTES.MONEY_REQUEST_HOLD_REASON.route, [SCREENS.MONEY_REQUEST.REJECT]: ROUTES.REJECT_MONEY_REQUEST_REASON.route, [SCREENS.MONEY_REQUEST.REPORT_REJECT]: ROUTES.REJECT_EXPENSE_REPORT.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 29e26555278a..f467d2a5c3e6 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2003,6 +2003,13 @@ type MoneyRequestNavigatorParamList = { imageType: OdometerImageType; isEditingConfirmation?: string; }; + [SCREENS.MONEY_REQUEST.GPS_TRIP_EDIT]: { + action: IOUAction; + iouType: IOUType; + transactionID: string; + reportID: string; + backToReport?: string; + }; [SCREENS.MONEY_REQUEST.CREATE]: { iouType: IOUType; reportID: string; diff --git a/src/libs/actions/GPSDraftDetails.ts b/src/libs/actions/GPSDraftDetails.ts index 515e4f84ef0d..64c90f0ea14c 100644 --- a/src/libs/actions/GPSDraftDetails.ts +++ b/src/libs/actions/GPSDraftDetails.ts @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {addressFromGpsPoint, calculateTrimmedEndPoint, coordinatesToString} from '@libs/GPSDraftDetailsUtils'; import {GPS_DISTANCE_INTERVAL_METERS} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; import {updateGpsTripNotificationDistance} from '@pages/iou/request/step/IOURequestStepDistanceGPS/GPSNotifications'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -91,6 +92,8 @@ function resumeGpsTrip(gpsDraftDetails: OnyxEntry) { Onyx.merge(ONYXKEYS.GPS_DRAFT_DETAILS, { gpsPoints: newGpsPoints, isTracking: true, + modifiedDistance: null, + trimmedEndPoint: null, }); } @@ -156,4 +159,47 @@ function addGpsPoints(gpsDraftDetails: OnyxEntry, newGpsPoints: return newCapturedPoints; } -export {resetGPSDraftDetails, initGpsDraft, setStartWaypointAddress, setEndWaypointAddress, addGpsPoints, setIsTracking, resumeGpsTrip, removeLastSegment, updateGpsPoints}; +async function applyTrimmedTrip(gpsDraftDetails: GpsDraftDetails, targetDistanceMeters: number, isOffline: boolean) { + const trimmedEndPoint = calculateTrimmedEndPoint(gpsDraftDetails.gpsPoints, targetDistanceMeters); + + if (!trimmedEndPoint) { + return; + } + + let address: GPSPoint['address'] | null | undefined; + if (!isOffline) { + const addressValue = await addressFromGpsPoint(trimmedEndPoint); + if (addressValue != null) { + address = {value: addressValue, type: 'address'}; + } + } + + if (!address) { + address = {value: coordinatesToString(trimmedEndPoint), type: 'coordinates'}; + } + + Onyx.merge(ONYXKEYS.GPS_DRAFT_DETAILS, { + modifiedDistance: targetDistanceMeters, + trimmedEndPoint: {...trimmedEndPoint, address}, + }); +} + +function resetTripTrim() { + Onyx.merge(ONYXKEYS.GPS_DRAFT_DETAILS, { + modifiedDistance: null, + trimmedEndPoint: null, + }); +} +export { + resetGPSDraftDetails, + initGpsDraft, + setStartWaypointAddress, + setEndWaypointAddress, + addGpsPoints, + setIsTracking, + resumeGpsTrip, + removeLastSegment, + applyTrimmedTrip, + resetTripTrim, + updateGpsPoints, +}; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx index 256d42910b39..307ffa7cd007 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx @@ -6,6 +6,7 @@ import useOnyx from '@hooks/useOnyx'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {getEffectiveDistance} from '@libs/GPSDraftDetailsUtils'; import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Unit} from '@src/types/onyx/Policy'; @@ -23,7 +24,7 @@ function DistanceCounter({unit}: DistanceCounterProps) { const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); - const distance = DistanceRequestUtils.convertDistanceUnit(gpsDraftDetails?.distanceInMeters ?? 0, unit).toFixed(1); + const distance = DistanceRequestUtils.convertDistanceUnit(getEffectiveDistance(gpsDraftDetails), unit).toFixed(1); return ( Navigation.navigate(ROUTES.GPS_TRIP_EDIT.getRoute(action, iouType, transactionID, reportID, backToReport))} + sentryLabel={CONST.SENTRY_LABEL.IOU_REQUEST_STEP.GPS_EDIT_BUTTON} + > + + + + + ); +} + +export default EditGPSTripButton; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx index ffa4a1c27726..8db5e227879f 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx @@ -6,11 +6,14 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getFirstGpsPoint, getLastGpsPoint, getTotalGpsTripPoints, isTripStopped as isTripStoppedUtil} from '@src/libs/GPSDraftDetailsUtils'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; +import {getEffectiveEndPoint, getFirstGpsPoint, getTotalGpsTripPoints, isTripStopped as isTripStoppedUtil} from '@src/libs/GPSDraftDetailsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; import type {Unit} from '@src/types/onyx/Policy'; import DiscardGPSTripButton from './DiscardGPSTripButton'; import DistanceCounter from './DistanceCounter'; +import EditGPSTripButton from './EditGPSTripButton'; type WaypointsProps = { /** Distance unit of the ongoing GPS trip */ @@ -18,9 +21,9 @@ type WaypointsProps = { /** Whether the screen is in landscape mode */ isInLandscapeMode: boolean; -}; +} & MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.GPS_TRIP_EDIT]; -function Waypoints({unit, isInLandscapeMode}: WaypointsProps) { +function Waypoints({unit, isInLandscapeMode, action, iouType, transactionID, reportID, backToReport}: WaypointsProps) { const styles = useThemeStyles(); const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); const {translate} = useLocalize(); @@ -35,11 +38,11 @@ function Waypoints({unit, isInLandscapeMode}: WaypointsProps) { } const firstPoint = getFirstGpsPoint(gpsDraftDetails); - const lastPoint = getLastGpsPoint(gpsDraftDetails); + const effectiveEndPoint = getEffectiveEndPoint(gpsDraftDetails); const isTripStopped = isTripStoppedUtil(gpsDraftDetails); - const shouldShowLoadingEndAddress = isTripStopped && !lastPoint?.address?.value; + const shouldShowLoadingEndAddress = isTripStopped && !effectiveEndPoint?.address?.value; const shouldShowLoadingStartAddress = !firstPoint?.address?.value; const getEndAddressTitle = () => { @@ -48,7 +51,7 @@ function Waypoints({unit, isInLandscapeMode}: WaypointsProps) { } if (isTripStopped) { - return lastPoint?.address?.value; + return effectiveEndPoint?.address?.value; } return translate('gps.trackingDistance'); @@ -64,6 +67,13 @@ function Waypoints({unit, isInLandscapeMode}: WaypointsProps) { + + points.map(({lat, long}) => [long, lat]), + ); - const directionCoordinates: Coordinate[][] = getGpsPoints(gpsDraftDetails).map((points): Coordinate[] => points.map(({lat, long}) => [long, lat])); + console.log({gpsWaypointMarkers}); return ( @@ -207,6 +210,11 @@ function IOURequestStepDistanceGPS({ diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx index 5ae4307792da..5ded6073acdd 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx @@ -1,55 +1,91 @@ -import React from 'react'; import type {ReactNode} from 'react'; +import {useCallback, useMemo} from 'react'; import ImageSVG from '@components/ImageSVG'; import type {WayPoint} from '@components/MapView/MapViewTypes'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; -import {getGPSWaypoints, getTotalGpsTripSegments, isTripStopped as isTripStoppedUtil} from '@libs/GPSDraftDetailsUtils'; +import {getGpsPoints, getGPSWaypoints, isTripStopped as isTripStoppedUtil} from '@libs/GPSDraftDetailsUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type IconAsset from '@src/types/utils/IconAsset'; +import type {GpsDraftDetails} from '@src/types/onyx'; +import type {TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; -function useGPSWaypointMarkers(): WayPoint[] { +type UseGPSWaypointMarkersProps = { + gpsDraftDetails: GpsDraftDetails | undefined; + trimmedEndPoint?: TrimmedGPSPoint; +}; + +function useGPSWaypointMarkers({gpsDraftDetails, trimmedEndPoint: trimmedEndPointProp}: UseGPSWaypointMarkersProps) { const theme = useTheme(); - const {DotIndicatorUnfilled, Location, DotIndicator} = useMemoizedLazyExpensifyIcons(['DotIndicatorUnfilled', 'Location', 'DotIndicator']); - const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); + const trimmedEndPoint = trimmedEndPointProp ?? gpsDraftDetails?.trimmedEndPoint; const isTripStopped = isTripStoppedUtil(gpsDraftDetails); + const gpsWaypoints = getGPSWaypoints(gpsDraftDetails, trimmedEndPoint); + + const {DotIndicatorUnfilled, Location, DotIndicator} = useMemoizedLazyExpensifyIcons(['DotIndicatorUnfilled', 'Location', 'DotIndicator']); - const getMarkerComponent = (icon: IconAsset): ReactNode => ( - + // Stable component references per icon type so React doesn't see a new component type on every render, + // which would cause MarkerView children to unmount/remount and flash on Android. + const DotIndicatorUnfilledMarker = useCallback( + (): ReactNode => ( + + ), + [DotIndicatorUnfilled, theme.icon], ); - const gpsWaypoints = getGPSWaypoints(gpsDraftDetails); + const DotIndicatorMarker = useCallback( + (): ReactNode => ( + + ), + [DotIndicator, theme.icon], + ); + + const LocationMarker = useCallback( + (): ReactNode => ( + + ), + [Location, theme.icon], + ); - const gpsWaypointMarkers = Object.entries(gpsWaypoints).map(([key, waypoint], index): WayPoint | null => { - const tripSegmentsCount = getTotalGpsTripSegments(gpsDraftDetails); - let icon = DotIndicator; - if (index === 0) { - icon = DotIndicatorUnfilled; - } else if (index === tripSegmentsCount * 2 - 1) { - icon = Location; + return useMemo((): WayPoint[] => { + const waypointMarkers = Object.entries(gpsWaypoints).map(([key, waypoint], index): WayPoint | null => { + const tripSegmentsCount = trimmedEndPoint?.segmentIndex !== undefined ? trimmedEndPoint.segmentIndex + 1 : getGpsPoints(gpsDraftDetails).length; - if (!isTripStopped) { - return null; + if (index === tripSegmentsCount * 2 - 1) { + if (!isTripStopped) { + return null; + } + return { + id: key, + coordinate: [waypoint.lng, waypoint.lat], + markerComponent: LocationMarker, + }; } - } - return { - id: key, - coordinate: [waypoint.lng, waypoint.lat], - markerComponent: (): ReactNode => getMarkerComponent(icon), - }; - }); + return { + id: key, + coordinate: [waypoint.lng, waypoint.lat], + markerComponent: index === 0 ? DotIndicatorUnfilledMarker : DotIndicatorMarker, + }; + }); - return gpsWaypointMarkers.filter((waypoint): waypoint is WayPoint => !!waypoint); + return waypointMarkers.filter((waypoint): waypoint is WayPoint => !!waypoint); + }, [gpsWaypoints, gpsDraftDetails, isTripStopped, trimmedEndPoint?.segmentIndex, DotIndicatorUnfilledMarker, DotIndicatorMarker, LocationMarker]); } export default useGPSWaypointMarkers; diff --git a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx new file mode 100644 index 000000000000..1ff182b946c1 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import {View} from 'react-native'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; +import {scheduleOnRN} from 'react-native-worklets'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {GPS_DISTANCE_INTERVAL_METERS} from '../IOURequestStepDistanceGPS/const'; + +type SliderProps = { + onSliderRatioChange: (ratio: number) => void; +}; + +function Slider({onSliderRatioChange}: SliderProps) { + const styles = useThemeStyles(); + + const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); + + const totalDistanceMeters = gpsDraftDetails?.distanceInMeters ?? 0; + + // If the user already trimmed and came back to edit again, restore the previous trim position + const initialRatio = totalDistanceMeters > 0 && gpsDraftDetails?.modifiedDistance !== undefined ? gpsDraftDetails.modifiedDistance / totalDistanceMeters : 1; + + const minRatio = totalDistanceMeters > 0 ? GPS_DISTANCE_INTERVAL_METERS / totalDistanceMeters : 0; + + const sliderRatio = useSharedValue(initialRatio); + const minSliderRatio = useSharedValue(minRatio); + const sliderWidthShared = useSharedValue(0); + + const panGesture = Gesture.Pan().onChange((e) => { + 'worklet'; + + if (sliderWidthShared.get() === 0) { + return; + } + + const newRatio = Math.min(1, Math.max(minSliderRatio.get(), sliderRatio.get() + e.changeX / sliderWidthShared.get())); + sliderRatio.set(newRatio); + scheduleOnRN(onSliderRatioChange, newRatio); + }); + + const thumbStyle = useAnimatedStyle(() => ({ + transform: [{translateX: sliderRatio.get() * Math.max(0, sliderWidthShared.get() - styles.sliderKnob.width / 2)}], + })); + + const filledStyle = useAnimatedStyle(() => ({ + width: sliderRatio.get() * sliderWidthShared.get(), + })); + + return ( + + { + sliderWidthShared.set(e.nativeEvent.layout.width); + }} + > + + + + {/* */} + + + + + ); +} + +export default Slider; diff --git a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx new file mode 100644 index 000000000000..02bc2e222bdd --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx @@ -0,0 +1,173 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import DistanceMapView from '@components/DistanceMapView'; +import type {Coordinate, MapViewHandle} from '@components/MapView/MapViewTypes'; +import Text from '@components/Text'; +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {applyTrimmedTrip, resetTripTrim} from '@libs/actions/GPSDraftDetails'; +import {init as initMapboxToken, stop as stopMapboxToken} from '@libs/actions/MapboxToken'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {calculateTrimmedEndPoint, getGpsPoints, getTrimmedGpsRoute} from '@libs/GPSDraftDetailsUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import useGPSWaypointMarkers from '@pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers'; +import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper'; +import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import Slider from './Slider'; + +type IOURequestStepGPSTripEditProps = WithFullTransactionOrNotFoundProps; + +function IOURequestStepGPSTripEdit({ + route: { + params: {action, iouType, transactionID, reportID, backToReport}, + }, +}: IOURequestStepGPSTripEditProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const isInLandscapeMode = useIsInLandscapeMode(); + + const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); + const [mapboxAccessToken] = useOnyx(ONYXKEYS.MAPBOX_ACCESS_TOKEN); + + const gpsPoints = getGpsPoints(gpsDraftDetails); + const totalDistanceMeters = gpsDraftDetails?.distanceInMeters ?? 0; + + const [isSaving, setIsSaving] = useState(false); + const [trimmedEndPoint, setTrimmedEndPoint] = useState(gpsDraftDetails?.trimmedEndPoint); + const [trimmedDistance, setTrimmedDistance] = useState(gpsDraftDetails?.modifiedDistance ?? gpsDraftDetails?.distanceInMeters ?? 0); + const [trimmedDirectionCoords, setTrimmedDirectionCoords] = useState(() => { + if (!trimmedEndPoint) { + return gpsPoints.map((points): Coordinate[] => points.map(({lat, long}) => [long, lat])); + } + const trimmedCoords = getTrimmedGpsRoute(gpsPoints, trimmedEndPoint); + return trimmedCoords.map((seg): Coordinate[] => seg.map(({lat, long}) => [long, lat])); + }); + + const mapRef = useRef(null); + + // Use refs so the animated-reaction callback is always fresh without re-creating the gesture + const gpsPointsRef = useRef(gpsPoints); + const totalDistanceMetersRef = useRef(totalDistanceMeters); + + useEffect(() => { + initMapboxToken(); + return stopMapboxToken; + }, []); + + const goBackRoute = ROUTES.DISTANCE_REQUEST_CREATE_TAB_GPS.getRoute(action, iouType, transactionID, reportID, backToReport); + const navigateBack = () => { + Navigation.goBack(goBackRoute); + }; + + const updateTrimmedRoute = (ratio: number) => { + if (!gpsPointsRef.current || !totalDistanceMetersRef.current) { + return; + } + + const newTrimmedDistance = ratio * totalDistanceMetersRef.current; + setTrimmedDistance(newTrimmedDistance); + + const newTrimmedEndPoint = calculateTrimmedEndPoint(gpsPointsRef.current, newTrimmedDistance); + + if (!newTrimmedEndPoint) { + return; + } + + setTrimmedEndPoint(newTrimmedEndPoint); + + const trimmedCoords = getTrimmedGpsRoute(gpsPointsRef.current, newTrimmedEndPoint); + setTrimmedDirectionCoords(trimmedCoords.map((seg): Coordinate[] => seg.map(({lat, long}) => [long, lat]))); + }; + + const gpsWaypointMarkers = useGPSWaypointMarkers({gpsDraftDetails, trimmedEndPoint}); + + const unit = gpsDraftDetails?.unit ?? 'mi'; + const displayDistance = DistanceRequestUtils.convertDistanceUnit(trimmedDistance, unit).toFixed(1); + + const saveTrimmedTrip = async () => { + if (!gpsDraftDetails) { + return; + } + + if (trimmedDistance === totalDistanceMeters) { + resetTripTrim(); + navigateBack(); + return; + } + + setIsSaving(true); + await applyTrimmedTrip(gpsDraftDetails, trimmedDistance, isOffline); + setIsSaving(false); + + navigateBack(); + }; + + return ( + + + + + + + + + {translate('gps.editStop')} + + + + + Total distance + + {displayDistance} {unit} + + + + + + From 95bb4f1b1403afeb41602282342c603e56093eb0 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 14 Jul 2026 16:41:10 +0200 Subject: [PATCH 15/26] Use MapStopWaypoint icon for slider thumb --- .../step/IOURequestStepGPSTripEdit/Slider.tsx | 22 +++++++++++-------- src/styles/index.ts | 15 +++++++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx index 53bb9b6a5d63..e3ac6beaded9 100644 --- a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx @@ -1,8 +1,11 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {GPS_DISTANCE_INTERVAL_METERS} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; +import ImageSVG from '@src/components/ImageSVG'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; @@ -20,6 +23,8 @@ function Slider({onSliderRatioChange}: SliderProps) { const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['MapStopWaypoint']); + const totalDistanceMeters = gpsDraftDetails?.distanceInMeters ?? 0; // If the user already trimmed and came back to edit again, restore the previous trim position @@ -44,7 +49,7 @@ function Slider({onSliderRatioChange}: SliderProps) { }); const thumbStyle = useAnimatedStyle(() => ({ - transform: [{translateX: sliderRatio.get() * Math.max(0, sliderWidthShared.get() - styles.sliderKnob.width / 2)}], + transform: [{translateX: sliderRatio.get() * sliderWidthShared.get()}], })); const filledStyle = useAnimatedStyle(() => ({ @@ -59,15 +64,14 @@ function Slider({onSliderRatioChange}: SliderProps) { sliderWidthShared.set(e.nativeEvent.layout.width); }} > - + - - {/* */} + + diff --git a/src/styles/index.ts b/src/styles/index.ts index aae8cfd11c52..4af753d7dee2 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3768,6 +3768,21 @@ const staticStyles = (theme: ThemeColors) => ...cursor.cursorPointer, }, + editedStopSliderKnob: { + position: 'absolute', + height: CONST.MAP_MARKER_SIZES.STOP_WAYPOINT.height, + width: CONST.MAP_MARKER_SIZES.STOP_WAYPOINT.width, + left: -(CONST.MAP_MARKER_SIZES.STOP_WAYPOINT.width / 2), + top: -16, + ...cursor.cursorPointer, + }, + + editStopSliderFilled: { + backgroundColor: colors.green400, + height: '100%', + borderRadius: variables.sliderBarHeight / 2, + }, + sliderBar: { backgroundColor: theme.border, height: variables.sliderBarHeight, From d773aa5f6fc4ddca884236c44b2ab1b7e3fec823 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 15 Jul 2026 10:35:19 +0200 Subject: [PATCH 16/26] Add modifiedDistance param to TrackExpense and CreateDistanceRequest API requests --- .../API/parameters/CreateDistanceRequestParams.ts | 1 + src/libs/API/parameters/TrackExpenseParams.ts | 1 + src/libs/DebugUtils.ts | 1 + src/libs/GPSDraftDetailsUtils.ts | 12 ------------ src/libs/actions/IOU/MoneyRequest.ts | 4 ++-- src/libs/actions/IOU/Split.ts | 5 ++++- src/libs/actions/IOU/TrackExpense.ts | 5 ++++- .../IOU/types/TrackExpenseTransactionParams.ts | 1 + .../handleMoneyRequestStepDistanceNavigation.ts | 4 ++++ .../step/IOURequestStepDistanceGPS/index.native.tsx | 11 +++++++---- .../step/confirmation/useExpenseSubmission.ts | 10 ++++++++-- src/types/onyx/Transaction.ts | 3 +++ 12 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/libs/API/parameters/CreateDistanceRequestParams.ts b/src/libs/API/parameters/CreateDistanceRequestParams.ts index 460e674da3b1..ec64a6fe56fc 100644 --- a/src/libs/API/parameters/CreateDistanceRequestParams.ts +++ b/src/libs/API/parameters/CreateDistanceRequestParams.ts @@ -29,6 +29,7 @@ type CreateDistanceRequestParams = { description?: string; attendees?: string; distance?: number; + modifiedDistance?: number; odometerStart?: number; odometerEnd?: number; gpsCoordinates?: string; diff --git a/src/libs/API/parameters/TrackExpenseParams.ts b/src/libs/API/parameters/TrackExpenseParams.ts index 4952fedbea29..1a235189266c 100644 --- a/src/libs/API/parameters/TrackExpenseParams.ts +++ b/src/libs/API/parameters/TrackExpenseParams.ts @@ -38,6 +38,7 @@ type TrackExpenseParams = { customUnitRateID?: string; description?: string; distance?: number; + modifiedDistance?: number; gpsCoordinates?: string; distanceRequestType?: string; isDistance?: boolean; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 0578bfd8dc70..2b26b9f9b83b 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1093,6 +1093,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) customUnitID: CONST.RED_BRICK_ROAD_PENDING_ACTION, customUnitRateID: CONST.RED_BRICK_ROAD_PENDING_ACTION, quantity: CONST.RED_BRICK_ROAD_PENDING_ACTION, + originalQuantity: CONST.RED_BRICK_ROAD_PENDING_ACTION, name: CONST.RED_BRICK_ROAD_PENDING_ACTION, defaultP2PRate: CONST.RED_BRICK_ROAD_PENDING_ACTION, distanceUnit: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/GPSDraftDetailsUtils.ts b/src/libs/GPSDraftDetailsUtils.ts index 8305ac9cd912..fe236867413f 100644 --- a/src/libs/GPSDraftDetailsUtils.ts +++ b/src/libs/GPSDraftDetailsUtils.ts @@ -3,7 +3,6 @@ import {stopGpsTripNotification} from '@pages/iou/request/step/IOURequestStepDis import type {GpsDraftDetails} from '@src/types/onyx'; import type {GPSPoint, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; -import type {Unit} from '@src/types/onyx/Policy'; import type {Routes, Waypoint} from '@src/types/onyx/Transaction'; import geodesicDistance from '@src/utils/geodesicDistance'; @@ -12,7 +11,6 @@ import type {SetRequired} from 'type-fest'; import {hasStartedLocationUpdatesAsync, reverseGeocodeAsync, stopLocationUpdatesAsync} from 'expo-location'; import {removeLastSegment, setEndWaypointAddress, setIsTracking} from './actions/GPSDraftDetails'; -import DistanceRequestUtils from './DistanceRequestUtils'; import {roundToTwoDecimalPlaces} from './NumberUtils'; type GPSWaypointCollection = Record>; @@ -115,15 +113,6 @@ function getStringifiedGPSCoordinates(gpsDraftDetails: GpsDraftDetails | undefin return JSON.stringify(updatedGpsPoints.map((points) => points.map(({lat, long}) => ({lng: long, lat})))); } -function calculateGPSDistance(distanceInMeters: number, unit: Unit): number { - return DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit); -} - -function getGPSConvertedDistance(gpsDraftDetails: GpsDraftDetails | undefined, unit: Unit): number { - const distanceInMeters = gpsDraftDetails?.modifiedDistance ?? gpsDraftDetails?.distanceInMeters ?? 0; - return calculateGPSDistance(distanceInMeters, unit); -} - async function addressFromGpsPoint(gpsPoint: {lat: number; long: number}): Promise { try { const [location] = await reverseGeocodeAsync({latitude: gpsPoint.lat, longitude: gpsPoint.long}); @@ -289,7 +278,6 @@ export { getGPSRoutes, getGPSWaypoints, stopGpsTrip, - getGPSConvertedDistance, getStringifiedGPSCoordinates, addressFromGpsPoint, coordinatesToString, diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index f2ff69c4bd65..e140c7071ead 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -725,13 +725,13 @@ function setCustomUnitRateID( }); } -function setGPSTransactionDraftData(transactionID: string, gpsDraftDetails: GpsDraftDetails | undefined, distance: number) { +function setGPSTransactionDraftData(transactionID: string, gpsDraftDetails: GpsDraftDetails | undefined, distance: number, originalDistance: number | null) { const waypoints = getGPSWaypoints(gpsDraftDetails); const routes = getGPSRoutes(gpsDraftDetails); Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { comment: { - customUnit: {quantity: distance}, + customUnit: {quantity: distance, originalQuantity: originalDistance}, waypoints, }, routes, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 09941faf3f25..5e2035ac5556 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -112,6 +112,7 @@ type DistanceRequestTransactionParams = BaseTransactionParams & { validWaypoints?: WaypointCollection; splitShares?: SplitShares; distance?: number; + modifiedDistance?: number; receipt?: Receipt; odometerStart?: number; odometerEnd?: number; @@ -1941,6 +1942,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest amount, comment, distance, + modifiedDistance, currency, created, category, @@ -2088,7 +2090,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest }, transactionParams: { amount, - distance, + distance: modifiedDistance ?? distance, currency, comment, created, @@ -2151,6 +2153,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest reportPreviewReportActionID: reportPreviewAction.reportActionID, waypoints: JSON.stringify(sanitizedWaypoints), distance: distance !== undefined ? roundToTwoDecimalPlaces(distance) : undefined, + modifiedDistance: modifiedDistance !== undefined ? roundToTwoDecimalPlaces(modifiedDistance) : undefined, receipt, odometerStart, odometerEnd, diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 8af26b74cefa..fde2446d3639 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -2425,6 +2425,7 @@ function trackExpense(params: CreateTrackExpenseParams) { merchant = '', comment = '', distance, + modifiedDistance, receipt, category, tag, @@ -2474,6 +2475,7 @@ function trackExpense(params: CreateTrackExpenseParams) { merchant, comment, distance, + modifiedDistance, receipt: undefined, category, tag, @@ -2536,7 +2538,7 @@ function trackExpense(params: CreateTrackExpenseParams) { transactionParams: { comment, amount, - distance, + distance: modifiedDistance ?? distance, currency, created, merchant, @@ -2793,6 +2795,7 @@ function trackExpense(params: CreateTrackExpenseParams) { currency, comment, distance: distance !== undefined ? roundToTwoDecimalPlaces(distance) : undefined, + modifiedDistance: modifiedDistance !== undefined ? roundToTwoDecimalPlaces(modifiedDistance) : undefined, created, merchant, iouReportID: iouReport?.reportID, diff --git a/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts b/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts index 774467f21081..3e98b43a87a5 100644 --- a/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts +++ b/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts @@ -14,6 +14,7 @@ type TrackExpenseTransactionParams = { merchant?: string; comment?: string; distance?: number; + modifiedDistance?: number; receipt?: Receipt; category?: string; tag?: string; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index 56a8243c2278..272f793475d7 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -84,6 +84,7 @@ type MoneyRequestStepDistanceNavigationParams = { selfDMReport: OnyxEntry; gpsCoordinates?: string; gpsDistance?: number; + gpsModifiedDistance?: number; odometerStart?: number; odometerEnd?: number; odometerDistance?: number; @@ -179,6 +180,7 @@ function handleMoneyRequestStepDistanceNavigation({ selfDMReport, gpsCoordinates, gpsDistance, + gpsModifiedDistance, policyForMovingExpenses, odometerStart, odometerEnd, @@ -291,6 +293,7 @@ function handleMoneyRequestStepDistanceNavigation({ transactionParams: { amount, distance, + modifiedDistance: gpsModifiedDistance, currency: transaction?.currency ?? 'USD', created: transaction?.created ?? '', merchant, @@ -366,6 +369,7 @@ function handleMoneyRequestStepDistanceNavigation({ transactionParams: { amount, distance, + modifiedDistance: gpsModifiedDistance, comment: '', created: transaction?.created ?? '', currency: transaction?.currency ?? 'USD', diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx index d53fa7099083..58acab520f1d 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx @@ -22,7 +22,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setGPSTransactionDraftData} from '@libs/actions/IOU/MoneyRequest'; import {init as initMapboxToken, stop as stopMapboxToken} from '@libs/actions/MapboxToken'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {getGPSConvertedDistance, getGpsPoints, getGPSWaypoints, getStringifiedGPSCoordinates, getTrimmedGpsTrip} from '@libs/GPSDraftDetailsUtils'; +import {getGpsPoints, getGPSWaypoints, getStringifiedGPSCoordinates, getTrimmedGpsTrip} from '@libs/GPSDraftDetailsUtils'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {generateReportID, isMoneyRequestReport as isMoneyRequestReportReportUtils, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; @@ -118,9 +118,11 @@ function IOURequestStepDistanceGPS({ const policyTagList = useMoneyRequestPolicyTagsForReport({report, currentUserAccountID: currentUserAccountIDParam}); const navigateToNextStep = () => { const gpsCoordinates = getStringifiedGPSCoordinates(gpsDraftDetails); - const distance = getGPSConvertedDistance(gpsDraftDetails, unit); + const originalDistance = DistanceRequestUtils.convertDistanceUnit(gpsDraftDetails?.distanceInMeters ?? 0, unit); + const modifiedDistance = gpsDraftDetails?.modifiedDistance !== undefined ? DistanceRequestUtils.convertDistanceUnit(gpsDraftDetails.modifiedDistance, unit) : undefined; + const distanceForDisplay = modifiedDistance ?? originalDistance; - setGPSTransactionDraftData(transactionID, gpsDraftDetails, distance); + setGPSTransactionDraftData(transactionID, gpsDraftDetails, distanceForDisplay, modifiedDistance === undefined ? null : originalDistance); const waypoints = getGPSWaypoints(gpsDraftDetails); const optimisticTransactionID = rand64(); @@ -153,7 +155,8 @@ function IOURequestStepDistanceGPS({ policyRecentlyUsedCurrencies, introSelected, gpsCoordinates, - gpsDistance: distance, + gpsDistance: originalDistance, + gpsModifiedDistance: modifiedDistance, selfDMReport, policyForMovingExpenses, betas, diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 526fe48f0f7d..216e817c3f92 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -298,6 +298,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const customUnitRateID = getRateID(transaction) ?? ''; const transactionDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (transaction?.comment?.customUnit?.quantity ?? undefined) : undefined; + const originalTransactionDistance = isGPSDistanceRequest ? (transaction?.comment?.customUnit?.originalQuantity ?? transactionDistance) : transactionDistance; + const modifiedTransactionDistance = isGPSDistanceRequest && transaction?.comment?.customUnit?.originalQuantity != null ? transactionDistance : undefined; const defaultTaxCode = getDefaultTaxCode(policy, transaction); const transactionTaxCode = isTaxTrackingEnabled(isPolicyExpenseChat || isUnreported || isTrackExpense || isSelfDMDestination, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest) ? ((transaction?.taxCode ? transaction?.taxCode : defaultTaxCode) ?? '') @@ -650,6 +652,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const isLinkedTrackedExpenseReportArchived = !!item.linkedTrackedExpenseReportID && privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`]; const itemDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (item.comment?.customUnit?.quantity ?? undefined) : undefined; + const originalItemDistance = isGPSDistanceRequest ? (item.comment?.customUnit?.originalQuantity ?? itemDistance) : itemDistance; + const modifiedItemDistance = isGPSDistanceRequest && item.comment?.customUnit?.originalQuantity != null ? itemDistance : undefined; const email = currentUserPersonalDetails.email ?? ''; trackExpenseIOUActions({ @@ -670,7 +674,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }, transactionParams: { amount: item.amount, - distance: itemDistance, + distance: originalItemDistance, + modifiedDistance: modifiedItemDistance, currency: item.currency, created: item.created, merchant: item.merchant, @@ -760,7 +765,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { transactionParams: { amount: transaction.amount, comment: trimmedComment, - distance: transactionDistance, + distance: originalTransactionDistance, + modifiedDistance: modifiedTransactionDistance, created: transaction.created, currency: transaction.currency, merchant: transaction.merchant, diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index c3b1caf12d6b..455384b81192 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -177,6 +177,9 @@ type TransactionCustomUnit = { /** Custom unit amount */ quantity?: number | null; + /** Original custom unit amount before the GPS trip was trimmed */ + originalQuantity?: number | null; + /** Name of the custom unit */ name?: ValueOf; From 5c04607cfd6ae2e5df4a65be4aafa9e3bdc3a2e0 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 15 Jul 2026 11:54:35 +0200 Subject: [PATCH 17/26] Fix showing start waypoints --- .../step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx index 2e4a8a542558..c8ff15b9a74e 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx @@ -23,7 +23,8 @@ function useGPSWaypointMarkers({gpsDraftDetails, trimmedEndPoint: trimmedEndPoin return waypointEntries.flatMap(([key, waypoint], index): WayPoint[] => { const isStart = index === 0; - const isEnd = index === lastIndex; + // End waypoint can only have odd index, as even indexes are start waypoints of trip segments + const isEnd = index === lastIndex && index % 2 === 1; if (isEnd && !isTripStopped) { return []; From 88b1a6b64c8cb4d1d8d17d7ab79ca45647d7440a Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 15 Jul 2026 12:00:44 +0200 Subject: [PATCH 18/26] Remove not used style --- src/styles/index.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 4af753d7dee2..003f29465049 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3791,12 +3791,6 @@ const staticStyles = (theme: ThemeColors) => justifyContent: 'center', }, - sliderBarFilled: { - backgroundColor: theme.success, - height: '100%', - borderRadius: variables.sliderBarHeight / 2, - }, - screenCenteredContainer: { flex: 1, justifyContent: 'center', From cde74264fe08b49489f1d0dfca5715e500a20f86 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 15 Jul 2026 14:31:26 +0200 Subject: [PATCH 19/26] Comment improvement --- src/types/onyx/Transaction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 455384b81192..954439e36ea6 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -177,7 +177,7 @@ type TransactionCustomUnit = { /** Custom unit amount */ quantity?: number | null; - /** Original custom unit amount before the GPS trip was trimmed */ + /** Original custom unit amount - used for GPS expense to store original distance if the trip is trimmed (trimmed distance is stored as quantity) */ originalQuantity?: number | null; /** Name of the custom unit */ From 32f803e6675085272a9f5aa4e5c557a7046baef9 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 21 Jul 2026 14:44:31 +0200 Subject: [PATCH 20/26] Fix route --- src/ROUTES.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index d422e2515d4d..7bb1b3544495 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2250,7 +2250,7 @@ const ROUTES = { GPS_TRIP_EDIT: { route: ':action/:iouType/gps-trip-edit/:transactionID/:reportID/:backToReport?', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string) => - `${action as string}/${iouType as string}/gps-trip-edit/${transactionID}/${reportID}/${backToReport ? `/${backToReport}` : ''}` as const, + `${action as string}/${iouType as string}/gps-trip-edit/${transactionID}/${reportID}${backToReport ? `/${backToReport}` : ''}` as const, }, IOU_SEND_ADD_DEBIT_CARD: 'pay/new/add-debit-card', IOU_SEND_ENABLE_PAYMENTS: 'pay/new/enable-payments', From 88dc36c803039f9a5b3973209071542537febb75 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 21 Jul 2026 16:42:28 +0200 Subject: [PATCH 21/26] Fix trimmed endpoint address update when going back online; fix ai linter comments --- .../useUpdateGpsTripOnReconnect.ts | 22 +++++++++++++++++-- src/components/MapView/MapMarkerIcon.tsx | 1 + src/libs/actions/GPSDraftDetails.ts | 10 ++++++++- .../step/IOURequestStepGPSTripEdit/Slider.tsx | 3 ++- .../step/IOURequestStepGPSTripEdit/index.tsx | 2 +- src/styles/index.ts | 6 +++++ 6 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/components/GPSTripStateChecker/useUpdateGpsTripOnReconnect.ts b/src/components/GPSTripStateChecker/useUpdateGpsTripOnReconnect.ts index eb45654966d6..6949b1c57915 100644 --- a/src/components/GPSTripStateChecker/useUpdateGpsTripOnReconnect.ts +++ b/src/components/GPSTripStateChecker/useUpdateGpsTripOnReconnect.ts @@ -1,14 +1,30 @@ import useNetwork from '@hooks/useNetwork'; -import {updateGpsPoints} from '@libs/actions/GPSDraftDetails'; +import {updateGpsPoints, updateTrimmedEndPoint} from '@libs/actions/GPSDraftDetails'; import {addressFromGpsPoint, getGpsPoints} from '@libs/GPSDraftDetailsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {GPSPoint} from '@src/types/onyx/GpsDraftDetails'; +import type {GPSPoint, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; function useUpdateGpsTripOnReconnect({gpsPoints}: {gpsPoints: GPSPoint[][]}) { + // The trimmed end point is chosen in the Edit Stop screen. When trimmed while offline, its address is stored as + // stringified coordinates, so on reconnect we fetch the human readable address to replace it. + const updateTrimmedEndPointAddress = async (trimmedEndPoint: TrimmedGPSPoint | undefined) => { + // If the address is already human readable, we don't need to update it + if (!trimmedEndPoint || trimmedEndPoint.address?.type === 'address') { + return; + } + + const address = await addressFromGpsPoint(trimmedEndPoint); + if (address == null) { + return; + } + + updateTrimmedEndPoint({...trimmedEndPoint, address: {value: address, type: 'address'}}); + }; + const updateAddressesToHumanReadable = async () => { const waypointUpdates: Array> = []; @@ -64,6 +80,8 @@ function useUpdateGpsTripOnReconnect({gpsPoints}: {gpsPoints: GPSPoint[][]}) { } updateGpsPoints(newGpsPoints); + + await updateTrimmedEndPointAddress(latestGpsDraftDetails?.trimmedEndPoint); }; // This is intentional to use async/await pattern for better readability diff --git a/src/components/MapView/MapMarkerIcon.tsx b/src/components/MapView/MapMarkerIcon.tsx index 7e64b3c198e6..bc803849be63 100644 --- a/src/components/MapView/MapMarkerIcon.tsx +++ b/src/components/MapView/MapMarkerIcon.tsx @@ -2,6 +2,7 @@ import useMapMarkers from '@hooks/useMapMarkers'; import type {MapMarkerType} from '@hooks/useMapMarkers/types'; type MapMarkerIconProps = { + /** The type of the map marker to display */ markerType: MapMarkerType; }; diff --git a/src/libs/actions/GPSDraftDetails.ts b/src/libs/actions/GPSDraftDetails.ts index baad0472c232..5cf7d0858b91 100644 --- a/src/libs/actions/GPSDraftDetails.ts +++ b/src/libs/actions/GPSDraftDetails.ts @@ -5,7 +5,7 @@ import {updateGpsTripNotificationDistance} from '@pages/iou/request/step/IOURequ import ONYXKEYS from '@src/ONYXKEYS'; import type {GpsDraftDetails} from '@src/types/onyx'; -import type {GPSPoint, GPSPointAddress} from '@src/types/onyx/GpsDraftDetails'; +import type {GPSPoint, GPSPointAddress, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; import type {Unit} from '@src/types/onyx/Policy'; import geodesicDistance from '@src/utils/geodesicDistance'; @@ -203,6 +203,13 @@ function resetTripTrim() { trimmedEndPoint: null, }); } + +function updateTrimmedEndPoint(trimmedEndPoint: TrimmedGPSPoint) { + Onyx.merge(ONYXKEYS.GPS_DRAFT_DETAILS, { + trimmedEndPoint, + }); +} + export { resetGPSDraftDetails, initGpsDraft, @@ -215,4 +222,5 @@ export { applyTrimmedTrip, resetTripTrim, updateGpsPoints, + updateTrimmedEndPoint, }; diff --git a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx index e3ac6beaded9..3dfdff605a82 100644 --- a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx @@ -15,6 +15,7 @@ import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimate import {scheduleOnRN} from 'react-native-worklets'; type SliderProps = { + /** Function called when the slider ratio changes */ onSliderRatioChange: (ratio: number) => void; }; @@ -57,7 +58,7 @@ function Slider({onSliderRatioChange}: SliderProps) { })); return ( - + { diff --git a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx index bec8ef33a232..60be4519f93b 100644 --- a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx @@ -124,7 +124,7 @@ function IOURequestStepGPSTripEdit({ testID="IOURequestStepGPSTripEdit" > - + borderRadius: variables.sliderBarHeight / 2, }, + editStopSliderBarContainer: { + height: 64, + paddingHorizontal: 20, + justifyContent: 'center', + }, + sliderBar: { backgroundColor: theme.border, height: variables.sliderBarHeight, From a5dd599096abd9c64f1116b49c2dcd5c1bdd5551 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Fri, 24 Jul 2026 10:09:03 +0200 Subject: [PATCH 22/26] Don't use refs on edit screen, add resetTripTrim() --- src/libs/actions/GPSDraftDetails.ts | 1 + .../step/IOURequestStepGPSTripEdit/index.tsx | 14 +++++--------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/GPSDraftDetails.ts b/src/libs/actions/GPSDraftDetails.ts index 5cf7d0858b91..09d473efa051 100644 --- a/src/libs/actions/GPSDraftDetails.ts +++ b/src/libs/actions/GPSDraftDetails.ts @@ -176,6 +176,7 @@ async function applyTrimmedTrip(gpsDraftDetails: GpsDraftDetails, targetDistance const trimmedEndPoint = calculateTrimmedEndPoint(gpsDraftDetails.gpsPoints, targetDistanceMeters); if (!trimmedEndPoint) { + resetTripTrim(); return; } diff --git a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx index 60be4519f93b..b255c93c9ff6 100644 --- a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx @@ -24,7 +24,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import Slider from './Slider'; @@ -58,10 +58,6 @@ function IOURequestStepGPSTripEdit({ return trimmedCoords.map((seg): Coordinate[] => seg.map(({lat, long}) => [long, lat])); }); - // Use refs so the animated-reaction callback is always fresh without re-creating the gesture - const gpsPointsRef = useRef(gpsPoints); - const totalDistanceMetersRef = useRef(totalDistanceMeters); - useEffect(() => { initMapboxToken(); return stopMapboxToken; @@ -73,14 +69,14 @@ function IOURequestStepGPSTripEdit({ }; const updateTrimmedRoute = (ratio: number) => { - if (!gpsPointsRef.current || !totalDistanceMetersRef.current) { + if (!gpsPoints || !totalDistanceMeters) { return; } - const newTrimmedDistance = ratio * totalDistanceMetersRef.current; + const newTrimmedDistance = ratio * totalDistanceMeters; setTrimmedDistance(newTrimmedDistance); - const newTrimmedEndPoint = calculateTrimmedEndPoint(gpsPointsRef.current, newTrimmedDistance); + const newTrimmedEndPoint = calculateTrimmedEndPoint(gpsPoints, newTrimmedDistance); if (!newTrimmedEndPoint) { return; @@ -88,7 +84,7 @@ function IOURequestStepGPSTripEdit({ setTrimmedEndPoint(newTrimmedEndPoint); - const trimmedCoords = getTrimmedGpsTrip(gpsPointsRef.current, newTrimmedEndPoint); + const trimmedCoords = getTrimmedGpsTrip(gpsPoints, newTrimmedEndPoint); setTrimmedDirectionCoords(trimmedCoords.map((seg): Coordinate[] => seg.map(({lat, long}) => [long, lat]))); }; From 919c2ffc2071bd7632ce6dd8f6e0a01281eba56b Mon Sep 17 00:00:00 2001 From: GCyganek Date: Fri, 24 Jul 2026 10:14:25 +0200 Subject: [PATCH 23/26] Add const --- src/CONST/index.ts | 2 +- src/styles/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 00e4517704c3..c85d73c01e5b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5360,7 +5360,7 @@ const CONST = { MAP_MARKER_SIZES: { CURRENT_LOCATION: {width: 48, height: 48}, START_WAYPOINT: {width: 48, height: 48}, - STOP_WAYPOINT: {width: 48, height: 53}, + STOP_WAYPOINT: {width: 48, height: 53, xAxisLineHeight: 16}, WAYPOINT: {width: 40, height: 40}, }, diff --git a/src/styles/index.ts b/src/styles/index.ts index 54c3dcb21c82..342af62877ee 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3773,7 +3773,7 @@ const staticStyles = (theme: ThemeColors) => height: CONST.MAP_MARKER_SIZES.STOP_WAYPOINT.height, width: CONST.MAP_MARKER_SIZES.STOP_WAYPOINT.width, left: -(CONST.MAP_MARKER_SIZES.STOP_WAYPOINT.width / 2), - top: -16, + top: -CONST.MAP_MARKER_SIZES.STOP_WAYPOINT.xAxisLineHeight, ...cursor.cursorPointer, }, From daadc82dcef42cccb87fd12c35900c82d3bf6780 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 28 Jul 2026 16:43:42 +0200 Subject: [PATCH 24/26] originalQuantity changes --- src/libs/DebugUtils.ts | 1 - src/libs/actions/IOU/MoneyRequest.ts | 4 ++-- .../index.native.tsx | 3 ++- .../step/confirmation/useExpenseSubmission.ts | 19 +++++++++++++++---- src/types/onyx/Transaction.ts | 3 --- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 03f002e7d9f4..590a2e6dddee 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1093,7 +1093,6 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) customUnitID: CONST.RED_BRICK_ROAD_PENDING_ACTION, customUnitRateID: CONST.RED_BRICK_ROAD_PENDING_ACTION, quantity: CONST.RED_BRICK_ROAD_PENDING_ACTION, - originalQuantity: CONST.RED_BRICK_ROAD_PENDING_ACTION, name: CONST.RED_BRICK_ROAD_PENDING_ACTION, defaultP2PRate: CONST.RED_BRICK_ROAD_PENDING_ACTION, distanceUnit: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index f8b090a05351..6680ffa24b50 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -728,13 +728,13 @@ function setCustomUnitRateID( }); } -function setGPSTransactionDraftData(transactionID: string, gpsDraftDetails: GpsDraftDetails | undefined, distance: number, originalDistance: number | null) { +function setGPSTransactionDraftData(transactionID: string, gpsDraftDetails: GpsDraftDetails | undefined, distance: number, distanceUnit: Unit) { const waypoints = getGPSWaypoints(gpsDraftDetails); const routes = getGPSRoutes(gpsDraftDetails); Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { comment: { - customUnit: {quantity: distance, originalQuantity: originalDistance}, + customUnit: {quantity: distance, distanceUnit}, waypoints, }, routes, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx index 6280442d0740..72a170e176a1 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx @@ -111,6 +111,7 @@ function IOURequestStepDistanceGPS({ const unit = DistanceRequestUtils.getRate({ transaction, policy: shouldUseDefaultExpensePolicy ? defaultExpensePolicy : policy, + useTransactionDistanceUnit: isEditing, personalPolicyOutputCurrency: personalPolicy?.outputCurrency, }).unit; @@ -124,7 +125,7 @@ function IOURequestStepDistanceGPS({ const modifiedDistance = gpsDraftDetails?.modifiedDistance !== undefined ? DistanceRequestUtils.convertDistanceUnit(gpsDraftDetails.modifiedDistance, unit) : undefined; const distanceForDisplay = modifiedDistance ?? originalDistance; - setGPSTransactionDraftData(transactionID, gpsDraftDetails, distanceForDisplay, modifiedDistance === undefined ? null : originalDistance); + setGPSTransactionDraftData(transactionID, gpsDraftDetails, distanceForDisplay, unit); const waypoints = getGPSWaypoints(gpsDraftDetails); const optimisticTransactionID = rand64(); diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 625f0d055753..50f2a6c0621b 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -306,8 +306,13 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const customUnitRateID = getRateID(transaction) ?? ''; const transactionDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (transaction?.comment?.customUnit?.quantity ?? undefined) : undefined; - const originalTransactionDistance = isGPSDistanceRequest ? (transaction?.comment?.customUnit?.originalQuantity ?? transactionDistance) : transactionDistance; - const modifiedTransactionDistance = isGPSDistanceRequest && transaction?.comment?.customUnit?.originalQuantity != null ? transactionDistance : undefined; + const transactionDistanceUnit = transaction?.comment?.customUnit?.distanceUnit; + const isModifiedGPSDistanceRequest = isGPSDistanceRequest && gpsDraftDetails?.modifiedDistance != null; + const originalTransactionDistance = + isModifiedGPSDistanceRequest && gpsDraftDetails.distanceInMeters && transactionDistanceUnit + ? DistanceRequestUtils.convertDistanceUnit(gpsDraftDetails.distanceInMeters, transactionDistanceUnit) + : transactionDistance; + const modifiedTransactionDistance = isModifiedGPSDistanceRequest ? transactionDistance : undefined; const defaultTaxCode = getDefaultTaxCode(policy, transaction); const transactionTaxCode = isTaxTrackingEnabled(isPolicyExpenseChat || isUnreported || isTrackExpense || isSelfDMDestination, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest) ? ((transaction?.taxCode ? transaction?.taxCode : defaultTaxCode) ?? '') @@ -696,8 +701,14 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const isLinkedTrackedExpenseReportArchived = !!item.linkedTrackedExpenseReportID && privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`]; const itemDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (item.comment?.customUnit?.quantity ?? undefined) : undefined; - const originalItemDistance = isGPSDistanceRequest ? (item.comment?.customUnit?.originalQuantity ?? itemDistance) : itemDistance; - const modifiedItemDistance = isGPSDistanceRequest && item.comment?.customUnit?.originalQuantity != null ? itemDistance : undefined; + const itemRouteDistanceMeters = item.comment?.customUnit?.routeDistanceMeters; + const itemDistanceUnit = item.comment?.customUnit?.distanceUnit; + const originalItemDistance = + isGPSDistanceRequest && itemRouteDistanceMeters != null && itemDistanceUnit + ? DistanceRequestUtils.convertDistanceUnit(itemRouteDistanceMeters, itemDistanceUnit) + : itemDistance; + const modifiedItemDistance = + isGPSDistanceRequest && originalItemDistance != null && itemDistance != null && Math.abs(originalItemDistance - itemDistance) > 0.01 ? itemDistance : undefined; const email = currentUserPersonalDetails.email ?? ''; trackExpenseIOUActions({ diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 6ca27be1e615..7d7a6da9db17 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -177,9 +177,6 @@ type TransactionCustomUnit = { /** Custom unit amount */ quantity?: number | null; - /** Original custom unit amount - used for GPS expense to store original distance if the trip is trimmed (trimmed distance is stored as quantity) */ - originalQuantity?: number | null; - /** Name of the custom unit */ name?: ValueOf; From 2a1de8d7dd2bd4275d318cbdbde9d4b88852a3b0 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 28 Jul 2026 16:50:01 +0200 Subject: [PATCH 25/26] Fix TrackExpense, better variables, useState changes --- src/libs/GPSDraftDetailsUtils.ts | 31 +++++++----- .../index.native.tsx | 4 +- .../step/IOURequestStepGPSTripEdit/index.tsx | 48 ++++++++----------- .../step/confirmation/useExpenseSubmission.ts | 8 ++-- 4 files changed, 43 insertions(+), 48 deletions(-) diff --git a/src/libs/GPSDraftDetailsUtils.ts b/src/libs/GPSDraftDetailsUtils.ts index fe236867413f..f6244ce4c271 100644 --- a/src/libs/GPSDraftDetailsUtils.ts +++ b/src/libs/GPSDraftDetailsUtils.ts @@ -1,3 +1,5 @@ +import type {Coordinate} from '@components/MapView/MapViewTypes'; + import {BACKGROUND_LOCATION_TRACKING_TASK_NAME} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; import {stopGpsTripNotification} from '@pages/iou/request/step/IOURequestStepDistanceGPS/GPSNotifications'; @@ -215,7 +217,7 @@ function getFirstGpsPoint(gpsDraftDetails: GpsDraftDetails | undefined): GPSPoin } function calculateTrimmedEndPoint(gpsPoints: GPSPoint[][], targetDistanceMeters: number): TrimmedGPSPoint | null { - let cumulativeDistance = 0; + let distanceTraveled = 0; for (let segmentIndex = 0; segmentIndex < gpsPoints.length; segmentIndex++) { const segment = gpsPoints.at(segmentIndex); @@ -225,25 +227,25 @@ function calculateTrimmedEndPoint(gpsPoints: GPSPoint[][], targetDistanceMeters: } for (let pointIndex = 1; pointIndex < segment.length; pointIndex++) { - const prev = segment.at(pointIndex - 1); - const curr = segment.at(pointIndex); + const previousPoint = segment.at(pointIndex - 1); + const currentPoint = segment.at(pointIndex); - if (!prev || !curr) { + if (!previousPoint || !currentPoint) { continue; } - const segmentDistance = geodesicDistance(prev, curr); + const distanceBetweenPoints = geodesicDistance(previousPoint, currentPoint); - if (cumulativeDistance + segmentDistance >= targetDistanceMeters) { - const t = segmentDistance === 0 ? 0 : (targetDistanceMeters - cumulativeDistance) / segmentDistance; - const interpolated = { - lat: prev.lat + t * (curr.lat - prev.lat), - long: prev.long + t * (curr.long - prev.long), + if (distanceTraveled + distanceBetweenPoints >= targetDistanceMeters) { + const fractionToInclude = distanceBetweenPoints === 0 ? 0 : (targetDistanceMeters - distanceTraveled) / distanceBetweenPoints; + const interpolatedPoint = { + lat: previousPoint.lat + fractionToInclude * (currentPoint.lat - previousPoint.lat), + long: previousPoint.long + fractionToInclude * (currentPoint.long - previousPoint.long), }; - return {...interpolated, segmentIndex, precedingPointIndex: pointIndex - 1}; + return {...interpolatedPoint, segmentIndex, precedingPointIndex: pointIndex - 1}; } - cumulativeDistance += segmentDistance; + distanceTraveled += distanceBetweenPoints; } } @@ -274,6 +276,10 @@ function getTrimmedGpsTrip(gpsData: GPSPoint[][] | GpsDraftDetails | undefined, return gpsPoints.slice(0, trimmedEndPointSegment).concat([updatedSegment]); } +function gpsPointsToMapboxCoordinates(coordinates: GPSPoint[][]): Coordinate[][] { + return coordinates.map((segment): Coordinate[] => segment.map(({lat, long}) => [long, lat])); +} + export { getGPSRoutes, getGPSWaypoints, @@ -290,4 +296,5 @@ export { calculateTrimmedEndPoint, getEffectiveDistance, getEffectiveEndPoint, + gpsPointsToMapboxCoordinates, }; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx index 72a170e176a1..ed852f860913 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx @@ -22,7 +22,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setGPSTransactionDraftData} from '@libs/actions/IOU/MoneyRequest'; import {init as initMapboxToken, stop as stopMapboxToken} from '@libs/actions/MapboxToken'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {getGpsPoints, getGPSWaypoints, getStringifiedGPSCoordinates, getTrimmedGpsTrip} from '@libs/GPSDraftDetailsUtils'; +import {getGpsPoints, getGPSWaypoints, getStringifiedGPSCoordinates, getTrimmedGpsTrip, gpsPointsToMapboxCoordinates} from '@libs/GPSDraftDetailsUtils'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; @@ -201,7 +201,7 @@ function IOURequestStepDistanceGPS({ const gpsWaypointMarkers = useGPSWaypointMarkers({gpsDraftDetails}); - const directionCoordinates: Coordinate[][] = getTrimmedGpsTrip(gpsDraftDetails).map((points): Coordinate[] => points.map(({lat, long}) => [long, lat])); + const directionCoordinates = gpsPointsToMapboxCoordinates(getTrimmedGpsTrip(gpsDraftDetails)); return ( (() => { - if (!trimmedEndPoint) { - return gpsPoints.map((points): Coordinate[] => points.map(({lat, long}) => [long, lat])); + // The trim the user is dragging out right now; undefined until they touch the slider + const [pendingTrim, setPendingTrim] = useState<{endPoint: TrimmedGPSPoint; distance: number} | undefined>(); + + const trimmedEndPoint = pendingTrim?.endPoint ?? gpsDraftDetails?.trimmedEndPoint; + const trimmedDistance = pendingTrim?.distance ?? gpsDraftDetails?.modifiedDistance ?? totalDistanceMeters; + const trimmedDirectionCoords = gpsPointsToMapboxCoordinates(getTrimmedGpsTrip(gpsPoints, trimmedEndPoint)); + + const updateTrimmedRoute = (ratio: number) => { + if (!gpsPoints.length || !totalDistanceMeters) { + return; + } + const distance = ratio * totalDistanceMeters; + const endPoint = calculateTrimmedEndPoint(gpsPoints, distance); + if (!endPoint) { + return; } - const trimmedCoords = getTrimmedGpsTrip(gpsPoints, trimmedEndPoint); - return trimmedCoords.map((seg): Coordinate[] => seg.map(({lat, long}) => [long, lat])); - }); + setPendingTrim({endPoint, distance}); + }; useEffect(() => { initMapboxToken(); @@ -68,26 +78,6 @@ function IOURequestStepGPSTripEdit({ Navigation.goBack(goBackRoute); }; - const updateTrimmedRoute = (ratio: number) => { - if (!gpsPoints || !totalDistanceMeters) { - return; - } - - const newTrimmedDistance = ratio * totalDistanceMeters; - setTrimmedDistance(newTrimmedDistance); - - const newTrimmedEndPoint = calculateTrimmedEndPoint(gpsPoints, newTrimmedDistance); - - if (!newTrimmedEndPoint) { - return; - } - - setTrimmedEndPoint(newTrimmedEndPoint); - - const trimmedCoords = getTrimmedGpsTrip(gpsPoints, newTrimmedEndPoint); - setTrimmedDirectionCoords(trimmedCoords.map((seg): Coordinate[] => seg.map(({lat, long}) => [long, lat]))); - }; - const gpsWaypointMarkers = useGPSWaypointMarkers({gpsDraftDetails, trimmedEndPoint}); const unit = gpsDraftDetails?.unit ?? 'mi'; diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 50f2a6c0621b..aaab4c903435 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -701,14 +701,12 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const isLinkedTrackedExpenseReportArchived = !!item.linkedTrackedExpenseReportID && privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${item.linkedTrackedExpenseReportID}`]; const itemDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (item.comment?.customUnit?.quantity ?? undefined) : undefined; - const itemRouteDistanceMeters = item.comment?.customUnit?.routeDistanceMeters; const itemDistanceUnit = item.comment?.customUnit?.distanceUnit; const originalItemDistance = - isGPSDistanceRequest && itemRouteDistanceMeters != null && itemDistanceUnit - ? DistanceRequestUtils.convertDistanceUnit(itemRouteDistanceMeters, itemDistanceUnit) + isModifiedGPSDistanceRequest && gpsDraftDetails?.distanceInMeters && itemDistanceUnit + ? DistanceRequestUtils.convertDistanceUnit(gpsDraftDetails.distanceInMeters, itemDistanceUnit) : itemDistance; - const modifiedItemDistance = - isGPSDistanceRequest && originalItemDistance != null && itemDistance != null && Math.abs(originalItemDistance - itemDistance) > 0.01 ? itemDistance : undefined; + const modifiedItemDistance = isModifiedGPSDistanceRequest ? transactionDistance : undefined; const email = currentUserPersonalDetails.email ?? ''; trackExpenseIOUActions({ From fc2c35b9b4399aa19fbcb935916743a4f81ef5a8 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 28 Jul 2026 16:59:59 +0200 Subject: [PATCH 26/26] Add unit tests and fix lint check --- .../index.native.tsx | 1 - .../step/IOURequestStepGPSTripEdit/index.tsx | 1 - tests/unit/GPSDraftDetailsUtilsTest.ts | 301 ++++++++++++++++++ 3 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 tests/unit/GPSDraftDetailsUtilsTest.ts diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx index ed852f860913..989be9c12fea 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx @@ -1,6 +1,5 @@ import DotIndicatorMessage from '@components/DotIndicatorMessage'; import GPSMapView from '@components/MapView/GPSMapView'; -import type {Coordinate} from '@components/MapView/MapViewTypes'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; diff --git a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx index cc641a7ec45c..041ed7260016 100644 --- a/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx @@ -1,6 +1,5 @@ import Button from '@components/ButtonComposed'; import DistanceMapView from '@components/DistanceMapView'; -import type {Coordinate} from '@components/MapView/MapViewTypes'; import Text from '@components/Text'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; diff --git a/tests/unit/GPSDraftDetailsUtilsTest.ts b/tests/unit/GPSDraftDetailsUtilsTest.ts new file mode 100644 index 000000000000..3c1c26fcc315 --- /dev/null +++ b/tests/unit/GPSDraftDetailsUtilsTest.ts @@ -0,0 +1,301 @@ +import { + calculateTrimmedEndPoint, + getEffectiveDistance, + getEffectiveEndPoint, + getGPSRoutes, + getGPSWaypoints, + getStringifiedGPSCoordinates, + getTrimmedGpsTrip, + gpsPointsToMapboxCoordinates, +} from '@libs/GPSDraftDetailsUtils'; + +import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; +import type {GPSPoint, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; +import type {Unit} from '@src/types/onyx/Policy'; +import geodesicDistance from '@src/utils/geodesicDistance'; + +const point = (lat: number, long: number, address?: GPSPoint['address']): GPSPoint => ({lat, long, ...(address ? {address} : {})}); + +const makeDraft = (overrides: Partial = {}): GpsDraftDetails => ({ + gpsPoints: [[point(0, 0), point(0, 1)]], + distanceInMeters: 100, + isTracking: false, + reportID: '1', + unit: 'mi' as Unit, + ...overrides, +}); + +describe('GPSDraftDetailsUtils', () => { + describe('getEffectiveDistance', () => { + it('returns 0 when draft is undefined', () => { + expect(getEffectiveDistance(undefined)).toBe(0); + }); + + it('returns distanceInMeters when modifiedDistance is not set', () => { + expect(getEffectiveDistance(makeDraft({distanceInMeters: 250}))).toBe(250); + }); + + it('prefers modifiedDistance over distanceInMeters', () => { + expect(getEffectiveDistance(makeDraft({distanceInMeters: 250, modifiedDistance: 100}))).toBe(100); + }); + + it('returns modifiedDistance of 0 (does not fall through the nullish coalescing)', () => { + expect(getEffectiveDistance(makeDraft({distanceInMeters: 250, modifiedDistance: 0}))).toBe(0); + }); + }); + + describe('getEffectiveEndPoint', () => { + it('returns undefined when draft is undefined', () => { + expect(getEffectiveEndPoint(undefined)).toBeUndefined(); + }); + + it('returns the last point of the last segment when no trimmedEndPoint is set', () => { + const draft = makeDraft({ + gpsPoints: [ + [point(0, 0), point(0, 1)], + [point(1, 0), point(1, 5)], + ], + }); + expect(getEffectiveEndPoint(draft)).toEqual(point(1, 5)); + }); + + it('prefers the trimmedEndPoint over the recorded last point', () => { + const trimmedEndPoint: TrimmedGPSPoint = {lat: 9, long: 9, segmentIndex: 0, precedingPointIndex: 0}; + expect(getEffectiveEndPoint(makeDraft({trimmedEndPoint}))).toEqual(trimmedEndPoint); + }); + + it('returns undefined when there are no recorded points', () => { + expect(getEffectiveEndPoint(makeDraft({gpsPoints: [[]]}))).toBeUndefined(); + }); + }); + + describe('calculateTrimmedEndPoint', () => { + it('returns null when the target distance exceeds the trip length', () => { + const gpsPoints = [[point(0, 0), point(0, 1)]]; + const total = geodesicDistance(point(0, 0), point(0, 1)); + expect(calculateTrimmedEndPoint(gpsPoints, total + 1000)).toBeNull(); + }); + + it('returns null for empty gpsPoints', () => { + expect(calculateTrimmedEndPoint([[]], 10)).toBeNull(); + }); + + it('interpolates the midpoint when the target is half of a single segment leg', () => { + const start = point(0, 0); + const end = point(0, 1); + const half = geodesicDistance(start, end) / 2; + + const result = calculateTrimmedEndPoint([[start, end]], half); + + expect(result?.segmentIndex).toBe(0); + expect(result?.precedingPointIndex).toBe(0); + // Longitude interpolation is linear, so halfway is 0.5. + expect(result?.long).toBeCloseTo(0.5, 6); + expect(result?.lat).toBeCloseTo(0, 6); + }); + + it('returns the starting point when target distance is 0', () => { + const start = point(2, 3); + const result = calculateTrimmedEndPoint([[start, point(2, 4)]], 0); + expect(result).toEqual({lat: 2, long: 3, segmentIndex: 0, precedingPointIndex: 0}); + }); + + it('locates the endpoint in a later segment when earlier segments are exhausted', () => { + const seg0 = [point(0, 0), point(0, 1)]; + const seg1 = [point(0, 1), point(0, 2)]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const leg0 = geodesicDistance(seg0.at(0)!, seg0.at(1)!); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const legHalf = geodesicDistance(seg1.at(0)!, seg1.at(1)!) / 2; + + const result = calculateTrimmedEndPoint([seg0, seg1], leg0 + legHalf); + + expect(result?.segmentIndex).toBe(1); + expect(result?.precedingPointIndex).toBe(0); + expect(result?.long).toBeCloseTo(1.5, 6); + }); + }); + + describe('getTrimmedGpsTrip', () => { + it('returns the original points when there is no trimmedEndPoint', () => { + const draft = makeDraft({gpsPoints: [[point(0, 0), point(0, 1)]]}); + expect(getTrimmedGpsTrip(draft)).toEqual([[point(0, 0), point(0, 1)]]); + }); + + it('reads trimmedEndPoint from the draft when no override is passed', () => { + const trimmedEndPoint: TrimmedGPSPoint = {lat: 0, long: 0.5, segmentIndex: 0, precedingPointIndex: 0}; + const draft = makeDraft({gpsPoints: [[point(0, 0), point(0, 1)]], trimmedEndPoint}); + + expect(getTrimmedGpsTrip(draft)).toEqual([[point(0, 0), trimmedEndPoint]]); + }); + + it('lets the trimmedEndPoint argument override the draft value', () => { + const draftTrim: TrimmedGPSPoint = {lat: 0, long: 0.9, segmentIndex: 0, precedingPointIndex: 0}; + const overrideTrim: TrimmedGPSPoint = {lat: 0, long: 0.2, segmentIndex: 0, precedingPointIndex: 0}; + const draft = makeDraft({gpsPoints: [[point(0, 0), point(0, 1)]], trimmedEndPoint: draftTrim}); + + expect(getTrimmedGpsTrip(draft, overrideTrim)).toEqual([[point(0, 0), overrideTrim]]); + }); + + it('drops segments after the trimmed segment and truncates within it', () => { + const trimmedEndPoint: TrimmedGPSPoint = {lat: 1, long: 0.5, segmentIndex: 1, precedingPointIndex: 0}; + const gpsPoints = [ + [point(0, 0), point(0, 1)], + [point(1, 0), point(1, 1), point(1, 2)], + [point(2, 0), point(2, 1)], + ]; + + expect(getTrimmedGpsTrip(gpsPoints, trimmedEndPoint)).toEqual([ + [point(0, 0), point(0, 1)], + [point(1, 0), trimmedEndPoint], + ]); + }); + + it('returns [[]] when the trimmed segment index is out of range', () => { + const trimmedEndPoint: TrimmedGPSPoint = {lat: 0, long: 0.5, segmentIndex: 5, precedingPointIndex: 0}; + expect(getTrimmedGpsTrip([[point(0, 0), point(0, 1)]], trimmedEndPoint)).toEqual([[]]); + }); + + it('works with the array overload when no trimmedEndPoint is passed', () => { + const gpsPoints = [[point(0, 0), point(0, 1)]]; + expect(getTrimmedGpsTrip(gpsPoints, undefined)).toEqual(gpsPoints); + }); + }); + + describe('gpsPointsToMapboxCoordinates', () => { + it('returns an empty list for empty input', () => { + expect(gpsPointsToMapboxCoordinates([])).toEqual([]); + }); + + it('maps {lat, long} to [long, lat] preserving segment structure', () => { + const input = [[point(10, 20), point(30, 40)], [point(50, 60)]]; + expect(gpsPointsToMapboxCoordinates(input)).toEqual([ + [ + [20, 10], + [40, 30], + ], + [[60, 50]], + ]); + }); + }); + + describe('getStringifiedGPSCoordinates', () => { + it('returns undefined when draft is undefined', () => { + expect(getStringifiedGPSCoordinates(undefined)).toBeUndefined(); + }); + + it('stringifies all recorded points as {lng, lat} when no trimmedEndPoint is set', () => { + const draft = makeDraft({gpsPoints: [[point(1, 2), point(3, 4)]]}); + expect(getStringifiedGPSCoordinates(draft)).toBe( + JSON.stringify([ + [ + {lng: 2, lat: 1}, + {lng: 4, lat: 3}, + ], + ]), + ); + }); + + it('inserts the interpolated trimmedEndPoint after its preceding point', () => { + const trimmedEndPoint: TrimmedGPSPoint = {lat: 0, long: 0.5, segmentIndex: 0, precedingPointIndex: 0}; + const draft = makeDraft({gpsPoints: [[point(0, 0), point(0, 1)]], trimmedEndPoint}); + + expect(getStringifiedGPSCoordinates(draft)).toBe( + JSON.stringify([ + [ + {lng: 0, lat: 0}, + {lng: 0.5, lat: 0}, + {lng: 1, lat: 0}, + ], + ]), + ); + }); + + it('falls back to the original coordinates when the trimmed segment is out of range', () => { + const trimmedEndPoint: TrimmedGPSPoint = {lat: 0, long: 0.5, segmentIndex: 9, precedingPointIndex: 0}; + const draft = makeDraft({gpsPoints: [[point(0, 0), point(0, 1)]], trimmedEndPoint}); + + expect(getStringifiedGPSCoordinates(draft)).toBe( + JSON.stringify([ + [ + {lng: 0, lat: 0}, + {lng: 1, lat: 0}, + ], + ]), + ); + }); + }); + + describe('getGPSWaypoints', () => { + it('returns an empty collection for an empty trip', () => { + expect(getGPSWaypoints(makeDraft({gpsPoints: [[]]}))).toEqual({}); + }); + + it('creates a first and last waypoint for a two-point segment', () => { + const draft = makeDraft({gpsPoints: [[point(0, 0), point(0, 1)]]}); + const result = getGPSWaypoints(draft); + + expect(Object.keys(result)).toEqual(['waypoint0', 'waypoint1']); + expect(result.waypoint0).toEqual({keyForList: 'gps0', lat: 0, lng: 0, address: '0,0'}); + expect(result.waypoint1).toEqual({keyForList: 'gps1', lat: 0, lng: 1, address: '0,1'}); + }); + + it('creates a single waypoint for a one-point segment', () => { + const draft = makeDraft({gpsPoints: [[point(5, 6)]]}); + const result = getGPSWaypoints(draft); + + expect(Object.keys(result)).toEqual(['waypoint0']); + expect(result.waypoint0).toEqual({keyForList: 'gps0', lat: 5, lng: 6, address: '5,6'}); + }); + + it('uses the point address value when present instead of coordinates', () => { + const draft = makeDraft({gpsPoints: [[point(0, 0, {value: 'Home', type: 'address'}), point(0, 1)]]}); + const result = getGPSWaypoints(draft); + + expect(result.waypoint0.address).toBe('Home'); + expect(result.waypoint1.address).toBe('0,1'); + }); + + it('respects the trimmedEndPoint argument to truncate the trip', () => { + const trimmedEndPoint: TrimmedGPSPoint = {lat: 0, long: 0.5, segmentIndex: 0, precedingPointIndex: 0}; + const draft = makeDraft({gpsPoints: [[point(0, 0), point(0, 1)]]}); + const result = getGPSWaypoints(draft, trimmedEndPoint); + + expect(Object.keys(result)).toEqual(['waypoint0', 'waypoint1']); + expect(result.waypoint1).toEqual({keyForList: 'gps1', lat: 0, lng: 0.5, address: '0,0.5'}); + }); + }); + + describe('getGPSRoutes', () => { + it('builds a single route with coordinates as [long, lat]', () => { + const draft = makeDraft({gpsPoints: [[point(1, 2), point(3, 4)]], distanceInMeters: 123.456}); + const {route0} = getGPSRoutes(draft); + + expect(route0.distance).toBe(123.46); + expect(route0.geometry.type).toBe('LineString'); + expect(route0.geometry.coordinates).toEqual([ + [ + [2, 1], + [4, 3], + ], + ]); + }); + + it('uses modifiedDistance for the route distance when set', () => { + const draft = makeDraft({distanceInMeters: 999, modifiedDistance: 50}); + expect(getGPSRoutes(draft).route0.distance).toBe(50); + }); + + it('applies the trimmedEndPoint to the route geometry', () => { + const trimmedEndPoint: TrimmedGPSPoint = {lat: 0, long: 0.5, segmentIndex: 0, precedingPointIndex: 0}; + const draft = makeDraft({gpsPoints: [[point(0, 0), point(0, 1)]], trimmedEndPoint, modifiedDistance: 40}); + + expect(getGPSRoutes(draft).route0.geometry.coordinates).toEqual([ + [ + [0, 0], + [0.5, 0], + ], + ]); + }); + }); +});