diff --git a/src/frontend/context/LocationContext.js b/src/frontend/context/LocationContext.js index 539109e5b..34d0c6588 100644 --- a/src/frontend/context/LocationContext.js +++ b/src/frontend/context/LocationContext.js @@ -8,9 +8,9 @@ import { withPermissions, PERMISSIONS, RESULTS } from "./PermissionsContext"; import type { PermissionResult, PermissionsType } from "./PermissionsContext"; const log = debug("mapeo:Location"); -const STORE_KEY = "@MapeoPosition"; +const STORE_KEY = "@MapeoPosition@1"; -type PositionType = { +export type PositionType = { // The timestamp of when the current position was obtained timestamp: number, // Whether the position is mocked or not @@ -48,7 +48,7 @@ export type LocationContextType = { // Whether the user has granted permissions to use location to this app permission?: PermissionResult, // This is the previous known position from the last time the app was open - savedPosition?: PositionType, + savedPosition?: PositionType | null, // True if there is some kind of error getting the device location error: boolean }; @@ -113,7 +113,9 @@ class LocationProvider extends React.Component { this.updateStatus(); try { const savedPosition = await AsyncStorage.getItem(STORE_KEY); - if (savedPosition != null && !this.state.position) { + if (savedPosition == null) { + this.setState({ savedPosition: null }); + } else if (!this.state.position) { this.setState({ savedPosition: JSON.parse(savedPosition) }); } } catch (e) { @@ -181,7 +183,11 @@ class LocationProvider extends React.Component { }; render() { - return {this.props.children}; + // Waiting until savedPosition has loaded before first render + // savedPosition will be null if it is loaded but there is no saved position + return this.state.savedPosition === undefined ? null : ( + {this.props.children} + ); } } diff --git a/src/frontend/screens/MapScreen.js b/src/frontend/screens/MapScreen.js index c05d3062f..8dbd6828c 100644 --- a/src/frontend/screens/MapScreen.js +++ b/src/frontend/screens/MapScreen.js @@ -6,6 +6,7 @@ import type { NavigationScreenConfigProps } from "react-navigation"; import MapView from "../sharedComponents/MapView"; import ObservationsContext from "../context/ObservationsContext"; +import LocationContext from "../context/LocationContext"; import { getMapStyleUrl, checkMapStyle } from "../api"; type Props = { @@ -37,25 +38,37 @@ class MapStyleProvider extends React.Component< } } -const MapScreen = ({ onAddPress, navigation }: Props) => ( - - - {({ observations }) => ( - - {styleURL => ( - - navigation.navigate("Observation", { observationId }) - } - styleURL={styleURL} - /> +class MapScreen extends React.Component { + handleObservationPress = (observationId: string) => + this.props.navigation.navigate("Observation", { observationId }); + + render() { + const { onAddPress } = this.props; + + return ( + + + {({ observations }) => ( + + {location => ( + + {styleURL => ( + + )} + + )} + )} - - )} - - -); + + + ); + } +} export default MapScreen; diff --git a/src/frontend/sharedComponents/MapView.js b/src/frontend/sharedComponents/MapView.js index 342495a66..9c2b66507 100644 --- a/src/frontend/sharedComponents/MapView.js +++ b/src/frontend/sharedComponents/MapView.js @@ -1,11 +1,18 @@ // @flow import React from "react"; +import { View, StyleSheet } from "react-native"; import MapboxGL from "@react-native-mapbox/maps"; +import debug from "debug"; // import type { MapStyle } from "../types"; -import type { ObservationsMap } from "../context/ObservationsContext"; import AddButton from "./AddButton"; -import debug from "debug"; +import { LocationFollowingIcon, LocationNoFollowIcon } from "./icons"; +import IconButton from "./IconButton"; +import type { + LocationContextType, + PositionType +} from "../context/LocationContext"; +import type { ObservationsMap } from "../context/ObservationsContext"; const log = debug("mapeo:MapView"); @@ -84,15 +91,24 @@ class ObservationMapLayer extends React.PureComponent<{ type Props = { observations: ObservationsMap, styleURL: string, + location: LocationContextType, onAddPress: () => any, onPressObservation: (observationId: string) => any }; -class MapView extends React.Component { +type State = { + // True if the map is following user location + following: boolean +}; + +class MapView extends React.Component { static defaultProps = { onAddPress: () => {}, onPressObservation: () => {} }; + state = { following: true }; + map: any; + initialPosition: void | null | PositionType; constructor(props: Props) { super(props); @@ -101,9 +117,20 @@ class MapView extends React.Component { ); MapboxGL.setTelemetryEnabled(false); log("accessToken set"); + this.initialPosition = + props.location.position || props.location.savedPosition; } - map: any; + // We only use the location prop (which contains the app GPS location) for the + // first render of the map. After that location updates come from the native + // map view, so we don't want to re-render this component every time there is + // a GPS update + shouldComponentUpdate(nextProps: Props, nextState: State) { + return ( + shallowDiffers(this.props, nextProps, ["location"]) || + shallowDiffers(this.state, nextState, ["location"]) + ); + } handleObservationPress = (e: { nativeEvent?: { @@ -133,8 +160,24 @@ class MapView extends React.Component { this.map = c; }; + handleRegionChange = (e: any) => { + // Any user interaction with the map switches follow mode to false + if (e.properties.isUserInteraction) this.setState({ following: false }); + }; + + handleLocationPress = () => { + this.setState(state => ({ following: !state.following })); + }; + render() { const { observations, onAddPress, styleURL } = this.props; + const initialCoords = this.initialPosition + ? [ + this.initialPosition.coords.longitude, + this.initialPosition.coords.latitude + ] + : [0, 0]; + const initialZoom = this.initialPosition ? 8 : 0; return ( <> { onPress={this.handleObservationPress} compassEnabled={false} styleURL={styleURL} + onRegionWillChange={this.handleRegionChange} + regionWillChangeDebounceTime={200} > - + {!this.state.following && ( + + )} + + + + {this.state.following ? ( + + ) : ( + + )} + + ); } } export default MapView; + +// Shallow compare objects, but omitting certain keys from the comparison +function shallowDiffers(a: any, b: any, omit: string[]) { + for (let i in a) if (!(i in b)) return true; + for (let i in b) { + if (a[i] !== b[i] && omit.indexOf(i) === -1) return true; + } + return false; +} + +const styles = StyleSheet.create({ + locationButton: { + position: "absolute", + right: 20, + bottom: 20 + } +}); diff --git a/src/frontend/sharedComponents/icons/CategoryIcon.js b/src/frontend/sharedComponents/icons/CategoryIcon.js index 86bf34b76..2de76558b 100644 --- a/src/frontend/sharedComponents/icons/CategoryIcon.js +++ b/src/frontend/sharedComponents/icons/CategoryIcon.js @@ -1,31 +1,12 @@ // @flow import * as React from "react"; -import { Image, StyleSheet, View } from "react-native"; +import { Image } from "react-native"; import MaterialIcon from "react-native-vector-icons/MaterialIcons"; +import Circle from "./Circle"; import { getIconUrl } from "../../api"; import type { IconSize } from "../../types"; -type CircleProps = { - radius?: number, - children: React.Node -}; - -const Circle = ({ radius = 25, children }: CircleProps) => ( - - {children} - -); - type IconProps = { size?: IconSize, iconId?: string @@ -72,21 +53,3 @@ export const CategoryCircleIcon = ({ ); - -const styles = StyleSheet.create({ - circle: { - width: 50, - height: 50, - backgroundColor: "white", - borderRadius: 50, - borderColor: "#EAEAEA", - borderWidth: 1, - justifyContent: "center", - alignItems: "center", - shadowColor: "black", - shadowRadius: 5, - shadowOpacity: 0.5, - shadowOffset: { width: 0, height: 2 }, - elevation: 3 - } -}); diff --git a/src/frontend/sharedComponents/icons/Circle.js b/src/frontend/sharedComponents/icons/Circle.js new file mode 100644 index 000000000..b9cab3fec --- /dev/null +++ b/src/frontend/sharedComponents/icons/Circle.js @@ -0,0 +1,46 @@ +// @flow +import * as React from "react"; +import { StyleSheet, View } from "react-native"; +import type { Style } from "../../types"; + +type CircleProps = { + radius?: number, + children: React.Node, + style?: Style +}; + +const Circle = ({ radius = 25, style, children }: CircleProps) => ( + + {children} + +); + +export default Circle; + +const styles = StyleSheet.create({ + circle: { + width: 50, + height: 50, + backgroundColor: "white", + borderRadius: 50, + borderColor: "#EAEAEA", + borderWidth: 1, + justifyContent: "center", + alignItems: "center", + shadowColor: "black", + shadowRadius: 5, + shadowOpacity: 0.5, + shadowOffset: { width: 0, height: 2 }, + elevation: 3 + } +}); diff --git a/src/frontend/sharedComponents/icons/index.js b/src/frontend/sharedComponents/icons/index.js index aa12b06ed..dc41cdc54 100644 --- a/src/frontend/sharedComponents/icons/index.js +++ b/src/frontend/sharedComponents/icons/index.js @@ -5,6 +5,7 @@ import MaterialIcon from "react-native-vector-icons/MaterialIcons"; import FontAwesomeIcon from "react-native-vector-icons/FontAwesome"; import { Image, Text } from "react-native"; +import Circle from "./Circle"; import { RED, DARK_GREY, MANGO, MEDIUM_GREY } from "../../lib/styles"; import type { Style } from "../../types"; @@ -159,3 +160,21 @@ export const ObservationListIcon = ({ size = 30, style }: ImageIconProps) => ( style={[{ width: size, height: size }, style]} /> ); + +export const LocationNoFollowIcon = ({ + size = 30, + color = MEDIUM_GREY +}: FontIconProps) => ( + + + +); + +export const LocationFollowingIcon = ({ + size = 30, + color = "#4A90E2" +}: FontIconProps) => ( + + + +);