Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 11 additions & 265 deletions src/components/MapView/GPSMapView.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,26 @@
import Button from '@components/Button';
import ImageSVG from '@components/ImageSVG';

import useAppFocusEvent from '@hooks/useAppFocusEvent';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useOnyx from '@hooks/useOnyx';
import useTheme from '@hooks/useTheme';
import useLocationServicesRemountKey from '@hooks/useLocationServicesRemountKey';
import useThemeStyles from '@hooks/useThemeStyles';

import CONST from '@src/CONST';
import useLocalize from '@src/hooks/useLocalize';
import useNetwork from '@src/hooks/useNetwork';
import ONYXKEYS from '@src/ONYXKEYS';

import type {MapState} from '@rnmapbox/maps';

import {useFocusEffect} from '@react-navigation/native';
import Mapbox, {MarkerView} from '@rnmapbox/maps';
import {getForegroundPermissionsAsync} from 'expo-location';
import {useEffect, useRef, useState} from 'react';
import {View} from 'react-native';
import {useSharedValue} from 'react-native-reanimated';
import {useState} from 'react';

import type {GPSMapViewProps} from './MapViewTypes';

import Compass from './Compass';
import GPSDirection from './GPSDirection';
import GPSWaypointLayer from './GPSWaypointLayer';
import LayerOrderAnchors from './LayerOrderAnchors';
import GPSMapViewContent from './GPSMapViewContent';
import PendingMapView from './PendingMapView';
import responder from './responder';
import useAccessToken from './useAccessToken';
import utils from './utils';

const LOCATION_PUCK_PULSING = {
isEnabled: true,
color: CONST.MAP_CURRENT_LOCATION_FILL_COLOR,
radius: 40.0,
};

const CURRENT_LOCATION_PUCK_IMAGE = 'current-location-puck-image';

function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, waypoints, directionCoordinates: directionCoordinatesProp, isTrackingGPS}: GPSMapViewProps) {
const directionCoordinates = utils.convertSegmentedRouteToSingleSegmentRoute(directionCoordinatesProp);
const noWaypoints = !waypoints || waypoints.length === 0;

// Fitting the camera to bounds around a single point zooms it in as far as it goes, so such a trip is centered at a fixed zoom instead
const singlePointCoordinate = utils.getSinglePointCoordinate(waypoints?.map((waypoint) => waypoint.coordinate) ?? [], directionCoordinates);

function GPSMapView({accessToken, ...contentProps}: GPSMapViewProps) {
const {isOffline} = useNetwork();
const {translate} = useLocalize();
const styles = useThemeStyles();
const theme = useTheme();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Crosshair', 'MapCurrentLocationPuck', 'MapCurrentLocation']);
const isAccessTokenReady = useAccessToken({accessToken});

const cameraRef = useRef<Mapbox.Camera>(null);

const [foregroundLocationPermissionsGranted, setForegroundLocationPermissionsGranted] = useState<boolean | null>(null);

// Check (never request) foreground location permissions to determine if we can use the followUserLocation prop on the map camera.
Expand Down Expand Up @@ -89,234 +54,15 @@ function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, way
});
});

const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION);
const centerCoordinate = userLocation ? [userLocation.longitude, userLocation.latitude] : CONST.MAPBOX.DEFAULT_COORDINATE;

const [userInteractedWithMap, setUserInteractedWithMap] = useState(false);
const [shouldUseImmediateFollowTransition, setShouldUseImmediateFollowTransition] = useState(noWaypoints || isTrackingGPS);
const [lastLocation, setLastLocation] = useState<{longitude: number; latitude: number} | undefined>();

// 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
// and the GPS trip is not active or the foreground location permissions are not granted.
const shouldFollowUserLocation = !userInteractedWithMap && (noWaypoints || isTrackingGPS) && foregroundLocationPermissionsGranted !== false;

// When the route/waypoints are cleared (e.g. discarding a GPS trip),
// resume following the user's current location.
const prevWaypointsLength = useRef(waypoints?.length ?? 0);
useEffect(() => {
const currentLength = waypoints?.length ?? 0;
if (prevWaypointsLength.current > 0 && currentLength === 0) {
// Reset the user interaction state to allow the map to follow the user's location
setUserInteractedWithMap(false);

// If foreground location permissions are not granted, center the map on the fallback location
if (!foregroundLocationPermissionsGranted) {
cameraRef.current?.setCamera({
zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM,
animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME,
centerCoordinate,
});
}
}
prevWaypointsLength.current = currentLength;
// only run when waypoints length changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [waypoints?.length]);

useFocusEffect(() => {
if (noWaypoints || shouldFollowUserLocation || !!lastLocation || userInteractedWithMap) {
return;
}

if (singlePointCoordinate) {
cameraRef.current?.setCamera({
zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM,
animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME,
centerCoordinate: singlePointCoordinate,
});
return;
}

const {southWest, northEast} = utils.getBounds(
waypoints.map((waypoint) => waypoint.coordinate),
directionCoordinates,
);
cameraRef.current?.fitBounds(northEast, southWest, mapPadding, CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME);
});

const centerMap = () => {
const waypointCoordinates = waypoints?.map((waypoint) => waypoint.coordinate) ?? [];
if (!isTrackingGPS && (waypointCoordinates.length > 1 || directionCoordinates?.length > 1)) {
const {southWest, northEast} = utils.getBounds(waypointCoordinates, directionCoordinates);
cameraRef.current?.fitBounds(southWest, northEast, mapPadding, CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME);
return;
}
// Reset the user interaction state to allow the map to follow the user's location
setUserInteractedWithMap(false);

// If foreground location permissions are not granted, center the map on the fallback location
if (!foregroundLocationPermissionsGranted) {
cameraRef.current?.setCamera({
zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM,
animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME,
centerCoordinate,
});
}
};

const getWaypointBounds = () => {
if (!waypoints || userInteractedWithMap || !!singlePointCoordinate || (!waypoints.length && !directionCoordinates?.length)) {
return undefined;
}

const {northEast, southWest} = utils.getBounds(
waypoints.map((waypoint) => waypoint.coordinate),
directionCoordinates,
);
return {ne: northEast, sw: southWest};
};

const waypointsBounds = getWaypointBounds();
const waypointsCenterCoordinate = userInteractedWithMap ? undefined : singlePointCoordinate;
const waypointsZoomLevel = waypointsCenterCoordinate ? CONST.MAPBOX.SINGLE_MARKER_ZOOM : undefined;

const onUserLocationUpdate = (update: Mapbox.Location) => {
const coords = update.coords;
setLastLocation({longitude: coords.longitude, latitude: coords.latitude});
};

const shouldFollowFallbackLocation = noWaypoints && foregroundLocationPermissionsGranted === false;

const cameraPadding: Mapbox.CameraPadding | undefined =
mapPadding !== undefined ? {paddingLeft: mapPadding, paddingRight: mapPadding, paddingTop: mapPadding, paddingBottom: mapPadding} : undefined;

// defaultSettings with bounds ensures there is immediate snap to GPS trip on map load
const defaultSettings: Mapbox.CameraStop | undefined = {
bounds: waypointsBounds,
padding: waypointsBounds ? cameraPadding : undefined,
centerCoordinate: shouldFollowFallbackLocation ? centerCoordinate : waypointsCenterCoordinate,
zoomLevel: shouldFollowFallbackLocation ? CONST.MAPBOX.DEFAULT_ZOOM : waypointsZoomLevel,
};

const mapHeading = useSharedValue(0);

const onCameraChanged = (e: MapState) => {
mapHeading.set(e.properties.heading ?? 0);
};
// A map created while Location services were off never gets a location fix again, so it is recreated once they come back
const mapInstanceKey = useLocationServicesRemountKey();

return !isOffline && isAccessTokenReady && foregroundLocationPermissionsGranted !== null ? (
<View style={style}>
<Mapbox.MapView
style={{flex: 1}}
styleURL={styleURL}
onTouchStart={() => setUserInteractedWithMap(true)}
pitchEnabled={pitchEnabled}
attributionPosition={{...styles.r2, ...styles.b2}}
scaleBarEnabled={false}
// We use scaleBarPosition with top: -32 to hide the scale bar on iOS because scaleBarEnabled={false} not work on iOS
scaleBarPosition={{...styles.tn8, left: 0}}
compassEnabled={false}
onCameraChanged={onCameraChanged}
logoPosition={{...styles.l2, ...styles.b2}}
{...responder.panHandlers}
>
<LayerOrderAnchors />

<Mapbox.Viewport
onStatusChanged={(event) => {
if (!shouldUseImmediateFollowTransition) {
return;
}

if (event.from.kind === 'transition' && event.from.toState.kind === 'followPuck') {
setShouldUseImmediateFollowTransition(false);
}
}}
/>
<Mapbox.Camera
ref={cameraRef}
followUserLocation={shouldFollowUserLocation}
followUserLocationUseImmediateTransition={shouldUseImmediateFollowTransition}
followZoomLevel={CONST.MAPBOX.DEFAULT_ZOOM}
bounds={waypointsBounds ? {...waypointsBounds, ...cameraPadding} : undefined}
defaultSettings={defaultSettings}
centerCoordinate={shouldFollowFallbackLocation ? centerCoordinate : waypointsCenterCoordinate}
zoomLevel={waypointsZoomLevel}
/>

{/** Show fallback location if foreground location permissions are not granted */}
{foregroundLocationPermissionsGranted === false && (
<MarkerView
id={CONST.MAP_VIEW_LAYERS.USER_LOCATION}
coordinate={centerCoordinate}
allowOverlap
>
<ImageSVG
src={expensifyIcons.MapCurrentLocation}
width={CONST.MAP_MARKER_SIZES.CURRENT_LOCATION.width}
height={CONST.MAP_MARKER_SIZES.CURRENT_LOCATION.height}
/>
</MarkerView>
)}

<Mapbox.Images>
<Mapbox.Image name={CURRENT_LOCATION_PUCK_IMAGE}>
<ImageSVG
src={expensifyIcons.MapCurrentLocationPuck}
width={CONST.MAP_MARKER_SIZES.CURRENT_LOCATION.width}
height={CONST.MAP_MARKER_SIZES.CURRENT_LOCATION.height}
/>
</Mapbox.Image>
</Mapbox.Images>

{/** We want to use our custom current location marker instead of the default one */}
{!!foregroundLocationPermissionsGranted && (
<>
<Mapbox.LocationPuck
pulsing={LOCATION_PUCK_PULSING}
topImage={CURRENT_LOCATION_PUCK_IMAGE}
/>
<Mapbox.UserLocation
onUpdate={onUserLocationUpdate}
visible={false}
/>
</>
)}

<GPSWaypointLayer
waypoints={waypoints}
belowLayerID={CONST.MAP_VIEW_LAYERS.WAYPOINTS_ANCHOR}
/>

{!noWaypoints && (
<GPSDirection
directionCoordinates={directionCoordinatesProp}
isTrackingGPS={isTrackingGPS}
lastLocation={lastLocation}
belowLayerID={CONST.MAP_VIEW_LAYERS.ROUTE_ANCHOR}
/>
)}
</Mapbox.MapView>
<Compass
interactive
shouldDisplayCompass
cameraRef={cameraRef}
mapHeading={mapHeading}
/>
<View style={[styles.pAbsolute, styles.p5, styles.t0, styles.r0, styles.zIndex1]}>
<Button
onPress={centerMap}
accessibilityLabel={translate('common.center')}
>
<Button.Icon
src={expensifyIcons.Crosshair}
fill={theme.icon}
hoverFill={theme.icon}
/>
</Button>
</View>
</View>
<GPSMapViewContent
key={mapInstanceKey}
{...contentProps}
foregroundLocationPermissionsGranted={foregroundLocationPermissionsGranted}
/>
) : (
<PendingMapView
title={translate('distance.mapPending.title')}
Expand Down
Loading
Loading