Revert "Maintenance: break upload code into smaller modules -- realm syncing functions (#2872)"

This reverts commit 43f9520b86.
This commit is contained in:
Johannes Klein
2025-04-29 16:53:25 +02:00
parent 43f9520b86
commit 844629bbf4
20 changed files with 487 additions and 373 deletions

View File

@@ -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" && (

View File

@@ -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

View File

@@ -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 (
<View className="flex-1 overflow-hidden h-full">
<View className="z-10">
{showMapBoundaryButton && (
{showRedoSearchButton && (
<View
className="mx-auto"
style={DROP_SHADOW}
@@ -84,28 +205,31 @@ const MapView = ( {
text={t( "REDO-SEARCH-IN-MAP-AREA" )}
level="focus"
className="top-[60px] absolute self-center"
onPress={redoSearchInMapArea}
onPress={handleRedoSearch}
/>
</View>
)}
</View>
{isLoading
? <View className="h-full flex justify-center"><ActivityIndicator size={50} /></View>
: (
<Map
currentLocationButtonClassName="left-5 bottom-20"
onPanDrag={onPanDrag}
onRegionChangeComplete={updateMapBoundaries}
region={region}
showCurrentLocationButton
showSwitchMapTypeButton
showsCompass={false}
switchMapTypeButtonClassName="left-20 bottom-20"
showsUserLocation
tileMapParams={tileMapParams}
withPressableObsTiles={tileMapParams !== null}
/>
)}
<Map
ref={mapRef}
currentLocationButtonClassName="left-5 bottom-20"
onPanDrag={handlePanDrag}
initialRegion={initialRegion}
showCurrentLocationButton
showSwitchMapTypeButton
showsCompass={false}
switchMapTypeButtonClassName="left-20 bottom-20"
showsUserLocation
tileMapParams={tileMapParams}
withPressableObsTiles={tileMapParams !== null}
onCurrentLocationPress={handleCurrentLocationPress}
/>
{isLoading && (
<View style={centeredLoadingWheel} testID="activity-indicator">
<ActivityIndicator size={activityIndicatorSize} />
</View>
)}
{renderLocationPermissionsGate( { onPermissionGranted: handleCurrentLocationPress } )}
</View>
);
};

View File

@@ -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}
/>
</View>
);

View File

@@ -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",

View File

@@ -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( {

View File

@@ -52,6 +52,7 @@ const ExploreTaxonSearch = ( {
const renderItem = useCallback( ( { item: taxon, index } ) => (
<TaxonResult
first={index === 0}
fetchRemote={false}
handleTaxonOrEditPress={() => onTaxonSelected( taxon )}
hideInfoButton={hideInfoButton}
onPressInfo={onPressInfo}

View File

@@ -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<MapBoundaries>( );
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;

View File

@@ -309,7 +309,6 @@ const MyObservationsSimple = ( {
: <Announcements isConnected={isConnected} /> )}
/>
<ObservationsViewBar
gridFirst
hideMap
layout={layout}
updateObservationsView={toggleLayout}

View File

@@ -20,6 +20,7 @@ import React, {
import { useTranslation } from "react-i18next";
import {
Image,
Platform,
StatusBar,
useWindowDimensions,
View
@@ -163,7 +164,12 @@ const OnboardingCarousel = ( ) => {
} );
}, [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 (
<ImageBackground
source={require( "images/background/daniel-olah-YNUFtf4qyh0-unsplash.jpg" )}

View File

@@ -2,7 +2,7 @@ import { tailwindFontBold } from "appConstants/fontFamilies.ts";
import classnames from "classnames";
import { ActivityIndicator, Heading4, INatIcon } from "components/SharedComponents";
import { Pressable, View } from "components/styledComponents";
import React, { useEffect, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { AccessibilityRole, GestureResponderEvent, ViewStyle } from "react-native";
import colors from "styles/tailwindColors";
@@ -181,9 +181,7 @@ const Button = ( {
const [isProcessing, setIsProcessing] = useState( false );
const onPressRef = useRef( onPress );
useEffect( ( ) => {
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 );

View File

@@ -31,6 +31,7 @@ const CurrentLocationButton = ( {
style={DROP_SHADOW}
accessibilityLabel={t( "Zoom-to-current-location" )}
onPress={onPress}
testID="Map.CurrentLocationButton"
/>
{renderPermissionsGate( )}
</>

View File

@@ -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 (
<View
style={mapContainerStyle}
@@ -469,7 +484,7 @@ const Map = ( {
onRegionChangeComplete={handleRegionChangeComplete}
onUserLocationChange={handleUserLocationChange}
pitchEnabled={false}
ref={mapViewRef}
ref={setRefs}
region={mapRegion}
rotateEnabled={false}
scrollEnabled={scrollEnabled}
@@ -529,6 +544,6 @@ const Map = ( {
{children}
</View>
);
};
} );
export default Map;

View File

@@ -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<ReturnType<typeof setTimeout>>();
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
? (
<View className="absolute right-0">
<INatIconButton
@@ -96,6 +113,7 @@ const SearchBar = ( {
onPress={() => {
Keyboard.dismiss();
clearSearch();
setLocalValue( "" );
}}
/>
</View>

View File

@@ -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<RealmTaxon[] | null>( 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;

View File

@@ -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<ExploreSlice> = set => ( {
...DEFAULT_STATE,
setExploreView: exploreView => set( ( ) => ( { exploreView } ) ),
setMapRegion: mapRegion => set( ( ) => ( { mapRegion } ) )
setExploreView: exploreView => set( ( ) => ( { exploreView } ) )
} );
export default createExploreSlice;

View File

@@ -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<RootExploreSlice> = set => ( {
...DEFAULT_STATE,
setRootStoredParams: rootStoredParams => set( ( ) => ( { rootStoredParams } ) ),
setRootExploreView: rootExploreView => set( ( ) => ( { rootExploreView } ) ),
setRootMapRegion: rootMapRegion => set( ( ) => ( { rootMapRegion } ) )
setRootExploreView: rootExploreView => set( ( ) => ( { rootExploreView } ) )
} );
export default createRootExploreSlice;

View File

@@ -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( <ExploreContainer /> );
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( <ExploreContainer /> );
await switchToSpeciesView( );
} );
it( "should display observations view grid correctly", async ( ) => {
renderAppWithComponent( <ExploreContainer /> );
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( <ExploreContainer /> );
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( <ExploreContainer /> );
await switchToObservationsView( );
// Clear the mock so we can make sure it gets called again
inatjs.observations.search.mockClear( );

View File

@@ -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" );

View File

@@ -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( <ExploreProvider><MapView /></ExploreProvider> );
function renderMapView( ) {
renderComponent( <ExploreProvider><MapView {...defaultProps} /></ExploreProvider> );
}
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 = (
<ExploreProvider>
<MapView observations={mockObservations} />
<MapView observations={mockObservations} {...defaultProps} />
</ExploreProvider>
);
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(
<ExploreProvider>
<MapView
{...defaultProps}
hasLocationPermissions={false}
/>
</ExploreProvider>
);
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(
<ExploreProvider>
<MapView {...defaultProps} isLoading />
</ExploreProvider>
);
const loadingIndicator = screen.getByTestId( "activity-indicator" );
expect( loadingIndicator ).toBeTruthy( );
} );
} );