diff --git a/src/components/Explore/Explore.js b/src/components/Explore/Explore.js
index fb294877e..8440c8021 100644
--- a/src/components/Explore/Explore.js
+++ b/src/components/Explore/Explore.js
@@ -48,7 +48,6 @@ type Props = {
closeFiltersModal: Function,
count: Object,
currentExploreView: string,
- currentMapRegion: Object,
filterByIconicTaxonUnknown: Function,
handleUpdateCount: Function,
hasLocationPermissions?: boolean,
@@ -61,7 +60,6 @@ type Props = {
renderLocationPermissionsGate: Function,
requestLocationPermissions: Function,
setCurrentExploreView: Function,
- setCurrentMapRegion: Function,
showFiltersModal: boolean,
startFetching: Function,
updateLocation: Function,
@@ -75,7 +73,6 @@ const Explore = ( {
closeFiltersModal,
count,
currentExploreView,
- currentMapRegion,
filterByIconicTaxonUnknown,
handleUpdateCount,
hasLocationPermissions,
@@ -88,7 +85,6 @@ const Explore = ( {
renderLocationPermissionsGate,
requestLocationPermissions,
setCurrentExploreView,
- setCurrentMapRegion,
showFiltersModal,
startFetching,
updateLocation,
@@ -162,8 +158,9 @@ const Explore = ( {
layout={layout}
queryParams={queryParams}
handleUpdateCount={handleUpdateCount}
- currentMapRegion={currentMapRegion}
- setCurrentMapRegion={setCurrentMapRegion}
+ hasLocationPermissions={hasLocationPermissions}
+ renderLocationPermissionsGate={renderLocationPermissionsGate}
+ requestLocationPermissions={requestLocationPermissions}
/>
)}
{currentExploreView === "species" && (
diff --git a/src/components/Explore/ExploreContainer.js b/src/components/Explore/ExploreContainer.js
index e9bf88249..e9bbc1a72 100644
--- a/src/components/Explore/ExploreContainer.js
+++ b/src/components/Explore/ExploreContainer.js
@@ -26,8 +26,6 @@ const ExploreContainerWithContext = ( ): Node => {
const { isConnected } = useNetInfo( );
const exploreView = useStore( state => state.exploreView );
const setExploreView = useStore( state => state.setExploreView );
- const mapRegion = useStore( s => s.mapRegion );
- const setMapRegion = useStore( s => s.setMapRegion );
const {
hasPermissions: hasLocationPermissions,
@@ -160,8 +158,6 @@ const ExploreContainerWithContext = ( ): Node => {
renderLocationPermissionsGate={renderPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
startFetching={startFetching}
- currentMapRegion={mapRegion}
- setCurrentMapRegion={setMapRegion}
/>
{renderPermissionsGate( {
onPermissionGranted: startFetching
diff --git a/src/components/Explore/MapView.tsx b/src/components/Explore/MapView.tsx
index af82470c4..4fad93a3d 100644
--- a/src/components/Explore/MapView.tsx
+++ b/src/components/Explore/MapView.tsx
@@ -5,19 +5,48 @@ import {
} from "components/SharedComponents";
import { getMapRegion } from "components/SharedComponents/Map/helpers/mapHelpers.ts";
import { View } from "components/styledComponents";
-import { MapBoundaries, PLACE_MODE, useExplore } from "providers/ExploreContext.tsx";
-import React, { useEffect } from "react";
+import {
+ EXPLORE_ACTION, MapBoundaries, PLACE_MODE, useExplore
+} from "providers/ExploreContext.tsx";
+import React, {
+ useEffect, useMemo, useRef, useState
+} from "react";
import { Region } from "react-native-maps";
import { useTranslation } from "sharedHooks";
import { getShadow } from "styles/global";
-import useMapLocation from "./hooks/useMapLocation";
+const NEARBY_DELTA = 0.02;
+
+const WORLDWIDE_DELTA = 180;
+const WORLDWIDE_LAT_LNG = 0.0;
+
+const worldwideRegion = {
+ latitude: WORLDWIDE_LAT_LNG,
+ longitude: WORLDWIDE_LAT_LNG,
+ latitudeDelta: WORLDWIDE_DELTA,
+ longitudeDelta: WORLDWIDE_DELTA
+};
const DROP_SHADOW = getShadow( {
offsetHeight: 4,
elevation: 6
} );
+const activityIndicatorSize = 50;
+const centeredLoadingWheel = {
+ position: "absolute",
+ top: "50%",
+ left: "50%",
+ transform: [
+ { translateX: -( activityIndicatorSize / 2 ) },
+ { translateY: -( activityIndicatorSize / 2 ) }
+ ],
+ backgroundColor: "rgba(0,0,0,0)",
+ alignItems: "center",
+ justifyContent: "center",
+ zIndex: 20
+};
+
interface Props {
// Bounding box of the observations retrieved for the query params
observationBounds?: MapBoundaries,
@@ -27,42 +56,104 @@ interface Props {
order?: string;
orderBy?: string;
};
- currentMapRegion: Region;
- setCurrentMapRegion: ( Region ) => void;
- isLoading: boolean
+ isLoading: boolean,
+ hasLocationPermissions?: boolean,
+ renderLocationPermissionsGate: Function,
+ requestLocationPermissions: Function
}
const MapView = ( {
observationBounds,
queryParams,
- currentMapRegion,
isLoading,
- setCurrentMapRegion
+ hasLocationPermissions,
+ renderLocationPermissionsGate,
+ requestLocationPermissions
}: Props ) => {
const { t } = useTranslation( );
- const { state: exploreState } = useExplore( );
+ const { state: exploreState, dispatch, defaultExploreLocation } = useExplore( );
+ const [showRedoSearchButton, setShowRedoSearchButton] = useState( false );
+ const isFirstRender = useRef( true );
- const {
- onPanDrag,
- redoSearchInMapArea,
- region,
- showMapBoundaryButton,
- updateMapBoundaries
- } = useMapLocation( currentMapRegion, setCurrentMapRegion );
+ const mapRef = useRef( null );
- // TODO this should really be a part of the explore reducer
- useEffect( ( ) => {
- if (
- observationBounds
- && [
- PLACE_MODE.WORLDWIDE,
- PLACE_MODE.PLACE,
- PLACE_MODE.NEARBY
- ].indexOf( exploreState.placeMode ) >= 0
- ) {
- updateMapBoundaries( getMapRegion( observationBounds ) );
+ const nearbyRegion = useMemo( () => ( {
+ latitude: exploreState.lat,
+ longitude: exploreState.lng,
+ latitudeDelta: NEARBY_DELTA,
+ longitudeDelta: NEARBY_DELTA
+ } ), [exploreState.lat, exploreState.lng] );
+
+ const regionFromCoordinates = useMemo( ( ) => {
+ if ( exploreState.place?.point_geojson?.coordinates ) {
+ const [longitude, latitude] = exploreState.place.point_geojson.coordinates;
+ return {
+ latitude,
+ longitude,
+ latitudeDelta: NEARBY_DELTA,
+ longitudeDelta: NEARBY_DELTA
+ };
}
- }, [observationBounds, updateMapBoundaries, exploreState.placeMode] );
+ return null;
+ }, [exploreState.place] );
+
+ useEffect( ( ) => {
+ // Skip animation on first render
+ if ( isFirstRender.current ) {
+ isFirstRender.current = false;
+ return;
+ }
+
+ if ( mapRef.current
+ && exploreState.placeMode === PLACE_MODE.MAP_AREA ) {
+ return;
+ }
+
+ // since we're using initialRegion, we need to animate to the correct zoom level
+ // when a user switches back to NEARBY or WORLDWIDE
+ if ( mapRef.current
+ && exploreState.placeMode === PLACE_MODE.NEARBY ) {
+ // Note: we do get observationBounds back from the API for nearby
+ // but per user feedback, we want to show users a more zoomed in view
+ // when they're looking at NEARBY view
+ if ( nearbyRegion.latitude !== undefined && nearbyRegion.longitude !== undefined ) {
+ mapRef.current.animateToRegion( nearbyRegion );
+ }
+ return;
+ }
+ if ( mapRef.current
+ && exploreState.placeMode === PLACE_MODE.WORLDWIDE ) {
+ mapRef.current.animateToRegion( worldwideRegion );
+ }
+ if ( mapRef.current
+ && exploreState.placeMode === PLACE_MODE.PLACE ) {
+ if ( observationBounds ) {
+ const newRegion = getMapRegion( observationBounds );
+ mapRef.current.animateToRegion( newRegion );
+ }
+ }
+ }, [
+ exploreState.placeMode,
+ nearbyRegion,
+ regionFromCoordinates,
+ observationBounds,
+ exploreState.place?.id
+ ] );
+
+ const handleRedoSearch = async ( ) => {
+ setShowRedoSearchButton( false );
+ const currentBounds = await mapRef?.current?.getMapBoundaries( );
+ dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_MAP_AREA } );
+ dispatch( {
+ type: EXPLORE_ACTION.SET_MAP_BOUNDARIES,
+ mapBoundaries: {
+ swlat: currentBounds.southWest.latitude,
+ swlng: currentBounds.southWest.longitude,
+ nelat: currentBounds.northEast.latitude,
+ nelng: currentBounds.northEast.longitude
+ }
+ } );
+ };
const tileMapParams = {
...queryParams
@@ -72,10 +163,40 @@ const MapView = ( {
delete tileMapParams.order;
delete tileMapParams.orderBy;
+ const initialRegion: Region = useMemo( () => {
+ if ( exploreState.placeMode === PLACE_MODE.NEARBY ) {
+ if ( nearbyRegion.latitude !== undefined && nearbyRegion.longitude !== undefined ) {
+ return nearbyRegion;
+ }
+ }
+
+ if ( exploreState.placeMode === PLACE_MODE.PLACE ) {
+ if ( regionFromCoordinates ) {
+ return regionFromCoordinates;
+ }
+ }
+
+ return worldwideRegion;
+ }, [exploreState.placeMode, nearbyRegion, regionFromCoordinates] );
+
+ const handlePanDrag = ( ) => setShowRedoSearchButton( true );
+
+ const handleCurrentLocationPress = async ( ) => {
+ if ( hasLocationPermissions ) {
+ const exploreLocation = await defaultExploreLocation( );
+ dispatch( {
+ type: EXPLORE_ACTION.SET_EXPLORE_LOCATION,
+ exploreLocation
+ } );
+ } else {
+ requestLocationPermissions( );
+ }
+ };
+
return (
- {showMapBoundaryButton && (
+ {showRedoSearchButton && (
)}
- {isLoading
- ?
- : (
-
- )}
+
+ {isLoading && (
+
+
+
+ )}
+ {renderLocationPermissionsGate( { onPermissionGranted: handleCurrentLocationPress } )}
);
};
diff --git a/src/components/Explore/ObservationsView.js b/src/components/Explore/ObservationsView.js
index 1a7e2061f..e1a7f5141 100644
--- a/src/components/Explore/ObservationsView.js
+++ b/src/components/Explore/ObservationsView.js
@@ -28,8 +28,9 @@ type Props = {
layout: string,
queryParams: Object,
handleUpdateCount: Function,
- currentMapRegion: Object,
- setCurrentMapRegion: Function
+ hasLocationPermissions?: boolean,
+ renderLocationPermissionsGate: Function,
+ requestLocationPermissions: Function
}
const OBS_LIST_CONTAINER_STYLE = { paddingTop: 50 };
@@ -41,8 +42,9 @@ const ObservationsView = ( {
layout,
queryParams,
handleUpdateCount,
- currentMapRegion,
- setCurrentMapRegion
+ hasLocationPermissions,
+ renderLocationPermissionsGate,
+ requestLocationPermissions
}: Props ): Node => {
const currentUser = useCurrentUser( );
const { state } = useExplore();
@@ -139,8 +141,9 @@ const ObservationsView = ( {
observationBounds={totalBounds}
isLoading={isLoading}
queryParams={queryParams}
- currentMapRegion={currentMapRegion}
- setCurrentMapRegion={setCurrentMapRegion}
+ hasLocationPermissions={hasLocationPermissions}
+ renderLocationPermissionsGate={renderLocationPermissionsGate}
+ requestLocationPermissions={requestLocationPermissions}
/>
);
diff --git a/src/components/Explore/ObservationsViewBar.js b/src/components/Explore/ObservationsViewBar.js
index b963492cd..02ea1125a 100644
--- a/src/components/Explore/ObservationsViewBar.js
+++ b/src/components/Explore/ObservationsViewBar.js
@@ -8,7 +8,6 @@ import { getShadow } from "styles/global";
import colors from "styles/tailwindColors";
type Props = {
- gridFirst?: boolean,
hideMap?: boolean,
layout: string,
updateObservationsView: Function
@@ -20,30 +19,26 @@ const DROP_SHADOW = getShadow( {
} );
const ObservationsViewBar = ( {
- gridFirst,
hideMap,
layout,
updateObservationsView
}: Props ): Node => {
const buttons = [
- {
- value: "list",
- icon: "hamburger-menu",
- accessibilityLabel: "List",
- testID: "SegmentedButton.list"
- },
{
value: "grid",
icon: "grid",
accessibilityLabel: "Grid",
testID: "SegmentedButton.grid"
+ },
+ {
+ value: "list",
+ icon: "hamburger-menu",
+ accessibilityLabel: "List",
+ testID: "SegmentedButton.list"
}
];
- if ( gridFirst ) {
- buttons.reverse( );
- }
if ( !hideMap ) {
- buttons.push( {
+ buttons.unshift( {
value: "map",
icon: "map",
accessibilityLabel: "Map",
diff --git a/src/components/Explore/RootExploreContainer.js b/src/components/Explore/RootExploreContainer.js
index e99989c2e..490ef0e9e 100644
--- a/src/components/Explore/RootExploreContainer.js
+++ b/src/components/Explore/RootExploreContainer.js
@@ -33,8 +33,6 @@ const RootExploreContainerWithContext = ( ): Node => {
const setRootExploreView = useStore( state => state.setRootExploreView );
const rootStoredParams = useStore( state => state.rootStoredParams );
const setRootStoredParams = useStore( state => state.setRootStoredParams );
- const rootMapRegion = useStore( s => s.rootMapRegion );
- const setRootMapRegion = useStore( s => s.setRootMapRegion );
const {
hasPermissions: hasLocationPermissions,
@@ -231,8 +229,6 @@ const RootExploreContainerWithContext = ( ): Node => {
hasLocationPermissions={hasLocationPermissions}
requestLocationPermissions={requestLocationPermissions}
startFetching={startFetching}
- currentMapRegion={rootMapRegion}
- setCurrentMapRegion={setRootMapRegion}
renderLocationPermissionsGate={renderPermissionsGate}
/>
{renderPermissionsGate( {
diff --git a/src/components/Explore/SearchScreens/ExploreTaxonSearch.js b/src/components/Explore/SearchScreens/ExploreTaxonSearch.js
index efab2d9be..79ae4530c 100644
--- a/src/components/Explore/SearchScreens/ExploreTaxonSearch.js
+++ b/src/components/Explore/SearchScreens/ExploreTaxonSearch.js
@@ -52,6 +52,7 @@ const ExploreTaxonSearch = ( {
const renderItem = useCallback( ( { item: taxon, index } ) => (
onTaxonSelected( taxon )}
hideInfoButton={hideInfoButton}
onPressInfo={onPressInfo}
diff --git a/src/components/Explore/hooks/useMapLocation.ts b/src/components/Explore/hooks/useMapLocation.ts
deleted file mode 100644
index 2c4c8b90a..000000000
--- a/src/components/Explore/hooks/useMapLocation.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import {
- EXPLORE_ACTION,
- MapBoundaries,
- PLACE_MODE,
- useExplore
-} from "providers/ExploreContext.tsx";
-import {
- useCallback, useEffect, useRef, useState
-} from "react";
-import { BoundingBox, Region } from "react-native-maps";
-import { initialMapRegion } from "stores/createExploreSlice.ts";
-
-const useMapLocation = ( currentMapRegion, setCurrentMapRegion ) => {
- const { dispatch, state } = useExplore( );
- const [mapBoundaries, setMapBoundaries] = useState( );
- const [showMapBoundaryButton, setShowMapBoundaryButton] = useState( false );
-
- const place = state?.place;
-
- const onPanDrag = ( ) => setShowMapBoundaryButton( true );
-
- const mapWasReset = state.placeMode === PLACE_MODE.NEARBY
- || state.placeMode === PLACE_MODE.WORLDWIDE;
- const placeIdWasSet = state.place_id;
-
- // eslint-disable-next-line max-len
- const updateMapBoundaries = useCallback( async ( newRegion: Region, boundaries?: BoundingBox ) => {
- setCurrentMapRegion( newRegion );
- if ( boundaries ) {
- const newMapBoundaries = {
- swlat: boundaries.southWest.latitude,
- swlng: boundaries.southWest.longitude,
- nelat: boundaries.northEast.latitude,
- nelng: boundaries.northEast.longitude
- };
- setMapBoundaries( newMapBoundaries );
- return newMapBoundaries;
- }
- return {};
- }, [
- setMapBoundaries,
- setCurrentMapRegion
- ] );
-
- const redoSearchInMapArea = ( ) => {
- if ( !mapBoundaries ) return;
- setShowMapBoundaryButton( false );
- dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_MAP_AREA } );
- dispatch( { type: EXPLORE_ACTION.SET_MAP_BOUNDARIES, mapBoundaries } );
- };
-
- const previousPlaceGuess = useRef( state.placeMode );
- useEffect( ( ) => {
- // region gets set when a user is navigating from ExploreLocationSearch
- if ( placeIdWasSet ) {
- const coordinates = place?.point_geojson?.coordinates
- ? place.point_geojson.coordinates
- : place?.bounding_box_geojson?.coordinates;
- if ( coordinates ) {
- setCurrentMapRegion( {
- ...initialMapRegion,
- latitude: coordinates[1],
- longitude: coordinates[0]
- } );
- }
- } else if ( mapWasReset ) {
- // map gets set or reset back to nearby/worldwide, but only if the placeMode
- // has changed
- if ( previousPlaceGuess.current === state.placeMode ) {
- return;
- }
- setCurrentMapRegion( {
- ...initialMapRegion,
- latitude: state?.lat,
- longitude: state?.lng
- } );
- previousPlaceGuess.current = state.placeMode;
- }
- }, [
- mapWasReset,
- place,
- placeIdWasSet,
- setCurrentMapRegion,
- state
- ] );
-
- return {
- onPanDrag,
- redoSearchInMapArea,
- region: currentMapRegion,
- showMapBoundaryButton,
- updateMapBoundaries
- };
-};
-
-export default useMapLocation;
diff --git a/src/components/MyObservations/MyObservationsSimple.tsx b/src/components/MyObservations/MyObservationsSimple.tsx
index 67529d393..d751962fd 100644
--- a/src/components/MyObservations/MyObservationsSimple.tsx
+++ b/src/components/MyObservations/MyObservationsSimple.tsx
@@ -309,7 +309,6 @@ const MyObservationsSimple = ( {
: )}
/>
{
} );
}, [ONBOARDING_SLIDES, totalImages] );
- if ( !imagesLoaded ) {
+ // TODO: On Android release build imagesLoaded never switched from false to true, and
+ // this screen was stuck in a loading state. On iOS it worked as expected.
+ // Disabling it now on Android to make a new release possible.
+ if ( Platform.OS === "android"
+ ? false
+ : !imagesLoaded ) {
return (
{
- onPressRef.current = onPress;
- }, [onPress] );
+ onPressRef.current = onPress;
const isPrimary = level === "primary";
const isWarning = level === "warning";
@@ -210,19 +208,16 @@ const Button = ( {
const handlePress = ( event?: GestureResponderEvent ) => {
if ( !preventMultipleTaps ) {
- onPressRef.current( event );
- return;
+ return onPressRef.current( event );
}
- if ( isProcessing ) return;
-
setIsProcessing( true );
-
onPressRef.current( event );
setTimeout( ( ) => {
setIsProcessing( false );
}, debounceTime );
+ return null;
};
const isDisabled = disabled || ( preventMultipleTaps && isProcessing );
diff --git a/src/components/SharedComponents/Map/CurrentLocationButton.tsx b/src/components/SharedComponents/Map/CurrentLocationButton.tsx
index 0dad733ef..d8e7e104e 100644
--- a/src/components/SharedComponents/Map/CurrentLocationButton.tsx
+++ b/src/components/SharedComponents/Map/CurrentLocationButton.tsx
@@ -31,6 +31,7 @@ const CurrentLocationButton = ( {
style={DROP_SHADOW}
accessibilityLabel={t( "Zoom-to-current-location" )}
onPress={onPress}
+ testID="Map.CurrentLocationButton"
/>
{renderPermissionsGate( )}
>
diff --git a/src/components/SharedComponents/Map/Map.tsx b/src/components/SharedComponents/Map/Map.tsx
index 702b78423..fc8e4f857 100644
--- a/src/components/SharedComponents/Map/Map.tsx
+++ b/src/components/SharedComponents/Map/Map.tsx
@@ -3,6 +3,7 @@ import classnames from "classnames";
import { Body1 } from "components/SharedComponents";
import { View } from "components/styledComponents";
import React, {
+ forwardRef,
useCallback,
useEffect,
useMemo,
@@ -53,7 +54,7 @@ interface Props {
children?: React.ReactNode;
className?: string;
currentLocationButtonClassName?: string;
- initialRegion?: boolean;
+ initialRegion?: Region;
mapHeight?: DimensionValue; // allows for height to be defined as px or percentage
mapType?: MapType;
mapViewClassName?: string;
@@ -64,7 +65,7 @@ interface Props {
onRegionChangeComplete?: ( _r: Region, _b: BoundingBox | undefined ) => void;
openMapScreen?: () => void;
region?: Region;
- regionToAnimate?: Object;
+ regionToAnimate?: Region;
scrollEnabled?: boolean;
showCurrentLocationButton?: boolean;
showsCompass?: boolean;
@@ -82,7 +83,7 @@ interface Props {
// TODO: fallback to another map library
// for people who don't use GMaps (i.e. users in China)
-const Map = ( {
+const Map = forwardRef( ( {
children,
className = "flex-1",
currentLocationButtonClassName,
@@ -111,7 +112,7 @@ const Map = ( {
withPressableObsTiles,
zoomEnabled = true,
zoomTapEnabled = true
-}: Props ) => {
+}: Props, ref ) => {
const { isDebug } = useDebugMode( );
const { screenWidth, screenHeight } = useDeviceOrientation( );
const [currentZoom, setCurrentZoom] = useState( 0 );
@@ -243,7 +244,9 @@ const Map = ( {
// If we're supposed to be showing user location but we don't have it, ask
// for permission again, which should result in fetching the location if
// we can
- if ( !userLocation ) {
+ // skipping onCurrentLocationPress here because the handlers
+ // are handling the permissions request outside of this component (example: Explore MapView)
+ if ( !userLocation && onCurrentLocationPress === undefined ) {
requestPermissions( );
return;
}
@@ -413,9 +416,9 @@ const Map = ( {
: unfuzzedMapRegion;
// In Android, we maintain initialRegion as state localRegion and
- // pass null to parameter initialRegion.
+ // pass undefined to parameter initialRegion.
const mapInitialRegion = Platform.OS === "android"
- ? null
+ ? undefined
: initialRegion;
const renderDebugZoomLevel = ( ) => {
@@ -448,6 +451,18 @@ const Map = ( {
const longitude = observation?.privateLongitude || observation?.longitude;
const hasCoordinates = latitude && longitude;
+ const setRefs = instance => {
+ // Update our internal ref
+ mapViewRef.current = instance;
+
+ // Forward to the parent ref
+ if ( typeof ref === "function" ) {
+ ref( instance );
+ } else if ( ref ) {
+ ref.current = instance;
+ }
+ };
+
return (
);
-};
+} );
export default Map;
diff --git a/src/components/SharedComponents/SearchBar.tsx b/src/components/SharedComponents/SearchBar.tsx
index 5b4e949c8..c942969a3 100644
--- a/src/components/SharedComponents/SearchBar.tsx
+++ b/src/components/SharedComponents/SearchBar.tsx
@@ -2,7 +2,7 @@ import { fontRegular } from "appConstants/fontFamilies.ts";
import classNames from "classnames";
import { INatIcon, INatIconButton } from "components/SharedComponents";
import { View } from "components/styledComponents";
-import React from "react";
+import React, { useCallback, useRef, useState } from "react";
import { Keyboard, TextInput as RNTextInput } from "react-native";
import { TextInput, useTheme } from "react-native-paper";
import { useTranslation } from "sharedHooks";
@@ -21,6 +21,7 @@ interface Props {
placeholder?: string;
testID?: string;
value: string;
+ debounceTime?: number;
}
// Ensure this component is placed outside of scroll views
@@ -34,10 +35,26 @@ const SearchBar = ( {
input,
placeholder,
testID,
- value
+ value,
+ debounceTime = 300
}: Props ) => {
const theme = useTheme( );
const { t } = useTranslation( );
+ const [localValue, setLocalValue] = useState( value );
+
+ const debounceTimeout = useRef>();
+
+ const debouncedHandleTextChange = useCallback( ( text: string ) => {
+ setLocalValue( text );
+
+ if ( debounceTimeout.current ) {
+ clearTimeout( debounceTimeout.current );
+ }
+
+ debounceTimeout.current = setTimeout( ( ) => {
+ handleTextChange( text );
+ }, debounceTime );
+ }, [handleTextChange, debounceTime] );
const outlineStyle = {
borderColor: "lightgray",
@@ -76,7 +93,7 @@ const SearchBar = ( {
dense
keyboardType="default"
mode="outlined"
- onChangeText={handleTextChange}
+ onChangeText={debouncedHandleTextChange}
outlineStyle={outlineStyle}
placeholder={placeholder}
selectionColor={colors.darkGray}
@@ -84,9 +101,9 @@ const SearchBar = ( {
testID={testID}
theme={fontTheme}
underlineColor={colors.darkGray}
- value={value}
+ value={localValue}
/>
- {value?.length > 0 && clearSearch
+ {localValue?.length > 0 && clearSearch
? (
{
Keyboard.dismiss();
clearSearch();
+ setLocalValue( "" );
}}
/>
diff --git a/src/sharedHooks/useTaxonSearch.ts b/src/sharedHooks/useTaxonSearch.ts
index 11c4f5b13..7d5bbdd5a 100644
--- a/src/sharedHooks/useTaxonSearch.ts
+++ b/src/sharedHooks/useTaxonSearch.ts
@@ -1,7 +1,9 @@
import { fetchSearchResults } from "api/search.ts";
import type { ApiOpts } from "api/types";
import { RealmContext } from "providers/contexts.ts";
-import { useCallback, useEffect, useState } from "react";
+import {
+ useCallback, useEffect, useMemo, useState
+} from "react";
import Realm, { UpdateMode } from "realm";
import Taxon from "realmModels/Taxon";
import type { RealmTaxon } from "realmModels/types";
@@ -34,6 +36,8 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => {
const taxonQuery = taxonQueryArg.trim();
const [localTaxa, setLocalTaxa] = useState( null );
+ const shouldFetchRemote = taxonQuery.length > 0;
+
const { data: remoteTaxa, refetch, isLoading } = useAuthenticatedQuery(
["fetchTaxonSuggestions", taxonQuery],
async ( optsWithAuth: ApiOpts ) => {
@@ -50,16 +54,10 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => {
return apiTaxa?.map( taxon => Taxon.mapApiToRealm( taxon ) ) || [];
},
{
- enabled: !!( taxonQuery.length > 0 )
+ enabled: shouldFetchRemote
}
);
- useEffect( ( ) => {
- if ( realm && remoteTaxa?.length > 0 ) {
- saveTaxaToRealm( remoteTaxa, realm );
- }
- }, [realm, remoteTaxa] );
-
const safeRealmSearch = useCallback( async ( searchString: string ) => {
try {
const { cleanedQuery } = validateRealmSearch( searchString );
@@ -82,53 +80,89 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => {
}, [realm] );
useEffect( ( ) => {
- const searchLocalTaxa = async ( ) => {
- if (
- taxonQuery.length > 0
- && !isLoading
- && ( !remoteTaxa || remoteTaxa.length === 0 )
- ) {
- try {
- const results = await safeRealmSearch( taxonQuery );
- setLocalTaxa( results );
- } catch ( error ) {
- console.error( "Local search failed:", error );
- setLocalTaxa( [] );
- }
- } else {
- setLocalTaxa( null );
+ let isSubscribed = true;
+ const saveOrSearchRealmTaxa = async ( ) => {
+ // save taxa to realm if we have results from the API
+ if ( realm && remoteTaxa?.length > 0 ) {
+ saveTaxaToRealm( remoteTaxa, realm );
+ }
+ // Search for local taxa if we have a query, if remote results are not loading
+ // and if remote results are empty
+ if ( taxonQuery.length === 0 ) {
+ if ( isSubscribed ) setLocalTaxa( null );
+ return;
+ }
+
+ if ( isLoading ) return;
+
+ if ( remoteTaxa && remoteTaxa.length > 0 ) {
+ if ( isSubscribed ) setLocalTaxa( null );
+ return;
+ }
+
+ try {
+ const results = await safeRealmSearch( taxonQuery );
+ if ( isSubscribed ) setLocalTaxa( results );
+ } catch ( error ) {
+ console.error( "Local search failed:", error );
+ if ( isSubscribed ) setLocalTaxa( [] );
}
};
- searchLocalTaxa( );
- }, [taxonQuery, isLoading, remoteTaxa, safeRealmSearch] );
+ saveOrSearchRealmTaxa( );
- // Show iconic taxa by default
- if ( taxonQuery.length === 0 ) {
+ return ( ) => {
+ isSubscribed = false;
+ };
+ }, [
+ isLoading,
+ realm,
+ remoteTaxa,
+ safeRealmSearch,
+ taxonQuery
+ ] );
+
+ return useMemo( () => {
+ // Show iconic taxa by default (empty query)
+ if ( taxonQuery.length === 0 ) {
+ return {
+ taxa: iconicTaxa,
+ refetch: () => undefined,
+ isLoading: false,
+ isLocal: false
+ };
+ }
+
+ // Show remote taxa if available
+ if ( remoteTaxa && remoteTaxa.length > 0 ) {
+ return {
+ taxa: remoteTaxa,
+ refetch,
+ isLoading,
+ isLocal: false
+ };
+ }
+
+ // Show local taxa if available
+ if ( localTaxa !== null && localTaxa.length > 0 ) {
+ return {
+ taxa: localTaxa,
+ refetch: () => undefined,
+ isLoading: false,
+ isLocal: true
+ };
+ }
+
+ // Still loading or no results
return {
- taxa: iconicTaxa,
- refetch: ( ) => undefined,
- isLoading: false,
+ taxa: isLoading
+ ? []
+ : localTaxa || [],
+ refetch,
+ isLoading,
isLocal: false
};
- }
-
- // Show local taxa if available
- if ( localTaxa !== null && localTaxa.length > 0 ) {
- return {
- taxa: localTaxa,
- refetch: ( ) => undefined,
- isLoading: false,
- isLocal: true
- };
- }
-
- return {
- taxa: remoteTaxa,
- refetch,
- isLoading,
- isLocal: false
- };
+ }, [taxonQuery, remoteTaxa, localTaxa, iconicTaxa, refetch, isLoading] );
};
export default useTaxonSearch;
diff --git a/src/stores/createExploreSlice.ts b/src/stores/createExploreSlice.ts
index 7d247683a..10b3a7b19 100644
--- a/src/stores/createExploreSlice.ts
+++ b/src/stores/createExploreSlice.ts
@@ -1,37 +1,17 @@
import { StateCreator } from "zustand";
-const DELTA = 0.2;
-
-export const initialMapRegion = {
- latitude: 0.0,
- longitude: 0.0,
- latitudeDelta: DELTA,
- longitudeDelta: DELTA
-};
-
const DEFAULT_STATE = {
- exploreView: "species",
- mapRegion: initialMapRegion
+ exploreView: "observations"
};
-interface MapRegion {
- latitude: number,
- longitude: number,
- latitudeDelta: number,
- longitudeDelta: number
-}
-
interface ExploreSlice {
exploreView: string,
- setExploreView: ( _view: string ) => void,
- mapRegion: MapRegion,
- setMapRegion: ( _region: MapRegion ) => void
+ setExploreView: ( _view: string ) => void
}
const createExploreSlice: StateCreator = set => ( {
...DEFAULT_STATE,
- setExploreView: exploreView => set( ( ) => ( { exploreView } ) ),
- setMapRegion: mapRegion => set( ( ) => ( { mapRegion } ) )
+ setExploreView: exploreView => set( ( ) => ( { exploreView } ) )
} );
export default createExploreSlice;
diff --git a/src/stores/createRootExploreSlice.ts b/src/stores/createRootExploreSlice.ts
index fe193067a..c78a82a21 100644
--- a/src/stores/createRootExploreSlice.ts
+++ b/src/stores/createRootExploreSlice.ts
@@ -1,41 +1,21 @@
import { StateCreator } from "zustand";
-const DELTA = 0.2;
-
-export const initialMapRegion = {
- latitude: 0.0,
- longitude: 0.0,
- latitudeDelta: DELTA,
- longitudeDelta: DELTA
-};
-
const DEFAULT_STATE = {
rootStoredParams: {},
- rootExploreView: "species",
- rootMapRegion: initialMapRegion
+ rootExploreView: "observations"
};
-interface MapRegion {
- latitude: number,
- longitude: number,
- latitudeDelta: number,
- longitudeDelta: number
-}
-
interface RootExploreSlice {
rootStoredParams: Object,
setRootStoredParams: ( _params: Object ) => void,
rootExploreView: string,
- setRootExploreView: ( _view: string ) => void,
- rootMapRegion: MapRegion,
- setRootMapRegion: ( _region: MapRegion ) => void
+ setRootExploreView: ( _view: string ) => void
}
const createRootExploreSlice: StateCreator = set => ( {
...DEFAULT_STATE,
setRootStoredParams: rootStoredParams => set( ( ) => ( { rootStoredParams } ) ),
- setRootExploreView: rootExploreView => set( ( ) => ( { rootExploreView } ) ),
- setRootMapRegion: rootMapRegion => set( ( ) => ( { rootMapRegion } ) )
+ setRootExploreView: rootExploreView => set( ( ) => ( { rootExploreView } ) )
} );
export default createRootExploreSlice;
diff --git a/tests/integration/Explore.test.js b/tests/integration/Explore.test.js
index ce8fe54e3..7cdb952cf 100644
--- a/tests/integration/Explore.test.js
+++ b/tests/integration/Explore.test.js
@@ -2,8 +2,7 @@ import {
fireEvent,
screen,
userEvent,
- waitFor,
- within
+ waitFor
} from "@testing-library/react-native";
import ExploreContainer from "components/Explore/ExploreContainer";
import inatjs from "inaturalistjs";
@@ -36,24 +35,25 @@ beforeAll( ( ) => {
jest.useFakeTimers( );
} );
-const switchToObservationsView = async ( ) => {
+const switchToSpeciesView = async ( ) => {
+ const observationsViewIcon = await screen.findByLabelText( /Observations View/ );
+ expect( observationsViewIcon ).toBeVisible( );
+ await actor.press( observationsViewIcon );
+ const speciesRadioButton = await screen.findByText( "Species" );
+ await actor.press( speciesRadioButton );
+ const confirmButton = await screen.findByText( /EXPLORE SPECIES/ );
+ await actor.press( confirmButton );
const speciesViewIcon = await screen.findByLabelText( /Species View/ );
expect( speciesViewIcon ).toBeVisible( );
- await actor.press( speciesViewIcon );
- const observationsRadioButton = await screen.findByText( "Observations" );
- await actor.press( observationsRadioButton );
- const bottomSheet = await screen.findByTestId( "ExploreObsViewSheet" );
- const confirmButton = await within( bottomSheet ).findByText( /EXPLORE OBSERVATIONS/ );
- expect( confirmButton ).toBeVisible( );
- await actor.press( confirmButton );
- const obsTaxonNameElt = await screen.findByText( mockRemoteObservation.taxon.name );
- expect( obsTaxonNameElt ).toBeTruthy( );
};
describe( "Explore", ( ) => {
- it( "should render species view and switch to observations view list correctly", async ( ) => {
+ it( "should render observations view list correctly on page load", async ( ) => {
renderAppWithComponent( );
- await switchToObservationsView( );
+ const observationsViewIcon = await screen.findByLabelText( /Observations View/ );
+ expect( observationsViewIcon ).toBeVisible( );
+ const obsTaxonNameElt = await screen.findByText( mockRemoteObservation.taxon.name );
+ expect( obsTaxonNameElt ).toBeTruthy( );
expect(
await screen.findByTestId( `ObsStatus.${mockRemoteObservation.uuid}` )
).toBeTruthy( );
@@ -62,9 +62,13 @@ describe( "Explore", ( ) => {
).toBeFalsy( );
} );
+ it( "should switch to species view list correctly", async ( ) => {
+ renderAppWithComponent( );
+ await switchToSpeciesView( );
+ } );
+
it( "should display observations view grid correctly", async ( ) => {
renderAppWithComponent( );
- await switchToObservationsView( );
expect(
await screen.findByTestId( "SegmentedButton.grid" )
).toBeTruthy( );
@@ -77,9 +81,12 @@ describe( "Explore", ( ) => {
).toBeFalsy( );
} );
- it( "should trigger new observation fetch on pull-to-refresh", async ( ) => {
+ it( "should trigger new observation fetch on pull-to-refresh in list view", async ( ) => {
renderAppWithComponent( );
- await switchToObservationsView( );
+ expect(
+ await screen.findByTestId( "SegmentedButton.list" )
+ ).toBeTruthy( );
+ fireEvent.press( await screen.findByTestId( "SegmentedButton.list" ) );
const exploreObsList = await screen.findByTestId( "ExploreObservationsAnimatedList" );
@@ -97,7 +104,6 @@ describe( "Explore", ( ) => {
it( "should trigger new observation fetch when filters change", async ( ) => {
renderAppWithComponent( );
- await switchToObservationsView( );
// Clear the mock so we can make sure it gets called again
inatjs.observations.search.mockClear( );
diff --git a/tests/integration/navigation/Explore.test.js b/tests/integration/navigation/Explore.test.js
index d4fe6fa3a..3ec6275c1 100644
--- a/tests/integration/navigation/Explore.test.js
+++ b/tests/integration/navigation/Explore.test.js
@@ -116,6 +116,23 @@ async function navigateToRootExplore( ) {
await actor.press( exploreButton );
}
+const landOnObservationsView = async ( ) => {
+ const observationsViewIcon = await screen.findByLabelText( /Observations View/ );
+ expect( observationsViewIcon ).toBeVisible( );
+};
+
+const switchToSpeciesView = async ( ) => {
+ const observationsViewIcon = await screen.findByLabelText( /Observations View/ );
+ expect( observationsViewIcon ).toBeVisible( );
+ await actor.press( observationsViewIcon );
+ const speciesRadioButton = await screen.findByText( "Species" );
+ await actor.press( speciesRadioButton );
+ const confirmButton = await screen.findByText( /EXPLORE SPECIES/ );
+ await actor.press( confirmButton );
+ const speciesViewIcon = await screen.findByLabelText( /Species View/ );
+ expect( speciesViewIcon ).toBeVisible( );
+};
+
describe( "logged in", ( ) => {
beforeEach( async ( ) => {
await signIn( mockUser, { realm: global.mockRealms[__filename] } );
@@ -240,8 +257,8 @@ describe( "logged in", ( ) => {
it( "should navigate from TaxonDetails to Explore and back to TaxonDetails", async ( ) => {
renderApp( );
await navigateToRootExplore( );
- const speciesViewIcon = await screen.findByLabelText( /Species View/ );
- expect( speciesViewIcon ).toBeVisible( );
+ await landOnObservationsView( );
+ await switchToSpeciesView( );
const firstTaxon = await screen.findByTestId( `TaxonGridItem.Pressable.${mockTaxon.id}` );
await actor.press( firstTaxon );
const taxonDetailsExploreButton = await screen.findByLabelText( /See observations of this taxon in explore/ );
@@ -263,12 +280,7 @@ describe( "logged in", ( ) => {
inatjs.observations.fetch.mockResolvedValue( makeResponse( mockObservations ) );
renderApp( );
await navigateToRootExplore( );
- const speciesViewIcon = await screen.findByLabelText( /Species View/ );
- await actor.press( speciesViewIcon );
- const observationsRadioButton = await screen.findByText( "Observations" );
- await actor.press( observationsRadioButton );
- const confirmButton = await screen.findByText( /EXPLORE OBSERVATIONS/ );
- await actor.press( confirmButton );
+ await landOnObservationsView( );
const headerCount = await screen.findByText( /1 Observation/ );
expect( headerCount ).toBeVisible( );
const gridView = await screen.findByTestId( "SegmentedButton.grid" );
@@ -312,8 +324,7 @@ describe( "logged in", ( ) => {
renderApp( );
await navigateToRootExplore( );
- const speciesViewIcon = await screen.findByLabelText( /Species View/ );
- expect( speciesViewIcon ).toBeVisible( );
+ await landOnObservationsView( );
const defaultNearbyLocationText = await screen.findByText( /Nearby/ );
expect( defaultNearbyLocationText ).toBeVisible( );
const backButton = screen.queryByTestId( "Explore.BackButton" );
diff --git a/tests/unit/components/Explore/MapView.test.js b/tests/unit/components/Explore/MapView.test.js
index 98ac46dd4..08da149c4 100644
--- a/tests/unit/components/Explore/MapView.test.js
+++ b/tests/unit/components/Explore/MapView.test.js
@@ -1,87 +1,140 @@
+/* eslint-disable react/jsx-props-no-spreading */
import { screen, userEvent } from "@testing-library/react-native";
-import * as useMapLocation from "components/Explore/hooks/useMapLocation.ts";
import MapView from "components/Explore/MapView.tsx";
-import { ExploreProvider } from "providers/ExploreContext.tsx";
+import { EXPLORE_ACTION, ExploreProvider } from "providers/ExploreContext.tsx";
import React from "react";
import factory from "tests/factory";
import { renderComponent } from "tests/helpers/render";
-const mockData = { };
-jest.mock( "sharedHooks/useAuthenticatedQuery", () => ( {
- __esModule: true,
- default: () => ( {
- data: mockData
- } )
-} ) );
+// Mock the useExplore hook with a mock dispatch function
+const mockDispatch = jest.fn( );
+const mockDefaultExploreLocation = jest.fn( ).mockResolvedValue( {
+ lat: 10,
+ lng: 20
+} );
-const mockRedoSearch = jest.fn( );
-jest.mock( "components/Explore/hooks/useMapLocation", () => ( {
- __esModule: true,
- default: ( ) => ( {
- showMapBoundaryButton: false,
- redoSearchInMapArea: mockRedoSearch
- } )
-} ) );
+// Create a mock implementation of the ExploreContext
+jest.mock( "providers/ExploreContext.tsx", ( ) => {
+ const originalModule = jest.requireActual( "providers/ExploreContext.tsx" );
+ return {
+ __esModule: true,
+ ...originalModule,
+ useExplore: ( ) => ( {
+ state: {
+ lat: 10,
+ lng: 20,
+ placeMode: originalModule.PLACE_MODE.NEARBY,
+ place: null
+ },
+ dispatch: mockDispatch,
+ defaultExploreLocation: mockDefaultExploreLocation
+ } )
+ };
+} );
-jest.mock( "sharedHooks/useLocationPermission.tsx", () => ( {
- __esModule: true,
- default: ( ) => ( {
- hasPermissions: true,
- renderPermissionsGate: jest.fn(),
- requestPermissions: jest.fn()
- } )
-} ) );
+const mockObservationBounds = {
+ swlat: 10,
+ swlng: 20,
+ nelat: 30,
+ nelng: 40
+};
+
+const mockRequestLocationPermissions = jest.fn( );
+
+const defaultProps = {
+ observationBounds: mockObservationBounds,
+ queryParams: {
+ taxon_id: 1,
+ return_bounds: true
+ },
+ isLoading: false,
+ hasLocationPermissions: true,
+ renderLocationPermissionsGate: jest.fn( ),
+ requestLocationPermissions: mockRequestLocationPermissions
+};
const mockObservations = [
factory( "RemoteObservation" ),
factory( "RemoteObservation" )
];
-function renderMapView() {
- renderComponent( );
+function renderMapView( ) {
+ renderComponent( );
}
-describe( "MapView", () => {
- beforeAll( async ( ) => {
- // userEvent recommends fake timers
- jest.useFakeTimers( );
+const actor = userEvent.setup( );
+
+describe( "MapView", ( ) => {
+ beforeEach( () => {
+ jest.useFakeTimers();
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
+ jest.clearAllTimers();
} );
it( "should be accessible", ( ) => {
const exploreMap = (
-
+
);
expect( exploreMap ).toBeAccessible( );
} );
- it( "should hide redo search button by default", async ( ) => {
- renderMapView();
+ it( "should hide redo search button by default", ( ) => {
+ renderMapView( );
+
const redoSearchButton = screen.queryByText( /REDO SEARCH IN MAP AREA/ );
expect( redoSearchButton ).toBeFalsy( );
} );
- it( "should render redo search button", async ( ) => {
- jest.spyOn( useMapLocation, "default" )
- .mockImplementation( ( ) => ( { showMapBoundaryButton: true } ) );
- renderMapView();
- const redoSearchButton = screen.queryByText( /REDO SEARCH IN MAP AREA/ );
- expect( redoSearchButton ).toBeVisible( );
+ it( "should dispatch SET_EXPLORE_LOCATION when current location button is pressed", async ( ) => {
+ renderMapView( );
+
+ const currentLocationButton = screen.getByTestId( "Map.CurrentLocationButton" );
+ await actor.press( currentLocationButton );
+
+ await Promise.resolve( );
+ jest.runAllTimers( );
+
+ expect( mockDefaultExploreLocation ).toHaveBeenCalled( );
+
+ expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: EXPLORE_ACTION.SET_EXPLORE_LOCATION,
+ exploreLocation: { lat: 10, lng: 20 }
+ } );
} );
- it( "should update map boundaries when redo search is pressed", async ( ) => {
- jest.spyOn( useMapLocation, "default" )
- .mockImplementation( ( ) => ( {
- showMapBoundaryButton: true,
- redoSearchInMapArea: mockRedoSearch
- } ) );
- renderMapView();
+ it( "should dispatch requestLocationPermissions when current location button "
+ + " is pressed and user has not given permissions", async ( ) => {
+ renderComponent(
+
+
+
+ );
- const actor = await userEvent.setup( );
- const redoSearchButton = screen.queryByText( /REDO SEARCH IN MAP AREA/ );
- await actor.press( redoSearchButton );
+ const currentLocationButton = screen.getByTestId( "Map.CurrentLocationButton" );
+ await actor.press( currentLocationButton );
- expect( mockRedoSearch ).toHaveBeenCalled( );
+ await Promise.resolve( );
+ jest.runAllTimers( );
+
+ expect( mockRequestLocationPermissions ).toHaveBeenCalled( );
+ } );
+
+ it( "should show loading indicator when isLoading is true", ( ) => {
+ renderComponent(
+
+
+
+ );
+
+ const loadingIndicator = screen.getByTestId( "activity-indicator" );
+ expect( loadingIndicator ).toBeTruthy( );
} );
} );