diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 67c6c7ac56dd..d5dfcd3c8d87 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5374,7 +5374,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}, }, @@ -8699,7 +8699,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 bdd9d7b31ee3..390ba393d701 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2314,6 +2314,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_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 8dacbe128c7f..cf20af18b07a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -373,6 +373,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/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx index 40bef760b3df..392146c6e135 100644 --- a/src/components/ConfirmedRoute.tsx +++ b/src/components/ConfirmedRoute.tsx @@ -1,4 +1,3 @@ -import useMapMarkers from '@hooks/useMapMarkers'; import type {MapMarkerType} from '@hooks/useMapMarkers/types'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -14,7 +13,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; -import type {ReactNode} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import React, {useEffect} from 'react'; @@ -52,7 +50,6 @@ function ConfirmedRoute({transaction, isSmallerIcon, shouldHaveBorderRadius = tr const coordinates = route?.geometry?.coordinates ?? []; const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const getMapMarkerIconComponent = useMapMarkers(); const [mapboxAccessToken] = useOnyx(ONYXKEYS.MAPBOX_ACCESS_TOKEN); @@ -79,7 +76,7 @@ function ConfirmedRoute({transaction, isSmallerIcon, shouldHaveBorderRadius = tr waypointMarkers.push({ id: `${waypoint.lng},${waypoint.lat},${index}`, coordinate: [waypoint.lng, waypoint.lat] as const, - markerComponent: (): ReactNode => getMapMarkerIconComponent(markerType), + markerType, }); } diff --git a/src/components/DistanceRequest/DistanceRequestFooter.tsx b/src/components/DistanceRequest/DistanceRequestFooter.tsx index 0e42539535fa..85cc1f6cfcfb 100644 --- a/src/components/DistanceRequest/DistanceRequestFooter.tsx +++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx @@ -4,7 +4,6 @@ import type {WayPoint} from '@components/MapView/MapViewTypes'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useMapMarkers from '@hooks/useMapMarkers'; import type {MapMarkerType} from '@hooks/useMapMarkers/types'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; @@ -19,7 +18,6 @@ import type {Policy} from '@src/types/onyx'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type Transaction from '@src/types/onyx/Transaction'; -import type {ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -49,7 +47,6 @@ function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPa const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); - const getMapMarkerIconComponent = useMapMarkers(); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); const activePolicy = usePolicy(activePolicyID); @@ -81,7 +78,7 @@ function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPa waypointMarkers.push({ id: `${waypoint.lng},${waypoint.lat},${index}`, coordinate: [waypoint.lng, waypoint.lat] as const, - markerComponent: (): ReactNode => getMapMarkerIconComponent(markerType), + markerType, }); } 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/GPSWaypointLayer.tsx b/src/components/MapView/GPSWaypointLayer.tsx index 39d8a5ab1148..3abb1d1cdcb8 100644 --- a/src/components/MapView/GPSWaypointLayer.tsx +++ b/src/components/MapView/GPSWaypointLayer.tsx @@ -31,24 +31,18 @@ function GPSWaypointLayer({waypoints, belowLayerID}: GPSWaypointLayerProps) { const waypointFeatures = !waypoints?.length ? [] - : waypoints.flatMap((waypoint) => { - if (!waypoint.markerType) { - return []; - } - - return [ - { - type: 'Feature' as const, - geometry: { - type: 'Point' as const, - coordinates: waypoint.coordinate, - }, - properties: { - icon: WAYPOINT_ICON_NAMES[waypoint.markerType], - }, + : waypoints.flatMap((waypoint) => [ + { + type: 'Feature' as const, + geometry: { + type: 'Point' as const, + coordinates: waypoint.coordinate, + }, + properties: { + icon: WAYPOINT_ICON_NAMES[waypoint.markerType], }, - ]; - }); + }, + ]); if (waypointFeatures.length === 0) { return null; diff --git a/src/components/MapView/MapMarkerIcon.tsx b/src/components/MapView/MapMarkerIcon.tsx new file mode 100644 index 000000000000..bc803849be63 --- /dev/null +++ b/src/components/MapView/MapMarkerIcon.tsx @@ -0,0 +1,14 @@ +import useMapMarkers from '@hooks/useMapMarkers'; +import type {MapMarkerType} from '@hooks/useMapMarkers/types'; + +type MapMarkerIconProps = { + /** The type of the map marker to display */ + markerType: MapMarkerType; +}; + +function MapMarkerIcon({markerType}: MapMarkerIconProps) { + const getMapMarkerIconComponent = useMapMarkers(); + return getMapMarkerIconComponent(markerType); +} + +export default MapMarkerIcon; diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index 9512be34218c..c4e1be1d8096 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -31,6 +31,7 @@ import type {MapViewProps} from './MapViewTypes'; import Compass from './Compass'; import Direction from './Direction'; +import MapMarkerIcon from './MapMarkerIcon'; import PendingMapView from './PendingMapView'; import responder from './responder'; import ToggleDistanceUnitButton from './ToggleDistanceUnitButton'; @@ -320,8 +321,7 @@ function MapView({ /> )} - {waypoints?.map(({coordinate, markerComponent, id}) => { - const MarkerComponent = markerComponent; + {waypoints?.map(({coordinate, markerType, id}) => { if ( utils.areSameCoordinate([coordinate[0], coordinate[1]], [currentPosition?.longitude ?? 0, currentPosition?.latitude ?? 0]) && interactive && @@ -337,7 +337,7 @@ function MapView({ coordinate={coordinate} allowOverlap > - + ); })} diff --git a/src/components/MapView/MapViewImpl.web.tsx b/src/components/MapView/MapViewImpl.web.tsx index d4ea131dfda5..09d3ed49038d 100644 --- a/src/components/MapView/MapViewImpl.web.tsx +++ b/src/components/MapView/MapViewImpl.web.tsx @@ -39,6 +39,7 @@ import type {MapViewProps} from './MapViewTypes'; import './mapbox.css'; import Direction from './Direction'; +import MapMarkerIcon from './MapMarkerIcon'; import PendingMapView from './PendingMapView'; import responder from './responder'; import useDistanceUnit from './useDistanceUnit'; @@ -310,8 +311,7 @@ function MapViewImpl({ )} - {waypoints?.map(({coordinate, markerComponent, id}) => { - const MarkerComponent = markerComponent; + {waypoints?.map(({coordinate, markerType, id}) => { if ( utils.areSameCoordinate([coordinate[0], coordinate[1]], [currentPosition?.longitude ?? 0, currentPosition?.latitude ?? 0]) && interactive && @@ -325,7 +325,7 @@ function MapViewImpl({ longitude={coordinate[0]} latitude={coordinate[1]} > - + ); })} diff --git a/src/components/MapView/MapViewTypes.ts b/src/components/MapView/MapViewTypes.ts index acda7c884283..eb90ebd047ad 100644 --- a/src/components/MapView/MapViewTypes.ts +++ b/src/components/MapView/MapViewTypes.ts @@ -3,7 +3,7 @@ import type {MapMarkerType} from '@hooks/useMapMarkers/types'; import type {Unit} from '@src/types/onyx/Policy'; import type {Camera} from '@rnmapbox/maps'; -import type {ReactNode, RefObject} from 'react'; +import type {RefObject} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {SharedValue} from 'react-native-reanimated'; @@ -116,8 +116,7 @@ type InitialState = { type WayPoint = { id: string; coordinate: Coordinate; - markerComponent: () => ReactNode; - markerType?: MapMarkerType; + markerType: MapMarkerType; }; // Represents a handle to interact with a map view. diff --git a/src/languages/de.ts b/src/languages/de.ts index 413652524a85..7a456d21430f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -9482,6 +9482,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc save: 'Speichern', resume: 'Fortsetzen', discard: 'Verwerfen', + edit: 'Bearbeiten', + editStop: 'Haltepunkt bearbeiten', + totalDistance: 'Gesamtdistanz', 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 5815698f7068..6dd67a3283d3 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9598,6 +9598,9 @@ const translations = { save: 'Save', resume: 'Resume', discard: 'Discard', + edit: 'Edit', + editStop: 'Edit stop', + totalDistance: 'Total distance', 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 edeb8c3bf6da..9e59c14aba85 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -10718,6 +10718,9 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, save: 'Guardar', resume: 'Reanudar', discard: 'Descartar', + edit: 'Editar', + editStop: 'Editar parada', + totalDistance: 'Distancia total', 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 11f016af51a6..9897ac678088 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -9519,6 +9519,9 @@ 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", + totalDistance: 'Distance totale', 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 91e1badbf803..1cd8d3d88652 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -9463,6 +9463,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, save: 'Salva', resume: 'Riprendi', discard: 'Scarta', + edit: 'Modifica', + editStop: 'Modifica fermata', + totalDistance: 'Distanza totale', 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 89268faf7bbe..ac12db58c9d7 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -9338,6 +9338,9 @@ ${reportName}`, save: '保存', resume: '再開', discard: '破棄', + edit: '編集', + editStop: '停止地点を編集', + totalDistance: '合計距離', discardDistanceTrackingModal: { title: '移動距離の追跡を破棄', prompt: '本当によろしいですか?現在の操作は破棄され、元に戻すことはできません。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d8c29d164d18..2b692888f7a6 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -9435,6 +9435,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, save: 'Opslaan', resume: 'Hervatten', discard: 'Weggooien', + edit: 'Bewerken', + editStop: 'Stop bewerken', + totalDistance: 'Totale afstand', 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 f1b0024352cd..87f60311d890 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -9413,6 +9413,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, save: 'Zapisz', resume: 'Wznów', discard: 'Odrzuć', + edit: 'Edytuj', + editStop: 'Edytuj przystanek', + totalDistance: 'Całkowity dystans', 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 74904b43bf11..c18642caaf4b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -9422,6 +9422,9 @@ 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', + totalDistance: 'Distância total', 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 960393a275f2..a514da39f705 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9112,6 +9112,9 @@ ${reportName}`, save: '保存', resume: '简历', discard: '丢弃', + edit: '编辑', + editStop: '编辑停止点', + totalDistance: '总距离', discardDistanceTrackingModal: { title: '放弃距离跟踪', prompt: '确定要继续吗?这将放弃你当前的行程,且无法撤销。', 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/GPSDraftDetailsUtils.ts b/src/libs/GPSDraftDetailsUtils.ts index d18e68beb5be..f6244ce4c271 100644 --- a/src/libs/GPSDraftDetailsUtils.ts +++ b/src/libs/GPSDraftDetailsUtils.ts @@ -1,17 +1,18 @@ +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'; import type {GpsDraftDetails} from '@src/types/onyx'; -import type {GPSPoint} from '@src/types/onyx/GpsDraftDetails'; -import type {Unit} from '@src/types/onyx/Policy'; +import type {GPSPoint, TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; import type {Routes, Waypoint} from '@src/types/onyx/Transaction'; +import geodesicDistance from '@src/utils/geodesicDistance'; 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>; @@ -25,30 +26,48 @@ function getGPSWaypoint(gpsPoint: GPSPoint, waypointIndex: number): GPSWaypointC }; } -function getGPSWaypoints(gpsDraftDetails: GpsDraftDetails | undefined): GPSWaypointCollection { - const gpsCoordinates = getGpsPoints(gpsDraftDetails); +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, trimmedEndPoint?: TrimmedGPSPoint): GPSWaypointCollection { + const gpsTrip = getTrimmedGpsTrip(gpsDraftDetails, trimmedEndPoint); const waypointCollection: GPSWaypointCollection = {}; let waypointsCounter = 0; - for (const segment of gpsCoordinates) { - const segmentFirstPoint = segment.at(0); - const segmentLastPoint = segment.at(-1); - if (!segmentFirstPoint || !segmentLastPoint) { + for (const segment of gpsTrip) { + if (segment.length < 1) { continue; } - waypointCollection[`waypoint${waypointsCounter}`] = getGPSWaypoint(segmentFirstPoint, waypointsCounter); - waypointCollection[`waypoint${waypointsCounter + 1}`] = getGPSWaypoint(segmentLastPoint, waypointsCounter + 1); - waypointsCounter += 2; + const firstSegmentPoint = segment.at(0); + const lastSegmentPoint = segment.length > 1 ? segment.at(-1) : undefined; + + if (firstSegmentPoint && !lastSegmentPoint) { + waypointCollection[`waypoint${waypointsCounter}`] = getGPSWaypoint(firstSegmentPoint, waypointsCounter); + waypointsCounter += 1; + continue; + } + + if (firstSegmentPoint && lastSegmentPoint) { + waypointCollection[`waypoint${waypointsCounter}`] = getGPSWaypoint(firstSegmentPoint, waypointsCounter); + waypointCollection[`waypoint${waypointsCounter + 1}`] = getGPSWaypoint(lastSegmentPoint, waypointsCounter + 1); + waypointsCounter += 2; + continue; + } } return waypointCollection; } function getGPSRoutes(gpsDraftDetails: GpsDraftDetails | undefined): Routes { - const distanceInMeters = roundToTwoDecimalPlaces(gpsDraftDetails?.distanceInMeters ?? 0); - const gpsCoordinates = getGpsPoints(gpsDraftDetails); + const distanceInMeters = roundToTwoDecimalPlaces(gpsDraftDetails?.modifiedDistance ?? gpsDraftDetails?.distanceInMeters ?? 0); + const gpsCoordinates = getTrimmedGpsTrip(gpsDraftDetails); const coordinates: Array> = gpsCoordinates.map((points) => points.map(({lat, long}) => [long, lat])); return { @@ -62,17 +81,38 @@ function getGPSRoutes(gpsDraftDetails: GpsDraftDetails | undefined): Routes { }; } +/** + * Returns the GPS coordinates as a stringified array of arrays of {lng, lat} objects. + * If the trimmed end point is not set, returns the original GPS coordinates. + * If the trimmed end point is set, it is added to the recorded GPS coordinates according to the data + * saved in trimmedEndPoint - this is a backend requirement to make the receipt generation easier. + */ function getStringifiedGPSCoordinates(gpsDraftDetails: GpsDraftDetails | undefined): string | undefined { - return gpsDraftDetails?.gpsPoints ? JSON.stringify(gpsDraftDetails.gpsPoints.map((points) => points.map(({lat, long}) => ({lng: long, lat})))) : undefined; -} + const trimmedEndPoint = gpsDraftDetails?.trimmedEndPoint; + const gpsPoints = gpsDraftDetails?.gpsPoints; -function calculateGPSDistance(distanceInMeters: number, unit: Unit): number { - return DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit); -} + if (!trimmedEndPoint || !gpsPoints) { + return JSON.stringify(gpsDraftDetails?.gpsPoints?.map((points) => points.map(({lat, long}) => ({lng: long, lat})))); + } -function getGPSConvertedDistance(gpsDraftDetails: GpsDraftDetails | undefined, unit: Unit): number { - const distanceInMeters = gpsDraftDetails?.distanceInMeters ?? 0; - return calculateGPSDistance(distanceInMeters, unit); + const trimmedEndPointSegment = trimmedEndPoint.segmentIndex; + const trimmedEndPointPrecedingPointIndex = trimmedEndPoint.precedingPointIndex; + + const segment = gpsPoints.at(trimmedEndPointSegment); + + // Type safety check - if the segment is not found, return the original GPS coordinates. + if (!segment) { + return JSON.stringify(gpsPoints.map((points) => points.map(({lat, long}) => ({lng: long, lat})))); + } + + // Update the trimmed end point segment to include the trimmed end point as it is interpolated. + const updatedSegment = [...segment.slice(0, trimmedEndPointPrecedingPointIndex + 1), trimmedEndPoint, ...segment.slice(trimmedEndPointPrecedingPointIndex + 1)]; + const updatedGpsPoints = gpsPoints + .slice(0, trimmedEndPointSegment) + .concat([updatedSegment]) + .concat(gpsPoints.slice(trimmedEndPointSegment + 1)); + + return JSON.stringify(updatedGpsPoints.map((points) => points.map(({lat, long}) => ({lng: long, lat})))); } async function addressFromGpsPoint(gpsPoint: {lat: number; long: number}): Promise { @@ -176,15 +216,74 @@ function getFirstGpsPoint(gpsDraftDetails: GpsDraftDetails | undefined): GPSPoin return gpsDraftDetails?.gpsPoints?.at(0)?.at(0); } -function getLastGpsPoint(gpsDraftDetails: GpsDraftDetails | undefined): GPSPoint | undefined { - return gpsDraftDetails?.gpsPoints?.at(-1)?.at(-1); +function calculateTrimmedEndPoint(gpsPoints: GPSPoint[][], targetDistanceMeters: number): TrimmedGPSPoint | null { + let distanceTraveled = 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 previousPoint = segment.at(pointIndex - 1); + const currentPoint = segment.at(pointIndex); + + if (!previousPoint || !currentPoint) { + continue; + } + const distanceBetweenPoints = geodesicDistance(previousPoint, currentPoint); + + 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 {...interpolatedPoint, segmentIndex, precedingPointIndex: pointIndex - 1}; + } + + distanceTraveled += distanceBetweenPoints; + } + } + + return null; +} + +function getTrimmedGpsTrip(gpsDraftDetails: GpsDraftDetails | undefined, trimmedEndPoint?: TrimmedGPSPoint): GPSPoint[][]; +function getTrimmedGpsTrip(gpsPoints: GPSPoint[][], trimmedEndPoint: TrimmedGPSPoint | undefined): GPSPoint[][]; +function getTrimmedGpsTrip(gpsData: GPSPoint[][] | GpsDraftDetails | undefined, trimmedEndPointProp?: TrimmedGPSPoint | undefined): GPSPoint[][] { + const gpsPoints = Array.isArray(gpsData) ? gpsData : getGpsPoints(gpsData); + const trimmedEndPoint = trimmedEndPointProp ?? (Array.isArray(gpsData) ? undefined : gpsData?.trimmedEndPoint); + + 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]); +} + +function gpsPointsToMapboxCoordinates(coordinates: GPSPoint[][]): Coordinate[][] { + return coordinates.map((segment): Coordinate[] => segment.map(({lat, long}) => [long, lat])); } export { getGPSRoutes, getGPSWaypoints, stopGpsTrip, - getGPSConvertedDistance, getStringifiedGPSCoordinates, addressFromGpsPoint, coordinatesToString, @@ -193,5 +292,9 @@ export { getTotalGpsTripPointsInLastSegment, getGpsPoints, getFirstGpsPoint, - getLastGpsPoint, + getTrimmedGpsTrip, + calculateTrimmedEndPoint, + getEffectiveDistance, + getEffectiveEndPoint, + gpsPointsToMapboxCoordinates, }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 751702266cbe..57b2e0200e9b 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -222,6 +222,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 17fbe2a4e575..0c15dac2437a 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1767,6 +1767,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 93219dd2e086..f1f9d8fc846c 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2141,6 +2141,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 9de432f80152..09d473efa051 100644 --- a/src/libs/actions/GPSDraftDetails.ts +++ b/src/libs/actions/GPSDraftDetails.ts @@ -1,9 +1,11 @@ +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'; 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'; @@ -103,6 +105,8 @@ function resumeGpsTrip(gpsDraftDetails: OnyxEntry) { Onyx.merge(ONYXKEYS.GPS_DRAFT_DETAILS, { gpsPoints: newGpsPoints, isTracking: true, + modifiedDistance: null, + trimmedEndPoint: null, }); } @@ -168,4 +172,56 @@ 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) { + resetTripTrim(); + 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, + }); +} + +function updateTrimmedEndPoint(trimmedEndPoint: TrimmedGPSPoint) { + Onyx.merge(ONYXKEYS.GPS_DRAFT_DETAILS, { + trimmedEndPoint, + }); +} + +export { + resetGPSDraftDetails, + initGpsDraft, + setStartWaypointAddress, + setEndWaypointAddress, + addGpsPoints, + setIsTracking, + resumeGpsTrip, + removeLastSegment, + applyTrimmedTrip, + resetTripTrim, + updateGpsPoints, + updateTrimmedEndPoint, +}; diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 91b95fbb74df..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) { +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}, + customUnit: {quantity: distance, distanceUnit}, waypoints, }, routes, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 6f9bc5969ee8..1df27fabc72c 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -113,6 +113,7 @@ type DistanceRequestTransactionParams = BaseTransactionParams & { validWaypoints?: WaypointCollection; splitShares?: SplitShares; distance?: number; + modifiedDistance?: number; receipt?: Receipt; odometerStart?: number; odometerEnd?: number; @@ -1987,6 +1988,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest amount, comment, distance, + modifiedDistance, currency, created, category, @@ -2135,7 +2137,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest }, transactionParams: { amount, - distance, + distance: modifiedDistance ?? distance, currency, comment, created, @@ -2201,6 +2203,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 bc1e0a2fc07f..d4e5216a294c 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -2435,6 +2435,7 @@ function trackExpense(params: CreateTrackExpenseParams) { merchant = '', comment = '', distance, + modifiedDistance, receipt, category, tag, @@ -2484,6 +2485,7 @@ function trackExpense(params: CreateTrackExpenseParams) { merchant, comment, distance, + modifiedDistance, receipt: undefined, category, tag, @@ -2546,7 +2548,7 @@ function trackExpense(params: CreateTrackExpenseParams) { transactionParams: { comment, amount, - distance, + distance: modifiedDistance ?? distance, currency, created, merchant, @@ -2803,6 +2805,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 859ca15b7247..538a30770b26 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -87,6 +87,7 @@ type MoneyRequestStepDistanceNavigationParams = { selfDMReport: OnyxEntry; gpsCoordinates?: string; gpsDistance?: number; + gpsModifiedDistance?: number; odometerStart?: number; odometerEnd?: number; odometerDistance?: number; @@ -184,6 +185,7 @@ function handleMoneyRequestStepDistanceNavigation({ selfDMReport, gpsCoordinates, gpsDistance, + gpsModifiedDistance, policyForMovingExpenses, odometerStart, odometerEnd, @@ -298,6 +300,7 @@ function handleMoneyRequestStepDistanceNavigation({ transactionParams: { amount, distance, + modifiedDistance: gpsModifiedDistance, currency: transaction?.currency ?? 'USD', created: transaction?.created ?? '', merchant, @@ -373,6 +376,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/Waypoints/DistanceCounter.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx index 201998e138b5..4a3bf838d577 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx @@ -8,6 +8,7 @@ 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'; @@ -27,7 +28,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 f3a9123b96cb..7f010a4c5c52 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx @@ -6,8 +6,11 @@ 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 React from 'react'; @@ -15,6 +18,7 @@ import {View} from 'react-native'; import DiscardGPSTripButton from './DiscardGPSTripButton'; import DistanceCounter from './DistanceCounter'; +import EditGPSTripButton from './EditGPSTripButton'; type WaypointsProps = { /** Distance unit of the ongoing GPS trip */ @@ -22,9 +26,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(); @@ -39,11 +43,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 = () => { @@ -52,7 +56,7 @@ function Waypoints({unit, isInLandscapeMode}: WaypointsProps) { } if (isTripStopped) { - return lastPoint?.address?.value; + return effectiveEndPoint?.address?.value; } return translate('gps.trackingDistance'); @@ -68,6 +72,13 @@ function Waypoints({unit, isInLandscapeMode}: WaypointsProps) { + { 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, unit); const waypoints = getGPSWaypoints(gpsDraftDetails); const optimisticTransactionID = rand64(); @@ -155,7 +157,8 @@ function IOURequestStepDistanceGPS({ policyRecentlyUsedCurrencies, introSelected, gpsCoordinates, - gpsDistance: distance, + gpsDistance: originalDistance, + gpsModifiedDistance: modifiedDistance, selfDMReport, policyForMovingExpenses, betas, @@ -195,9 +198,9 @@ function IOURequestStepDistanceGPS({ return stopMapboxToken; }, []); - const waypointMarkers = useGPSWaypointMarkers(); + const gpsWaypointMarkers = useGPSWaypointMarkers({gpsDraftDetails}); - const directionCoordinates: Coordinate[][] = getGpsPoints(gpsDraftDetails).map((points): Coordinate[] => points.map(({lat, long}) => [long, lat])); + const directionCoordinates = gpsPointsToMapboxCoordinates(getTrimmedGpsTrip(gpsDraftDetails)); return ( @@ -225,6 +228,11 @@ function IOURequestStepDistanceGPS({ diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx index f4c0fd13a02c..c8ff15b9a74e 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/useGPSWaypointMarkers.tsx @@ -1,29 +1,30 @@ import type {WayPoint} from '@components/MapView/MapViewTypes'; -import useMapMarkers from '@hooks/useMapMarkers'; import type {MapMarkerType} from '@hooks/useMapMarkers/types'; -import useOnyx from '@hooks/useOnyx'; import {getGPSWaypoints, isTripStopped as isTripStoppedUtil} from '@libs/GPSDraftDetailsUtils'; -import ONYXKEYS from '@src/ONYXKEYS'; +import type {GpsDraftDetails} from '@src/types/onyx'; +import type {TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; -import type {ReactNode} from 'react'; +type UseGPSWaypointMarkersProps = { + gpsDraftDetails: GpsDraftDetails | undefined; + trimmedEndPoint?: TrimmedGPSPoint; +}; -function useGPSWaypointMarkers(): WayPoint[] { - const getMapMarkerIconComponent = useMapMarkers(); - - const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); +function useGPSWaypointMarkers({gpsDraftDetails, trimmedEndPoint: trimmedEndPointProp}: UseGPSWaypointMarkersProps) { + const trimmedEndPoint = trimmedEndPointProp ?? gpsDraftDetails?.trimmedEndPoint; const isTripStopped = isTripStoppedUtil(gpsDraftDetails); - const gpsWaypoints = getGPSWaypoints(gpsDraftDetails); + const gpsWaypoints = getGPSWaypoints(gpsDraftDetails, trimmedEndPoint); const waypointEntries = Object.entries(gpsWaypoints); const lastIndex = waypointEntries.length - 1; 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 []; @@ -40,7 +41,6 @@ function useGPSWaypointMarkers(): WayPoint[] { { id: key, coordinate: [waypoint.lng, waypoint.lat], - markerComponent: (): ReactNode => getMapMarkerIconComponent(markerType), markerType, }, ]; 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..3dfdff605a82 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx @@ -0,0 +1,83 @@ +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'; +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'; + +type SliderProps = { + /** Function called when the slider ratio changes */ + onSliderRatioChange: (ratio: number) => void; +}; + +function Slider({onSliderRatioChange}: SliderProps) { + const styles = useThemeStyles(); + + 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 + const initialRatio = totalDistanceMeters > 0 && !!gpsDraftDetails?.modifiedDistance ? 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() * sliderWidthShared.get()}], + })); + + 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..041ed7260016 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepGPSTripEdit/index.tsx @@ -0,0 +1,161 @@ +import Button from '@components/ButtonComposed'; +import DistanceMapView from '@components/DistanceMapView'; +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, getTrimmedGpsTrip, gpsPointsToMapboxCoordinates} 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 type {TrimmedGPSPoint} from '@src/types/onyx/GpsDraftDetails'; + +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; + +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); + // 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; + } + setPendingTrim({endPoint, distance}); + }; + + useEffect(() => { + initMapboxToken(); + return stopMapboxToken; + }, []); + + const goBackRoute = ROUTES.DISTANCE_REQUEST_CREATE_TAB_GPS.getRoute(action, iouType, transactionID, reportID, backToReport); + const navigateBack = () => { + Navigation.goBack(goBackRoute); + }; + + 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')} + + + + + {translate('gps.totalDistance')} + + {displayDistance} {unit} + + + + + + + + + + + ); +} + +export default IOURequestStepGPSTripEdit; diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 19a3af5bf803..aaab4c903435 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -306,6 +306,13 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const customUnitRateID = getRateID(transaction) ?? ''; const transactionDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (transaction?.comment?.customUnit?.quantity ?? undefined) : 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) ?? '') @@ -694,6 +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 itemDistanceUnit = item.comment?.customUnit?.distanceUnit; + const originalItemDistance = + isModifiedGPSDistanceRequest && gpsDraftDetails?.distanceInMeters && itemDistanceUnit + ? DistanceRequestUtils.convertDistanceUnit(gpsDraftDetails.distanceInMeters, itemDistanceUnit) + : itemDistance; + const modifiedItemDistance = isModifiedGPSDistanceRequest ? transactionDistance : undefined; const email = currentUserPersonalDetails.email ?? ''; trackExpenseIOUActions({ @@ -714,7 +727,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }, transactionParams: { amount: item.amount, - distance: itemDistance, + distance: originalItemDistance, + modifiedDistance: modifiedItemDistance, currency: item.currency, created: item.created, merchant: item.merchant, @@ -815,7 +829,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/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index 58707bd82f26..42329aa91f52 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -57,6 +57,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_SUBRATE | typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_MAP | typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_GPS + | typeof SCREENS.MONEY_REQUEST.GPS_TRIP_EDIT | typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE | typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_MANUAL | typeof SCREENS.MONEY_REQUEST.STEP_DISTANCE_ODOMETER diff --git a/src/styles/index.ts b/src/styles/index.ts index 6bbaabe70df9..0f8620652a9e 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3779,6 +3779,27 @@ 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: -CONST.MAP_MARKER_SIZES.STOP_WAYPOINT.xAxisLineHeight, + ...cursor.cursorPointer, + }, + + editStopSliderFilled: { + backgroundColor: colors.green400, + height: '100%', + borderRadius: variables.sliderBarHeight / 2, + }, + + editStopSliderBarContainer: { + height: 64, + paddingHorizontal: 20, + justifyContent: 'center', + }, + sliderBar: { backgroundColor: theme.border, height: variables.sliderBarHeight, diff --git a/src/types/onyx/GpsDraftDetails.ts b/src/types/onyx/GpsDraftDetails.ts index 7b674ce9ec42..2add9327a523 100644 --- a/src/types/onyx/GpsDraftDetails.ts +++ b/src/types/onyx/GpsDraftDetails.ts @@ -18,6 +18,14 @@ type GPSPoint = { address?: GPSPointAddress; }; +/** User selected point to be used as the stop location for the recorded GPS trip */ +type TrimmedGPSPoint = GPSPoint & { + /** Index of the segment that the point belongs to */ + segmentIndex: number; + /** Index of the preceding GPS point in the segment */ + precedingPointIndex: number; +}; + /** * Stores data from GPS trip (GPS distance request) */ @@ -36,7 +44,20 @@ type GpsDraftDetails = { /** Distance unit of the ongoing GPS trip */ unit: Unit; + + /** + * Distance the user trimmed to in the Edit Stop screen. + * When set, this is the distance shown to the user and used when creating the expense. + * The original gpsPoints and distanceInMeters are kept intact. + */ + modifiedDistance?: number; + + /** + * The interpolated endpoint chosen in the Edit Stop screen. + * When set, this replaces the last GPS point as the stop location for the recorded GPS trip. + */ + trimmedEndPoint?: TrimmedGPSPoint; }; export default GpsDraftDetails; -export type {GPSPoint, GPSPointAddress}; +export type {GPSPoint, GPSPointAddress, TrimmedGPSPoint}; 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], + ], + ]); + }); + }); +});