diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5c0993c58a36..5576eb64736d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -84,6 +84,9 @@ const ONYXKEYS = { /** Contains all the users settings for the Settings page and sub pages */ USER: 'user', + /** Contains latitude and longitude of user's last known location */ + USER_LOCATION: 'userLocation', + /** Contains metadata (partner, login, validation date) for all of the user's logins */ LOGIN_LIST: 'loginList', @@ -372,6 +375,7 @@ type OnyxValues = { [ONYXKEYS.COUNTRY_CODE]: number; [ONYXKEYS.COUNTRY]: string; [ONYXKEYS.USER]: OnyxTypes.User; + [ONYXKEYS.USER_LOCATION]: OnyxTypes.UserLocation; [ONYXKEYS.LOGIN_LIST]: Record; [ONYXKEYS.SESSION]: OnyxTypes.Session; [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; diff --git a/src/components/DistanceRequest/DistanceRequestFooter.js b/src/components/DistanceRequest/DistanceRequestFooter.js index f53fadb8ab87..b212dae615e4 100644 --- a/src/components/DistanceRequest/DistanceRequestFooter.js +++ b/src/components/DistanceRequest/DistanceRequestFooter.js @@ -8,10 +8,8 @@ import _ from 'underscore'; import Button from '@components/Button'; import DistanceMapView from '@components/DistanceMapView'; import * as Expensicons from '@components/Icon/Expensicons'; -import PendingMapView from '@components/MapView/PendingMapView'; import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import * as TransactionUtils from '@libs/TransactionUtils'; import useTheme from '@styles/themes/useTheme'; import useThemeStyles from '@styles/useThemeStyles'; @@ -57,7 +55,6 @@ const defaultProps = { function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navigateToWaypointEditPage}) { const theme = useTheme(); const styles = useThemeStyles(); - const {isOffline} = useNetwork(); const {translate} = useLocalize(); const numberOfWaypoints = _.size(waypoints); @@ -114,28 +111,20 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig )} - {!isOffline && Boolean(mapboxAccessToken.token) ? ( - - ) : ( - - )} + ); diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index c91dc63a3bd1..db3e076eacca 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -2,36 +2,99 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native'; import Mapbox, {MapState, MarkerView, setAccessToken} from '@rnmapbox/maps'; import {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import setUserLocation from '@libs/actions/UserLocation'; +import compose from '@libs/compose'; +import getCurrentPosition from '@libs/getCurrentPosition'; import styles from '@styles/styles'; import CONST from '@src/CONST'; +import useLocalize from '@src/hooks/useLocalize'; +import useNetwork from '@src/hooks/useNetwork'; +import ONYXKEYS from '@src/ONYXKEYS'; import Direction from './Direction'; -import {MapViewHandle, MapViewProps} from './MapViewTypes'; +import {MapViewHandle} from './MapViewTypes'; +import PendingMapView from './PendingMapView'; import responder from './responder'; +import {ComponentProps, MapViewOnyxProps} from './types'; import utils from './utils'; -const MapView = forwardRef(({accessToken, style, mapPadding, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady}, ref) => { - const cameraRef = useRef(null); - const [isIdle, setIsIdle] = useState(false); - const navigation = useNavigation(); - - useImperativeHandle( - ref, - () => ({ - flyTo: (location: [number, number], zoomLevel: number = CONST.MAPBOX.DEFAULT_ZOOM, animationDuration?: number) => - cameraRef.current?.setCamera({zoomLevel, centerCoordinate: location, animationDuration}), - fitBounds: (northEast: [number, number], southWest: [number, number], paddingConfig?: number | number[] | undefined, animationDuration?: number | undefined) => - cameraRef.current?.fitBounds(northEast, southWest, paddingConfig, animationDuration), - }), - [], - ); - - // When the page loses focus, we temporarily set the "idled" state to false. - // When the page regains focus, the onIdled method of the map will set the actual "idled" state, - // which in turn triggers the callback. - useFocusEffect( - // eslint-disable-next-line rulesdir/prefer-early-return - useCallback(() => { - if (waypoints?.length && isIdle) { +const MapView = forwardRef( + ({accessToken, style, mapPadding, userLocation: cachedUserLocation, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady}, ref) => { + const navigation = useNavigation(); + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + + const cameraRef = useRef(null); + const [isIdle, setIsIdle] = useState(false); + const [currentPosition, setCurrentPosition] = useState(cachedUserLocation); + const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); + + useFocusEffect( + useCallback(() => { + if (isOffline) { + return; + } + + getCurrentPosition( + (params) => { + const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude}; + setCurrentPosition(currentCoords); + setUserLocation(currentCoords); + }, + () => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (cachedUserLocation || !initialState) { + return; + } + + setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]}); + }, + ); + }, [cachedUserLocation, initialState, isOffline]), + ); + + // Determines if map can be panned to user's detected + // location without bothering the user. It will return + // false if user has already started dragging the map or + // if there are one or more waypoints present. + const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]); + + useEffect(() => { + if (!currentPosition || !cameraRef.current) { + return; + } + + if (!shouldPanMapToCurrentPosition()) { + return; + } + + cameraRef.current.setCamera({ + zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM, + animationDuration: 1500, + centerCoordinate: [currentPosition.longitude, currentPosition.latitude], + }); + }, [currentPosition, shouldPanMapToCurrentPosition]); + + useImperativeHandle( + ref, + () => ({ + flyTo: (location: [number, number], zoomLevel: number = CONST.MAPBOX.DEFAULT_ZOOM, animationDuration?: number) => + cameraRef.current?.setCamera({zoomLevel, centerCoordinate: location, animationDuration}), + fitBounds: (northEast: [number, number], southWest: [number, number], paddingConfig?: number | number[] | undefined, animationDuration?: number | undefined) => + cameraRef.current?.fitBounds(northEast, southWest, paddingConfig, animationDuration), + }), + [], + ); + + // When the page loses focus, we temporarily set the "idled" state to false. + // When the page regains focus, the onIdled method of the map will set the actual "idled" state, + // which in turn triggers the callback. + useFocusEffect( + useCallback(() => { + if (!waypoints || waypoints.length === 0 || !isIdle) { + return; + } + if (waypoints.length === 1) { cameraRef.current?.setCamera({ zoomLevel: 15, @@ -45,69 +108,87 @@ const MapView = forwardRef(({accessToken, style, ma ); cameraRef.current?.fitBounds(northEast, southWest, mapPadding, 1000); } + }, [mapPadding, waypoints, isIdle, directionCoordinates]), + ); + + useEffect(() => { + const unsubscribe = navigation.addListener('blur', () => { + setIsIdle(false); + }); + return unsubscribe; + }, [navigation]); + + useEffect(() => { + setAccessToken(accessToken); + }, [accessToken]); + + const setMapIdle = (e: MapState) => { + if (e.gestures.isGestureActive) { + return; + } + setIsIdle(true); + if (onMapReady) { + onMapReady(); } - }, [mapPadding, waypoints, isIdle, directionCoordinates]), - ); - - useEffect(() => { - const unsubscribe = navigation.addListener('blur', () => { - setIsIdle(false); - }); - return unsubscribe; - }, [navigation]); - - useEffect(() => { - setAccessToken(accessToken); - }, [accessToken]); - - const setMapIdle = (e: MapState) => { - if (e.gestures.isGestureActive) { - return; - } - setIsIdle(true); - if (onMapReady) { - onMapReady(); - } - }; - - return ( - - - - - {waypoints?.map(({coordinate, markerComponent, id}) => { - const MarkerComponent = markerComponent; - return ( - + {!isOffline && Boolean(accessToken) && Boolean(currentPosition) ? ( + + setUserInteractedWithMap(true)} + pitchEnabled={pitchEnabled} + attributionPosition={{...styles.r2, ...styles.b2}} + scaleBarEnabled={false} + logoPosition={{...styles.l2, ...styles.b2}} + // eslint-disable-next-line react/jsx-props-no-spreading + {...responder.panHandlers} > - - - ); - })} + + + {waypoints?.map(({coordinate, markerComponent, id}) => { + const MarkerComponent = markerComponent; + return ( + + + + ); + })} - {directionCoordinates && } - - - ); -}); + {directionCoordinates && } + + + ) : ( + + )} + + ); + }, +); -export default memo(MapView); +export default compose( + withOnyx({ + userLocation: { + key: ONYXKEYS.USER_LOCATION, + }, + }), + memo, +)(MapView); diff --git a/src/components/MapView/MapView.web.tsx b/src/components/MapView/MapView.web.tsx index 110d24f0c087..1880049b3542 100644 --- a/src/components/MapView/MapView.web.tsx +++ b/src/components/MapView/MapView.web.tsx @@ -2,26 +2,97 @@ // This is why we have separate components for web and native to handle the specific implementations. // For the web version, we use the Mapbox Web library called react-map-gl, while for the native mobile version, // we utilize a different Mapbox library @rnmapbox/maps tailored for mobile development. +import {useFocusEffect} from '@react-navigation/native'; import mapboxgl from 'mapbox-gl'; import 'mapbox-gl/dist/mapbox-gl.css'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useState} from 'react'; import Map, {MapRef, Marker} from 'react-map-gl'; import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; import * as StyleUtils from '@styles/StyleUtils'; import themeColors from '@styles/themes/default'; +import setUserLocation from '@userActions/UserLocation'; import CONST from '@src/CONST'; +import useLocalize from '@src/hooks/useLocalize'; +import useNetwork from '@src/hooks/useNetwork'; +import getCurrentPosition from '@src/libs/getCurrentPosition'; +import ONYXKEYS from '@src/ONYXKEYS'; +import styles from '@src/styles/styles'; import Direction from './Direction'; import './mapbox.css'; -import {MapViewHandle, MapViewProps} from './MapViewTypes'; +import {MapViewHandle} from './MapViewTypes'; +import PendingMapView from './PendingMapView'; import responder from './responder'; +import {ComponentProps, MapViewOnyxProps} from './types'; import utils from './utils'; -const MapView = forwardRef( - ({style, styleURL, waypoints, mapPadding, accessToken, directionCoordinates, initialState = {location: CONST.MAPBOX.DEFAULT_COORDINATE, zoom: CONST.MAPBOX.DEFAULT_ZOOM}}, ref) => { +const MapView = forwardRef( + ( + { + style, + styleURL, + waypoints, + mapPadding, + accessToken, + userLocation: cachedUserLocation, + directionCoordinates, + initialState = {location: CONST.MAPBOX.DEFAULT_COORDINATE, zoom: CONST.MAPBOX.DEFAULT_ZOOM}, + }, + ref, + ) => { + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + const [mapRef, setMapRef] = useState(null); + const [currentPosition, setCurrentPosition] = useState(cachedUserLocation); + const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); const [shouldResetBoundaries, setShouldResetBoundaries] = useState(false); const setRef = useCallback((newRef: MapRef | null) => setMapRef(newRef), []); + useFocusEffect( + useCallback(() => { + if (isOffline) { + return; + } + + getCurrentPosition( + (params) => { + const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude}; + setCurrentPosition(currentCoords); + setUserLocation(currentCoords); + }, + () => { + if (cachedUserLocation) { + return; + } + + setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]}); + }, + ); + }, [cachedUserLocation, isOffline, initialState.location]), + ); + + // Determines if map can be panned to user's detected + // location without bothering the user. It will return + // false if user has already started dragging the map or + // if there are one or more waypoints present. + const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]); + + useEffect(() => { + if (!currentPosition || !mapRef) { + return; + } + + if (!shouldPanMapToCurrentPosition()) { + return; + } + + mapRef.flyTo({ + center: [currentPosition.longitude, currentPosition.latitude], + zoom: CONST.MAPBOX.DEFAULT_ZOOM, + }); + }, [currentPosition, userInteractedWithMap, mapRef, shouldPanMapToCurrentPosition]); + const resetBoundaries = useCallback(() => { if (!waypoints || waypoints.length === 0) { return; @@ -34,7 +105,7 @@ const MapView = forwardRef( if (waypoints.length === 1) { mapRef.flyTo({ center: waypoints[0].coordinate, - zoom: 15, + zoom: CONST.MAPBOX.DEFAULT_ZOOM, }); return; } @@ -91,40 +162,55 @@ const MapView = forwardRef( ); return ( - - - {waypoints?.map(({coordinate, markerComponent, id}) => { - const MarkerComponent = markerComponent; - return ( - - - - ); - })} - {directionCoordinates && } - - + <> + {!isOffline && Boolean(accessToken) && Boolean(currentPosition) ? ( + + setUserInteractedWithMap(true)} + ref={setRef} + mapLib={mapboxgl} + mapboxAccessToken={accessToken} + initialViewState={{ + longitude: currentPosition?.longitude, + latitude: currentPosition?.latitude, + zoom: initialState.zoom, + }} + style={StyleUtils.getTextColorStyle(themeColors.mapAttributionText) as React.CSSProperties} + mapStyle={styleURL} + > + {waypoints?.map(({coordinate, markerComponent, id}) => { + const MarkerComponent = markerComponent; + return ( + + + + ); + })} + {directionCoordinates && } + + + ) : ( + + )} + ); }, ); -export default MapView; +export default withOnyx({ + userLocation: { + key: ONYXKEYS.USER_LOCATION, + }, +})(MapView); diff --git a/src/components/MapView/types.ts b/src/components/MapView/types.ts new file mode 100644 index 000000000000..2c8b9240c445 --- /dev/null +++ b/src/components/MapView/types.ts @@ -0,0 +1,11 @@ +import {OnyxEntry} from 'react-native-onyx'; +import * as OnyxTypes from '@src/types/onyx'; +import {MapViewProps} from './MapViewTypes'; + +type MapViewOnyxProps = { + userLocation: OnyxEntry; +}; + +type ComponentProps = MapViewProps & MapViewOnyxProps; + +export type {MapViewOnyxProps, ComponentProps}; diff --git a/src/libs/actions/UserLocation.ts b/src/libs/actions/UserLocation.ts new file mode 100644 index 000000000000..4c58f7a83284 --- /dev/null +++ b/src/libs/actions/UserLocation.ts @@ -0,0 +1,12 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {UserLocation} from '@src/types/onyx'; + +/** + * Sets the longitude and latitude of user's current location + */ +function setUserLocation({longitude, latitude}: UserLocation) { + Onyx.set(ONYXKEYS.USER_LOCATION, {longitude, latitude}); +} + +export default setUserLocation; diff --git a/src/types/onyx/UserLocation.ts b/src/types/onyx/UserLocation.ts new file mode 100644 index 000000000000..b22802bfefb1 --- /dev/null +++ b/src/types/onyx/UserLocation.ts @@ -0,0 +1,3 @@ +type UserLocation = Pick; + +export default UserLocation; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 87cf24d6dec7..e7b9c7661c79 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -43,6 +43,7 @@ import Session from './Session'; import Task from './Task'; import Transaction from './Transaction'; import User from './User'; +import UserLocation from './UserLocation'; import UserWallet from './UserWallet'; import WalletAdditionalDetails from './WalletAdditionalDetails'; import WalletOnfido from './WalletOnfido'; @@ -52,6 +53,7 @@ import WalletTransfer from './WalletTransfer'; export type { Account, + UserLocation, AccountData, AddDebitCardForm, BankAccount,