Explore UI (#731)

* Start building explore screen

* Header for Explore screen

* Fix button build

* Move ObsFlashList to shared components for use in Explore

* Fix background transparency on SegmentedButtons

* Use reusable components for map and ObservationLocation

* Use search observations API to show results in explore views

* Add infinite scroll hook to species, observers, and identifiers views

* Update snapshots and create ExploreFlashList for consistent header animation

* Float segmented buttons above observation lists in explore

* Use queryparams for API calls; add loading state to each view
This commit is contained in:
Amanda Bullington
2023-08-08 12:29:17 -07:00
committed by GitHub
parent 5f55285ee4
commit 20d8308e5f
63 changed files with 1943 additions and 797 deletions

View File

Binary file not shown.

View File

@@ -3,7 +3,7 @@
"data": [
{
"path": "assets/fonts/INatIcon.ttf",
"sha1": "94ddb30161ddbd61eda2524a57b2c5eec804401e"
"sha1": "039642e4aed65748aa3285e16527608a4b8c3168"
},
{
"path": "assets/fonts/Whitney-BookItalic-Pro.otf",

View File

Binary file not shown.

View File

@@ -15,8 +15,8 @@
197A169D2A7C2567001A03DC /* cvmodel.mlmodel in Sources */ = {isa = PBXBuildFile; fileRef = 197A169B2A7C2567001A03DC /* cvmodel.mlmodel */; };
197A169E2A7C2567001A03DC /* taxonomy.json in Resources */ = {isa = PBXBuildFile; fileRef = 197A169C2A7C2567001A03DC /* taxonomy.json */; };
374CB22F29943E63005885ED /* Whitney-BookItalic-Pro.otf in Resources */ = {isa = PBXBuildFile; fileRef = 374CB22E29943E63005885ED /* Whitney-BookItalic-Pro.otf */; };
37785BD62A81CC0A0077C2CC /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 37785BD52A81CC090077C2CC /* INatIcon.ttf */; };
4FB3B444D46A4115B867B9CC /* inaturalisticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EE004FD2EC174086A7AB2908 /* inaturalisticons.ttf */; };
763319F15FC44DBF89C9DEB0 /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = A96A8C1FA45F4C8692AAE36F /* INatIcon.ttf */; };
7F29616A6267D5F6EC5F67B7 /* libPods-iNaturalistReactNative.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B935DA49196EBFE90895C8DD /* libPods-iNaturalistReactNative.a */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
8B65ED3129F575C10054CCEF /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8B65ED2F29F575C10054CCEF /* MainInterface.storyboard */; };
@@ -76,6 +76,7 @@
197A169C2A7C2567001A03DC /* taxonomy.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = taxonomy.json; sourceTree = "<group>"; };
19A5877328F8E3310016D128 /* iNaturalistReactNative-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "iNaturalistReactNative-Bridging-Header.h"; sourceTree = "<group>"; };
374CB22E29943E63005885ED /* Whitney-BookItalic-Pro.otf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Whitney-BookItalic-Pro.otf"; path = "../assets/fonts/Whitney-BookItalic-Pro.otf"; sourceTree = "<group>"; };
37785BD52A81CC090077C2CC /* INatIcon.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = INatIcon.ttf; path = ../assets/fonts/INatIcon.ttf; sourceTree = "<group>"; };
52305C33CFC262F17ADC692E /* Pods-iNaturalistReactNative.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iNaturalistReactNative.release.xcconfig"; path = "Target Support Files/Pods-iNaturalistReactNative/Pods-iNaturalistReactNative.release.xcconfig"; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = iNaturalistReactNative/LaunchScreen.storyboard; sourceTree = "<group>"; };
8B65ED2B29F575C10054CCEF /* iNaturalistReactNative-ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "iNaturalistReactNative-ShareExtension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -88,7 +89,6 @@
8BF3756EB416D21D28518C7D /* Pods-iNaturalistReactNative.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iNaturalistReactNative.debug.xcconfig"; path = "Target Support Files/Pods-iNaturalistReactNative/Pods-iNaturalistReactNative.debug.xcconfig"; sourceTree = "<group>"; };
8FE03BB92A5EFCB2001B35BA /* small_inception_tf1.mlmodel */ = {isa = PBXFileReference; lastKnownFileType = file.mlmodel; path = small_inception_tf1.mlmodel; sourceTree = "<group>"; };
8FE03BBA2A5EFCB2001B35BA /* small_export_tax.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = small_export_tax.json; sourceTree = "<group>"; };
A96A8C1FA45F4C8692AAE36F /* INatIcon.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = INatIcon.ttf; path = ../assets/fonts/INatIcon.ttf; sourceTree = "<group>"; };
ADBDD0D061046941F61CA31D /* libPods-iNaturalistReactNative-ShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-iNaturalistReactNative-ShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; };
B935DA49196EBFE90895C8DD /* libPods-iNaturalistReactNative.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-iNaturalistReactNative.a"; sourceTree = BUILT_PRODUCTS_DIR; };
BA9D41ECEBFA4C38B74009B3 /* Whitney-Light-Pro.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Whitney-Light-Pro.otf"; path = "../assets/fonts/Whitney-Light-Pro.otf"; sourceTree = "<group>"; };
@@ -234,9 +234,9 @@
children = (
BA9D41ECEBFA4C38B74009B3 /* Whitney-Light-Pro.otf */,
D09FA3A0162844FF80A5EF96 /* Whitney-Medium-Pro.otf */,
37785BD52A81CC090077C2CC /* INatIcon.ttf */,
374CB22E29943E63005885ED /* Whitney-BookItalic-Pro.otf */,
EE004FD2EC174086A7AB2908 /* inaturalisticons.ttf */,
A96A8C1FA45F4C8692AAE36F /* INatIcon.ttf */,
);
name = Resources;
sourceTree = "<group>";
@@ -369,7 +369,7 @@
A252B2AEA64E47C9AC1D20E8 /* Whitney-Light-Pro.otf in Resources */,
BA2479FA3D7B40A7BEF7B3CD /* Whitney-Medium-Pro.otf in Resources */,
4FB3B444D46A4115B867B9CC /* inaturalisticons.ttf in Resources */,
763319F15FC44DBF89C9DEB0 /* INatIcon.ttf in Resources */,
37785BD62A81CC0A0077C2CC /* INatIcon.ttf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -20,6 +20,17 @@
<string>0.8.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>inaturalistmobile</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>50</string>
<key>ITSAppUsesNonExemptEncryption</key>
@@ -71,16 +82,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>inaturalistmobile</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
"data": [
{
"path": "assets/fonts/INatIcon.ttf",
"sha1": "94ddb30161ddbd61eda2524a57b2c5eec804401e"
"sha1": "039642e4aed65748aa3285e16527608a4b8c3168"
},
{
"path": "assets/fonts/Whitney-BookItalic-Pro.otf",

16
package-lock.json generated
View File

@@ -57,7 +57,6 @@
"react-native-circular-progress-indicator": "^4.4.2",
"react-native-config": "1.5.0",
"react-native-device-info": "^10.6.0",
"react-native-dropdown-picker": "^5.4.6",
"react-native-email-link": "^1.14.5",
"react-native-event-listeners": "^1.0.7",
"react-native-exception-handler": "^2.10.10",
@@ -22878,15 +22877,6 @@
"react-native": "*"
}
},
"node_modules/react-native-dropdown-picker": {
"version": "5.4.6",
"resolved": "https://registry.npmjs.org/react-native-dropdown-picker/-/react-native-dropdown-picker-5.4.6.tgz",
"integrity": "sha512-T1XBHbE++M6aRU3wFYw3MvcOuabhWZ29RK/Ivdls2r1ZkZ62iEBZknLUPeVLMX3x6iUxj4Zgr3X2DGlEGXeHsA==",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-email-link": {
"version": "1.14.5",
"resolved": "https://registry.npmjs.org/react-native-email-link/-/react-native-email-link-1.14.5.tgz",
@@ -43907,12 +43897,6 @@
"integrity": "sha512-/MmINdojWdw2/9rwYpH/dX+1gFP0o78p8yYPjwxiPhoySSL2rZaNi+Mq9VwC+zFi/yQmJUvHntkKSw2KUc7rFw==",
"requires": {}
},
"react-native-dropdown-picker": {
"version": "5.4.6",
"resolved": "https://registry.npmjs.org/react-native-dropdown-picker/-/react-native-dropdown-picker-5.4.6.tgz",
"integrity": "sha512-T1XBHbE++M6aRU3wFYw3MvcOuabhWZ29RK/Ivdls2r1ZkZ62iEBZknLUPeVLMX3x6iUxj4Zgr3X2DGlEGXeHsA==",
"requires": {}
},
"react-native-email-link": {
"version": "1.14.5",
"resolved": "https://registry.npmjs.org/react-native-email-link/-/react-native-email-link-1.14.5.tgz",

View File

@@ -76,7 +76,6 @@
"react-native-circular-progress-indicator": "^4.4.2",
"react-native-config": "1.5.0",
"react-native-device-info": "^10.6.0",
"react-native-dropdown-picker": "^5.4.6",
"react-native-email-link": "^1.14.5",
"react-native-event-listeners": "^1.0.7",
"react-native-exception-handler": "^2.10.10",

View File

@@ -124,13 +124,40 @@ const deleteObservation = async ( params: Object = {}, opts: Object = {} ) : Pro
}
};
const fetchObservers = async ( params: Object = {} ) : Promise<?any> => {
try {
return await inatjs.observations.observers( params );
} catch ( e ) {
return handleError( e );
}
};
const fetchIdentifiers = async ( params: Object = {} ) : Promise<?any> => {
try {
return await inatjs.observations.identifiers( params );
} catch ( e ) {
return handleError( e );
}
};
const fetchSpeciesCounts = async ( params: Object = {} ) : Promise<?any> => {
try {
return await inatjs.observations.speciesCounts( params );
} catch ( e ) {
return handleError( e );
}
};
export {
createObservation,
createOrUpdateEvidence,
deleteObservation,
faveObservation,
fetchIdentifiers,
fetchObservationUpdates,
fetchObservers,
fetchRemoteObservation,
fetchSpeciesCounts,
markAsReviewed,
markObservationUpdatesViewed,
searchObservations,

View File

@@ -1,14 +1,204 @@
// @flow
import PlaceholderText from "components/PlaceholderText";
import ViewWrapper from "components/SharedComponents/ViewWrapper";
import {
BottomSheet,
Button,
ViewWrapper
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import React, { useEffect, useRef, useState } from "react";
import { Animated, Platform } from "react-native";
import { useDeviceOrientation, useTranslation } from "sharedHooks";
const Explore = (): Node => (
<ViewWrapper>
<PlaceholderText text="explore placeholder, accessible from left side menu" />
</ViewWrapper>
);
import Header from "./Header";
import IdentifiersView from "./IdentifiersView";
import ObservationsView from "./ObservationsView";
import ObservationsViewBar from "./ObservationsViewBar";
import ObserversView from "./ObserversView";
import SpeciesView from "./SpeciesView";
const { diffClamp } = Animated;
type Props = {
exploreParams: Object,
region: Object,
exploreView: string,
changeExploreView: Function,
updateTaxon: Function,
updatePlace: Function,
updatePlaceName: Function,
updateTaxonName: Function
}
const Explore = ( {
exploreParams,
region,
exploreView,
changeExploreView,
updateTaxon,
updatePlace,
updatePlaceName,
updateTaxonName
}: Props ): Node => {
const {
isTablet,
screenHeight,
screenWidth
} = useDeviceOrientation( );
const { t } = useTranslation( );
const [showExploreBottomSheet, setShowExploreBottomSheet] = useState( false );
const [headerRight, setHeaderRight] = useState( null );
const [observationsView, setObservationsView] = useState( "map" );
const [heightAboveFilters, setHeightAboveFilters] = useState( 0 );
const [hideHeaderCard, setHideHeaderCard] = useState( false );
const [yValue, setYValue] = useState( 0 );
// basing collapsible sticky header code off the example in this article
// https://medium.com/swlh/making-a-collapsible-sticky-header-animations-with-react-native-6ad7763875c3
const scrollY = useRef( new Animated.Value( 0 ) );
// On Android, the scroll view offset is a double (not an integer), and interpolation shouldn't be
// one-to-one, which causes a jittery header while slow scrolling (see issue #634).
// See here as well: https://stackoverflow.com/a/60898411/1233767
const scrollYClamped = diffClamp(
scrollY.current,
0,
heightAboveFilters * 2
);
// Same as comment above (see here: https://stackoverflow.com/a/60898411/1233767)
const offsetForHeader = scrollYClamped.interpolate( {
inputRange: [0, heightAboveFilters * 2],
// $FlowIgnore
outputRange: [0, -heightAboveFilters]
} );
const exploreViewText = {
observations: t( "OBSERVATIONS" ),
species: t( "SPECIES" ),
observers: t( "OBSERVERS" ),
identifiers: t( "IDENTIFIERS" )
};
useEffect( ( ) => {
if ( exploreView === "observations" ) {
setHeaderRight( null );
}
}, [exploreView] );
const handleScroll = Animated.event(
[
{
nativeEvent: {
contentOffset: { y: scrollY.current }
}
}
],
{
listener: ( { nativeEvent } ) => {
const { y } = nativeEvent.contentOffset;
const hide = yValue < y;
// there's likely a better way to do this, but for now fading out
// the content that goes under the status bar / safe area notch on iOS
if ( Platform.OS !== "ios" ) { return; }
if ( hide !== hideHeaderCard ) {
setHideHeaderCard( hide );
setYValue( y );
}
},
useNativeDriver: true
}
);
const queryParams = { ...exploreParams };
delete queryParams.taxon_name;
return (
<>
<ViewWrapper testID="Explore">
<View className="overflow-hidden">
{exploreView === "observations" && (
<ObservationsViewBar
observationsView={observationsView}
updateObservationsView={newView => setObservationsView( newView )}
/>
)}
<Animated.View
style={[
{
transform: [{ translateY: offsetForHeader }],
height: isTablet
? screenHeight
: Math.max( screenWidth, screenHeight )
}
]}
>
<Header
region={region}
setShowExploreBottomSheet={setShowExploreBottomSheet}
exploreViewButtonText={exploreViewText[exploreView]}
headerRight={headerRight}
setHeightAboveFilters={setHeightAboveFilters}
updateTaxon={updateTaxon}
updatePlace={updatePlace}
updatePlaceName={updatePlaceName}
exploreParams={exploreParams}
updateTaxonName={updateTaxonName}
/>
{exploreView === "observations" && (
<ObservationsView
region={region}
exploreParams={exploreParams}
handleScroll={handleScroll}
observationsView={observationsView}
/>
)}
{exploreView === "species" && (
<SpeciesView
setHeaderRight={setHeaderRight}
handleScroll={handleScroll}
queryParams={queryParams}
/>
)}
{exploreView === "observers" && (
<ObserversView
setHeaderRight={setHeaderRight}
handleScroll={handleScroll}
queryParams={queryParams}
/>
)}
{exploreView === "identifiers" && (
<IdentifiersView
setHeaderRight={setHeaderRight}
handleScroll={handleScroll}
queryParams={queryParams}
/>
)}
</Animated.View>
</View>
</ViewWrapper>
{showExploreBottomSheet && (
<BottomSheet
headerText={t( "EXPLORE" )}
>
{Object.keys( exploreViewText ).map( view => (
<Button
text={exploreViewText[view]}
key={exploreViewText[view]}
className="mx-5 my-3"
onPress={( ) => {
changeExploreView( view );
setShowExploreBottomSheet( false );
}}
/>
) )}
</BottomSheet>
)}
</>
);
};
export default Explore;

View File

@@ -0,0 +1,171 @@
// @flow
import type { Node } from "react";
import React, { useEffect, useReducer } from "react";
import { useUserLocation } from "sharedHooks";
import Explore from "./Explore";
const DELTA = 0.2;
const initialState = {
region: {
latitude: 0.0,
longitude: 0.0,
latitudeDelta: DELTA,
longitudeDelta: DELTA,
place_guess: ""
},
exploreParams: {
taxon_id: 1,
lat: 0.0,
lng: 0.0,
radius: 50,
taxon_name: "Animals"
},
exploreView: "observations"
};
const reducer = ( state, action ) => {
switch ( action.type ) {
case "SET_LOCATION":
return {
...state,
region: action.region,
exploreParams: {
...state.exploreParams,
lat: action.region.latitude,
lng: action.region.longitude,
radius: 50
}
};
case "CHANGE_EXPLORE_VIEW":
return {
...state,
exploreView: action.exploreView
};
case "CHANGE_TAXON":
return {
...state,
exploreParams: {
...state.exploreParams,
taxon_id: action.taxonId,
taxon_name: action.taxonName
}
};
case "CHANGE_PLACE_ID":
return {
...state,
exploreParams: {
...state.exploreParams,
lat: null,
lng: null,
radius: null,
place_id: action.placeId
},
region: action.region
};
case "SET_PLACE_NAME":
return {
...state,
region: {
...state.region,
place_guess: action.placeName
}
};
case "SET_TAXON_NAME":
return {
...state,
exploreParams: {
...state.exploreParams,
taxon_name: action.taxonName
}
};
default:
throw new Error( );
}
};
const ExploreContainer = ( ): Node => {
const { latLng } = useUserLocation( { skipPlaceGuess: false } );
const [state, dispatch] = useReducer( reducer, initialState );
const {
region,
exploreParams,
exploreView
} = state;
useEffect( ( ) => {
if ( region.latitude === 0.0 && latLng?.latitude ) {
dispatch( {
type: "SET_LOCATION",
region: {
...region,
latitude: latLng.latitude,
longitude: latLng.longitude,
place_guess: latLng.place_guess
}
} );
}
}, [latLng, region] );
const changeExploreView = newView => {
dispatch( {
type: "CHANGE_EXPLORE_VIEW",
exploreView: newView
} );
};
const updateTaxon = taxon => {
dispatch( {
type: "CHANGE_TAXON",
taxonId: taxon?.id,
taxonName: taxon?.preferred_common_name || taxon?.name
} );
};
const updatePlace = place => {
const { coordinates } = place.point_geojson;
dispatch( {
type: "CHANGE_PLACE_ID",
placeId: place?.id,
region: {
...state.region,
latitude: coordinates[1],
longitude: coordinates[0],
place_guess: place?.display_name
}
} );
};
const updatePlaceName = newPlaceName => {
dispatch( {
type: "SET_PLACE_NAME",
placeName: newPlaceName
} );
};
const updateTaxonName = newTaxonName => {
dispatch( {
type: "SET_TAXON_NAME",
taxonName: newTaxonName
} );
};
return (
<Explore
exploreParams={exploreParams}
region={region}
exploreView={exploreView}
changeExploreView={changeExploreView}
updateTaxon={updateTaxon}
updatePlace={updatePlace}
updatePlaceName={updatePlaceName}
updateTaxonName={updateTaxonName}
/>
);
};
export default ExploreContainer;

View File

@@ -0,0 +1,89 @@
// @flow
import { FlashList } from "@shopify/flash-list";
import InfiniteScrollLoadingWheel from "components/MyObservations/InfiniteScrollLoadingWheel";
import { Body3 } from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { ActivityIndicator, Animated } from "react-native";
import { useTranslation } from "sharedHooks";
const AnimatedFlashList = Animated.createAnimatedComponent( FlashList );
type Props = {
testID: string,
handleScroll: Function,
isFetchingNextPage: boolean,
data: Array<Object>,
renderItem: Function,
renderItemSeparator?: Function,
fetchNextPage: boolean,
estimatedItemSize: number,
keyExtractor: Function,
layout?: string,
contentContainerStyle?: Object,
numColumns?: number,
status: string
};
const ExploreFlashList = ( {
testID,
handleScroll,
isFetchingNextPage,
data,
renderItem,
renderItemSeparator,
fetchNextPage,
estimatedItemSize,
keyExtractor,
layout,
contentContainerStyle,
numColumns,
status
}: Props ): Node => {
const { t } = useTranslation( );
const renderFooter = ( ) => (
<InfiniteScrollLoadingWheel
isFetchingNextPage={isFetchingNextPage}
layout={layout}
/>
);
if ( !data || data.length === 0 ) {
return (
<View className="flex-1 justify-center items-center">
{status === "loading"
? (
<ActivityIndicator size="large" />
)
: <Body3>{t( "No-results-found" )}</Body3>}
</View>
);
}
return (
<View className="h-full mt-[180px]">
<AnimatedFlashList
contentContainerStyle={contentContainerStyle}
data={data}
estimatedItemSize={estimatedItemSize}
testID={testID}
horizontal={false}
keyExtractor={keyExtractor}
renderItem={renderItem}
ItemSeparatorComponent={renderItemSeparator}
ListFooterComponent={renderFooter}
initialNumToRender={5}
onEndReached={fetchNextPage}
onEndReachedThreshold={1}
refreshing={isFetchingNextPage}
onScroll={handleScroll}
accessible
numColumns={numColumns}
/>
</View>
);
};
export default ExploreFlashList;

View File

@@ -0,0 +1,203 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import fetchSearchResults from "api/search";
import {
Body1,
Body3,
Button,
INatIcon,
INatIconButton
} from "components/SharedComponents";
import SearchBar from "components/SharedComponents/SearchBar";
import { Image, Pressable, View } from "components/styledComponents";
import type { Node } from "react";
import React, { useRef, useState } from "react";
import { Keyboard } from "react-native";
import { Surface, useTheme } from "react-native-paper";
import Taxon from "realmModels/Taxon";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import colors from "styles/tailwindColors";
type Props = {
region: Object,
setShowExploreBottomSheet: Function,
exploreViewButtonText: string,
headerRight?: ?string,
setHeightAboveFilters: Function,
updateTaxon: Function,
updatePlace: Function,
updatePlaceName: Function,
exploreParams: Object,
updateTaxonName: Function
}
const Header = ( {
region, setShowExploreBottomSheet, exploreViewButtonText, headerRight,
setHeightAboveFilters, updateTaxon, updatePlace, updatePlaceName, exploreParams,
updateTaxonName
}: Props ): Node => {
const taxonInput = useRef( );
const placeInput = useRef( );
const placeName = region.place_guess;
const taxonName = exploreParams.taxon_name;
const navigation = useNavigation( );
const theme = useTheme( );
const [hideTaxonResults, setHideTaxonResults] = useState( true );
const [hidePlaceResults, setHidePlaceResults] = useState( true );
const surfaceStyle = {
backgroundColor: theme.colors.onPrimary,
borderBottomLeftRadius: 20,
borderBottomRightRadius: 20
};
const { data: taxonList } = useAuthenticatedQuery(
["fetchSearchResults", taxonName],
optsWithAuth => fetchSearchResults(
{
q: taxonName,
sources: "taxa",
fields: {
taxon: Taxon.TAXON_FIELDS
}
},
optsWithAuth
)
);
const { data: placeList } = useAuthenticatedQuery(
["fetchSearchResults", placeName],
optsWithAuth => fetchSearchResults(
{
q: placeName,
sources: "places",
fields: "place,place.display_name,place.point_geojson"
},
optsWithAuth
)
);
return (
<View className="z-10 top-0 absolute w-full">
<Surface
style={surfaceStyle}
className="h-[175px]"
elevation={5}
>
<View
className="top-[15px] mx-5"
>
<View
className="flex-row justify-between align-center"
onLayout={event => {
const {
height
} = event.nativeEvent.layout;
setHeightAboveFilters( height );
}}
>
<Button
text={exploreViewButtonText}
className="shrink"
dropdown
onPress={( ) => setShowExploreBottomSheet( true )}
/>
{headerRight
? (
<View className="mt-4">
<Body1>{headerRight}</Body1>
</View>
)
: (
<View className="bg-darkGray rounded-full h-[46px] w-[46px]">
<INatIconButton
icon="label"
color={colors.white}
className="self-center"
onPress={( ) => navigation.navigate( "Identify" )}
/>
</View>
)}
</View>
<View className="flex-row items-center">
<INatIcon name="label-outline" size={15} />
<SearchBar
handleTextChange={taxonText => {
if ( taxonInput?.current?.isFocused( ) ) {
setHideTaxonResults( false );
updateTaxonName( taxonText );
}
}}
value={taxonName}
testID="Explore.taxonSearch"
containerClass="w-[250px]"
input={taxonInput}
/>
</View>
<View className="bg-white">
{!hideTaxonResults && taxonList?.map( taxon => (
<Pressable
accessibilityRole="button"
key={taxon.id}
className="p-2 border-[0.5px] border-lightGray flex-row items-center"
onPress={( ) => {
updateTaxon( taxon );
setHideTaxonResults( true );
Keyboard.dismiss( );
}}
>
<Image
source={{ uri: taxon?.default_photo?.url }}
className="w-[25px] h-[25px]"
accessibilityIgnoresInvertColors
/>
<Body3 className="ml-2">{taxon?.preferred_common_name || taxon?.name}</Body3>
</Pressable>
) )}
</View>
<View className="flex-row items-center">
<INatIcon name="location" size={15} />
<SearchBar
handleTextChange={placeText => {
if ( placeInput?.current?.isFocused( ) ) {
setHidePlaceResults( false );
updatePlaceName( placeText );
}
}}
value={placeName}
testID="Explore.placeSearch"
containerClass="w-[250px]"
input={placeInput}
/>
</View>
<View className="bg-white">
{!hidePlaceResults && placeList?.map( place => (
<Pressable
accessibilityRole="button"
key={place.id}
className="p-2 border-[0.5px] border-lightGray flex-row items-center"
onPress={( ) => {
updatePlace( place );
setHidePlaceResults( true );
Keyboard.dismiss( );
}}
>
<Body3 className="ml-2">{place?.display_name}</Body3>
</Pressable>
) )}
</View>
<View className="absolute right-0 bg-darkGray rounded-full" />
<View className="absolute right-0 top-20 bg-darkGray rounded-md">
<INatIconButton
icon="sliders"
color={colors.white}
/>
</View>
</View>
</Surface>
</View>
);
};
export default Header;

View File

@@ -0,0 +1,75 @@
// @flow
import { fetchIdentifiers } from "api/observations";
import UserListItem from "components/SharedComponents/UserListItem";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React, { useEffect } from "react";
import User from "realmModels/User";
import { useInfiniteScroll, useTranslation } from "sharedHooks";
import ExploreFlashList from "./ExploreFlashList";
type Props = {
setHeaderRight: Function,
handleScroll: Function,
queryParams: Object
};
const IdentifiersView = ( {
setHeaderRight,
handleScroll,
queryParams
}: Props ): Node => {
const { t } = useTranslation( );
const {
data,
isFetchingNextPage,
fetchNextPage,
totalResults,
status
} = useInfiniteScroll(
"fetchIdentifiers",
fetchIdentifiers,
{
...queryParams,
fields: {
identifications_count: true,
user: User.USER_FIELDS
}
}
);
const renderItem = ( { item } ) => (
<UserListItem
item={item}
count={item.count}
countText="X-Identifications"
/>
);
const renderItemSeparator = ( ) => <View className="border-b border-lightGray" />;
useEffect( ( ) => {
if ( totalResults ) {
setHeaderRight( t( "X-Identifiers", { count: totalResults } ) );
}
}, [totalResults, setHeaderRight, t] );
return (
<ExploreFlashList
testID="ExploreIdentifiersAnimatedList"
handleScroll={handleScroll}
isFetchingNextPage={isFetchingNextPage}
data={data}
renderItem={renderItem}
renderItemSeparator={renderItemSeparator}
fetchNextPage={fetchNextPage}
estimatedItemSize={98}
keyExtractor={item => item.user.id}
status={status}
/>
);
};
export default IdentifiersView;

View File

@@ -0,0 +1,53 @@
// @flow
import {
Map,
ObservationsFlashList
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { useInfiniteObservationsScroll } from "sharedHooks";
type Props = {
exploreParams: Object,
region: Object,
handleScroll: Function,
observationsView: string
}
const ObservationsView = ( {
exploreParams,
region,
handleScroll,
observationsView
}: Props ): Node => {
const {
observations, isFetchingNextPage, fetchNextPage, status
} = useInfiniteObservationsScroll( { upsert: false, params: exploreParams } );
return observationsView === "map"
? (
<Map
className="h-full"
showsCompass={false}
region={region}
taxonId={exploreParams.taxon_id}
/>
)
: (
<View className="h-full mt-[180px]">
<ObservationsFlashList
isFetchingNextPage={isFetchingNextPage}
layout={observationsView}
data={observations}
onEndReached={fetchNextPage}
testID="ExploreObservationsAnimatedList"
handleScroll={handleScroll}
status={status}
/>
</View>
);
};
export default ObservationsView;

View File

@@ -0,0 +1,72 @@
// @flow
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { SegmentedButtons, useTheme } from "react-native-paper";
import { getShadowStyle } from "styles/global";
import colors from "styles/tailwindColors";
const getShadow = shadowColor => getShadowStyle( {
shadowColor,
offsetWidth: 0,
offsetHeight: 2,
shadowOpacity: 0.25,
shadowRadius: 2,
elevation: 5
} );
type Props = {
observationsView: string,
updateObservationsView: Function
};
const ObservationsViewBar = ( {
observationsView,
updateObservationsView
}: Props ): Node => {
const theme = useTheme( );
const buttonStyle = buttonValue => ( {
minWidth: 55,
backgroundColor: buttonValue === observationsView
? colors.inatGreen
: colors.white
} );
return (
<View
className="bottom-[165px] absolute left-[10px] z-10"
>
<SegmentedButtons
value={observationsView}
onValueChange={updateObservationsView}
theme={{
colors: {
onSecondaryContainer: colors.white
}
}}
style={getShadow( theme.colors.primary )}
buttons={[
{
style: buttonStyle( "map" ),
value: "map",
icon: "map"
},
{
style: buttonStyle( "list" ),
value: "list",
icon: "hamburger-menu"
},
{
style: buttonStyle( "grid" ),
value: "grid",
icon: "grid"
}
]}
/>
</View>
);
};
export default ObservationsViewBar;

View File

@@ -0,0 +1,73 @@
// @flow
import { fetchObservers } from "api/observations";
import UserListItem from "components/SharedComponents/UserListItem";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React, { useEffect } from "react";
import User from "realmModels/User";
import { useInfiniteScroll, useTranslation } from "sharedHooks";
import ExploreFlashList from "./ExploreFlashList";
type Props = {
setHeaderRight: Function,
handleScroll: Function,
queryParams: Object
};
const ObserversView = ( {
setHeaderRight,
handleScroll,
queryParams
}: Props ): Node => {
const { t } = useTranslation( );
const {
data,
isFetchingNextPage,
fetchNextPage,
totalResults,
status
} = useInfiniteScroll(
"fetchObservers",
fetchObservers,
{
...queryParams,
fields: {
user: User.USER_FIELDS
}
}
);
const renderItem = ( { item } ) => (
<UserListItem
item={item}
count={item.observation_count}
countText="X-Observations"
/>
);
const renderItemSeparator = ( ) => <View className="border-b border-lightGray" />;
useEffect( ( ) => {
if ( totalResults ) {
setHeaderRight( t( "X-Observers", { count: totalResults } ) );
}
}, [totalResults, setHeaderRight, t] );
return (
<ExploreFlashList
testID="ExploreObserversAnimatedList"
handleScroll={handleScroll}
isFetchingNextPage={isFetchingNextPage}
data={data}
renderItem={renderItem}
renderItemSeparator={renderItemSeparator}
fetchNextPage={fetchNextPage}
estimatedItemSize={98}
keyExtractor={item => item.user.id}
status={status}
/>
);
};
export default ObserversView;

View File

@@ -0,0 +1,48 @@
// @flow
import { INatIcon } from "components/SharedComponents";
import { View } from "components/styledComponents";
import { RealmContext } from "providers/contexts";
import type { Node } from "react";
import React from "react";
import { useTheme } from "react-native-paper";
import { getShadowStyle } from "styles/global";
const { useRealm } = RealmContext;
const getShadow = shadowColor => getShadowStyle( {
shadowColor,
offsetWidth: 0,
offsetHeight: 2,
shadowOpacity: 0.25,
shadowRadius: 2,
elevation: 5
} );
type Props = {
taxonId: number
};
const SpeciesSeenCheckmark = ( {
taxonId
}: Props ): Node => {
const realm = useRealm( );
const theme = useTheme( );
const userObservation = realm?.objectForPrimaryKey( "Taxon", taxonId );
if ( !userObservation ) { return null; }
return (
<View
className="absolute top-3 left-3 bg-white rounded-full"
style={getShadow( theme.colors.primary )}
>
<INatIcon
name="checkmark-circle"
size={20}
color={theme.colors.secondary}
/>
</View>
);
};
export default SpeciesSeenCheckmark;

View File

@@ -0,0 +1,122 @@
// @flow
import { fetchSpeciesCounts } from "api/observations";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import Taxon from "realmModels/Taxon";
import { BREAKPOINTS } from "sharedHelpers/breakpoint";
import {
useDeviceOrientation, useInfiniteScroll, useTranslation
} from "sharedHooks";
import ExploreFlashList from "./ExploreFlashList";
import TaxonGridItem from "./TaxonGridItem";
const GUTTER = 15;
type Props = {
handleScroll: Function,
setHeaderRight: Function,
queryParams: Object
}
const SpeciesView = ( {
handleScroll,
setHeaderRight,
queryParams
}: Props ): Node => {
const {
isLandscapeMode,
isTablet,
screenHeight,
screenWidth
} = useDeviceOrientation( );
const [numColumns, setNumColumns] = useState( 0 );
const [gridItemWidth, setGridItemWidth] = useState( 0 );
useEffect( ( ) => {
const calculateGridItemWidth = columns => {
const combinedGutter = ( columns + 1 ) * GUTTER;
const gridWidth = isTablet
? screenWidth
: Math.min( screenWidth, screenHeight );
return Math.floor(
( gridWidth - combinedGutter ) / columns
);
};
const calculateNumColumns = ( ) => {
if ( !isTablet ) return 2;
if ( isLandscapeMode ) return 6;
if ( screenWidth <= BREAKPOINTS.xl ) return 2;
return 4;
};
const columns = calculateNumColumns( );
setGridItemWidth( calculateGridItemWidth( columns ) );
setNumColumns( columns );
}, [
isLandscapeMode,
isTablet,
screenHeight,
screenWidth
] );
const { t } = useTranslation( );
const {
data,
isFetchingNextPage,
fetchNextPage,
totalResults,
status
} = useInfiniteScroll(
"fetchSpeciesCounts",
fetchSpeciesCounts,
{
...queryParams,
fields: {
taxon: Taxon.TAXON_FIELDS
}
}
);
const renderItem = ( { item } ) => (
<TaxonGridItem
taxon={item.taxon}
style={{
height: gridItemWidth,
width: gridItemWidth,
margin: GUTTER / 2
}}
/>
);
useEffect( ( ) => {
if ( totalResults ) {
setHeaderRight( t( "X-Species", { count: totalResults } ) );
}
}, [totalResults, setHeaderRight, t] );
const contentContainerStyle = {
paddingLeft: GUTTER / 2,
paddingRight: GUTTER / 2
};
return (
<ExploreFlashList
contentContainerStyle={contentContainerStyle}
testID="ExploreSpeciesAnimatedList"
handleScroll={handleScroll}
isFetchingNextPage={isFetchingNextPage}
data={data}
renderItem={renderItem}
fetchNextPage={fetchNextPage}
estimatedItemSize={gridItemWidth}
keyExtractor={item => item.taxon.id}
layout="grid"
numColumns={numColumns}
status={status}
/>
);
};
export default SpeciesView;

View File

@@ -0,0 +1,51 @@
// @flow
import { DisplayTaxonName } from "components/SharedComponents";
import ObsImagePreview from "components/SharedComponents/ObservationsFlashList/ObsImagePreview";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import Photo from "realmModels/Photo";
import SpeciesSeenCheckmark from "./SpeciesSeenCheckmark";
type Props = {
taxon: Object,
width?: string,
height?: string,
style?: Object
};
const ObsGridItem = ( {
taxon,
width = "w-full",
height,
style
}: Props ): Node => (
<ObsImagePreview
source={{
uri: Photo.displayLocalOrRemoteMediumPhoto(
taxon?.default_photo
)
}}
width={width}
height={height}
style={style}
isMultiplePhotosTop
testID={`TaxonGridItem.${taxon.id}`}
>
<SpeciesSeenCheckmark
taxonId={taxon.id}
/>
<View className="absolute bottom-0 flex p-2 w-full">
<DisplayTaxonName
keyBase={taxon?.id}
taxon={taxon}
layout="vertical"
color="text-white"
/>
</View>
</ObsImagePreview>
);
export default ObsGridItem;

View File

@@ -1,82 +0,0 @@
// @flow
import createIdentification from "api/identifications";
import { markAsReviewed } from "api/observations";
import PlaceholderText from "components/PlaceholderText";
import type { Node } from "react";
import React, { useState } from "react";
import { Image, Text, View } from "react-native";
import TinderCard from "react-tinder-card";
import Observation from "realmModels/Observation";
import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation";
import { imageStyles, textStyles, viewStyles } from "styles/identify/identify";
type Props = {
observationList: Array<Object>,
}
const CardSwipeView = ( { observationList }: Props ): Node => {
const [totalSwiped, setTotalSwiped] = useState( 0 );
const reviewMutation = useAuthenticatedMutation(
( id, optsWithAuth ) => markAsReviewed( { id }, optsWithAuth )
);
const createIdentificationMutation = useAuthenticatedMutation(
( params, optsWithAuth ) => createIdentification( params, optsWithAuth )
);
const onSwipe = async ( direction, id, isSpecies, agreeParams ) => {
if ( direction === "left" ) {
reviewMutation.mutate( id );
} else if ( direction === "right" && isSpecies ) {
createIdentificationMutation.mutate( { identification: agreeParams } );
}
};
const onCardLeftScreen = ( ) => {
setTotalSwiped( totalSwiped + 1 );
// TODO: when total swiped is 30, fetch next page of observations
};
if ( observationList.length === 0 ) {
return null;
}
return (
<View style={viewStyles.cardContainer}>
<PlaceholderText text="Swipe left to mark as reviewed." />
<PlaceholderText text="Swipe right to agree." />
{observationList.map( obs => {
const commonName = obs.taxon && obs.taxon.preferred_common_name;
const name = obs.taxon
? obs.taxon.name
: "unknown";
const isSpecies = obs.taxon && obs.taxon.rank === "species";
const imageUri = Observation.mediumUri( obs );
const preventSwipeDirections = ["up", "down"];
if ( !isSpecies ) {
preventSwipeDirections.push( "right" );
}
const agreeParams = { observation_id: obs.uuid, taxon_id: obs.taxon?.id };
return (
<TinderCard
key={obs.id}
onSwipe={dir => onSwipe( dir, obs.uuid, isSpecies, agreeParams )}
onCardLeftScreen={onCardLeftScreen}
preventSwipe={preventSwipeDirections}
>
<View style={viewStyles.card}>
<Image style={imageStyles.cardImage} source={imageUri} />
{commonName && <Text style={textStyles.commonNameText}>{commonName}</Text>}
<Text style={textStyles.text}>{name}</Text>
</View>
</TinderCard>
);
} )}
</View>
);
};
export default CardSwipeView;

View File

@@ -1,104 +0,0 @@
// @flow
import createIdentification from "api/identifications";
import { Button } from "components/SharedComponents";
import { t } from "i18next";
import type { Node } from "react";
import React, { useState } from "react";
import {
ActivityIndicator, Image, Pressable, Text, View
} from "react-native";
import Observation from "realmModels/Observation";
import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation";
import {
imageStyles,
textStyles,
viewStyles
} from "styles/observations/gridItem";
type Props = {
item: Object,
reviewedIds: Array<number>,
setReviewedIds: Function
}
const GridItem = ( {
item, reviewedIds, setReviewedIds
}: Props ): Node => {
const [showLoadingWheel, setShowLoadingWheel] = useState( false );
const commonName = item.taxon && item.taxon.preferred_common_name;
const name = item.taxon
? item.taxon.name
: "unknown";
const isSpecies = item.taxon && item.taxon.rank === "species";
const wasReviewed = reviewedIds.includes( item.id );
// TODO: fix whatever funkiness is preventing realm mapTo from correctly
// displaying camelcased item keys on ObservationList
// TODO: add fallback image when there is no uri
const imageUri = Observation.projectUri( item );
const createIdentificationMutation = useAuthenticatedMutation(
( params, optsWithAuth ) => createIdentification( params, optsWithAuth ),
{
onSuccess: ( ) => {
const ids = Array.from( reviewedIds );
ids.push( item.id );
setReviewedIds( ids );
}
}
);
const agreeWithObservation = async ( ) => {
setShowLoadingWheel( true );
createIdentificationMutation.mutate( {
identification: {
observation_id: item.uuid,
taxon_id: item.taxon.id
}
} );
setShowLoadingWheel( false );
};
return (
<Pressable
style={[
viewStyles.gridItem,
wasReviewed && viewStyles.markReviewed
]}
testID={`ObsList.gridItem.${item.uuid}`}
accessibilityRole="link"
accessibilityLabel={t( "Navigate-to-observation-details" )}
>
<Image
source={imageUri}
style={imageStyles.gridImage}
testID="ObsList.photo"
/>
<Image
source={{ uri: item?.user?.icon_url }}
style={imageStyles.userImage}
testID="ObsList.identifierPhoto"
/>
{showLoadingWheel && <ActivityIndicator />}
<View style={viewStyles.taxonName}>
<View style={viewStyles.textBox}>
{commonName && <Text style={textStyles.text}>{commonName}</Text>}
<Text style={textStyles.text}>{name}</Text>
</View>
{isSpecies && (
<Button
level="focus"
onPress={agreeWithObservation}
text={t( "Agree" )}
testID="Identify.agree"
disabled={wasReviewed}
accessibilityHint={t( "Agrees-with-identification" )}
/>
)}
</View>
</Pressable>
);
};
export default GridItem;

View File

@@ -1,46 +0,0 @@
// @flow
// import { useNavigation, useRoute } from "@react-navigation/native";
import type { Node } from "react";
import React, { useState } from "react";
import { ActivityIndicator, FlatList } from "react-native";
import GridItem from "./GridItem";
type Props = {
loading: boolean,
observationList: Array<Object>,
testID: string
}
const GridView = ( {
loading,
observationList,
testID
}: Props ): Node => {
const [reviewedIds, setReviewedIds] = useState( [] );
const renderGridItem = ( { item } ) => (
<GridItem
item={item}
reviewedIds={reviewedIds}
setReviewedIds={setReviewedIds}
/>
);
const renderView = ( ) => (
<FlatList
data={observationList}
key={1}
renderItem={renderGridItem}
numColumns={3}
testID={testID}
/>
);
return loading
? <ActivityIndicator />
: renderView( );
};
export default GridView;

View File

@@ -1,66 +1,10 @@
// @flow
import { searchObservations } from "api/observations";
import ViewWrapper from "components/SharedComponents/ViewWrapper";
import PlaceholderComponent from "components/PlaceholderComponent";
import type { Node } from "react";
import React from "react";
import { Pressable, Text, View } from "react-native";
import Observation from "realmModels/Observation";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import useTranslation from "sharedHooks/useTranslation";
import { viewStyles } from "styles/identify/identify";
import CardSwipeView from "./CardSwipeView";
import GridView from "./GridView";
const Identify = (): Node => {
const [view, setView] = React.useState( "grid" );
const searchParams = {
reviewed: false,
fields: Observation.FIELDS
};
const { data: observations, isLoading } = useAuthenticatedQuery(
["searchObservations"],
optsWithAuth => searchObservations( searchParams, optsWithAuth )
);
const setGridView = () => setView( "grid" );
const setCardView = () => setView( "card" );
const renderView = () => {
if ( view === "card" ) {
return <CardSwipeView observationList={observations} />;
}
return (
<GridView
loading={isLoading}
observationList={observations}
testID="Identify.observationGrid"
/>
);
};
const { t } = useTranslation();
return (
<ViewWrapper>
<View style={viewStyles.toggleViewRow}>
<Pressable onPress={setCardView} accessibilityRole="button">
<Text>{t( "Card-View" )}</Text>
</Pressable>
<Pressable
onPress={setGridView}
testID="ObsList.toggleGridView"
accessibilityRole="button"
>
<Text>{t( "Grid-View" )}</Text>
</Pressable>
</View>
{renderView()}
</ViewWrapper>
);
};
const Identify = ( ): Node => (
<PlaceholderComponent />
);
export default Identify;

View File

@@ -10,8 +10,8 @@ import { useIsConnected, useTranslation } from "sharedHooks";
type Props = {
isFetchingNextPage?: boolean,
currentUser: ?Object,
layout: string
currentUser?: ?Object,
layout?: string
}
const InfiniteScrollLoadingWheel = ( {

View File

@@ -1,22 +1,14 @@
// @flow
import { FlashList } from "@shopify/flash-list";
import Header from "components/MyObservations/Header";
import ViewWrapper from "components/SharedComponents/ViewWrapper";
import { ObservationsFlashList, ViewWrapper } from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React, { useEffect, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { Animated, Platform } from "react-native";
import { BREAKPOINTS } from "sharedHelpers/breakpoint";
import { useDeviceOrientation } from "sharedHooks";
import InfiniteScrollLoadingWheel from "./InfiniteScrollLoadingWheel";
import LoginSheet from "./LoginSheet";
import MyObservationsEmpty from "./MyObservationsEmpty";
import MyObservationsPressable from "./MyObservationsPressable";
import ObsGridItem from "./ObsGridItem";
import ObsListItem from "./ObsListItem";
const AnimatedFlashList = Animated.createAnimatedComponent( FlashList );
const { diffClamp } = Animated;
@@ -32,39 +24,6 @@ type Props = {
setShowLoginSheet: Function,
};
const GUTTER = 15;
const Item = React.memo(
( {
observation, layout, gridItemWidth, setShowLoginSheet
} ) => (
<MyObservationsPressable observation={observation}>
{
layout === "grid"
? (
<ObsGridItem
observation={observation}
// 03022023 it seems like Flatlist is designed to work
// better with RN styles than with Tailwind classes
style={{
height: gridItemWidth,
width: gridItemWidth,
margin: GUTTER / 2
}}
setShowLoginSheet={setShowLoginSheet}
/>
)
: (
<ObsListItem
observation={observation}
setShowLoginSheet={setShowLoginSheet}
/>
)
}
</MyObservationsPressable>
)
);
const MyObservations = ( {
isFetchingNextPage,
layout,
@@ -77,14 +36,11 @@ const MyObservations = ( {
setShowLoginSheet
}: Props ): Node => {
const {
isLandscapeMode,
isTablet,
screenHeight,
screenWidth
} = useDeviceOrientation( );
const [heightAboveToolbar, setHeightAboveToolbar] = useState( 0 );
const [numColumns, setNumColumns] = useState( 0 );
const [gridItemWidth, setGridItemWidth] = useState( 0 );
const [hideHeaderCard, setHideHeaderCard] = useState( false );
const [yValue, setYValue] = useState( 0 );
@@ -108,37 +64,7 @@ const MyObservations = ( {
outputRange: [0, -heightAboveToolbar]
} );
useEffect( ( ) => {
const calculateGridItemWidth = columns => {
const combinedGutter = ( columns + 1 ) * GUTTER;
const gridWidth = isTablet
? screenWidth
: Math.min( screenWidth, screenHeight );
return Math.floor(
( gridWidth - combinedGutter ) / columns
);
};
const calculateNumColumns = ( ) => {
if ( layout === "list" || screenWidth <= BREAKPOINTS.md ) {
return 1;
}
if ( !isTablet ) return 2;
if ( isLandscapeMode ) return 6;
if ( screenWidth <= BREAKPOINTS.xl ) return 2;
return 4;
};
const columns = calculateNumColumns( );
setGridItemWidth( calculateGridItemWidth( columns ) );
setNumColumns( columns );
}, [
isLandscapeMode,
isTablet,
layout,
screenHeight,
screenWidth
] );
const renderEmptyList = ( ) => <MyObservationsEmpty isFetchingNextPage={isFetchingNextPage} />;
const handleScroll = Animated.event(
[
@@ -164,42 +90,6 @@ const MyObservations = ( {
}
);
const renderItem = ( { item } ) => (
<Item
observation={item}
layout={layout}
gridItemWidth={gridItemWidth}
allObsToUpload={allObsToUpload}
setShowLoginSheet={setShowLoginSheet}
/>
);
const renderEmptyList = ( ) => <MyObservationsEmpty isFetchingNextPage={isFetchingNextPage} />;
const renderItemSeparator = ( ) => {
if ( layout === "grid" ) {
return null;
}
return <View className="border-b border-lightGray" />;
};
const renderFooter = ( ) => (
<InfiniteScrollLoadingWheel
isFetchingNextPage={isFetchingNextPage}
currentUser={currentUser}
layout={layout}
/>
);
const contentContainerStyle = layout === "list"
? {}
: {
paddingLeft: GUTTER / 2,
paddingRight: GUTTER / 2
};
if ( numColumns === 0 ) { return null; }
return (
<>
<ViewWrapper>
@@ -223,31 +113,17 @@ const MyObservations = ( {
allObsToUpload={allObsToUpload}
setShowLoginSheet={setShowLoginSheet}
/>
<AnimatedFlashList
contentContainerStyle={contentContainerStyle}
data={observations.filter( o => o.isValid() )}
key={layout}
estimatedItemSize={
layout === "grid"
? gridItemWidth
: 98
}
testID="MyObservationsAnimatedList"
numColumns={numColumns}
horizontal={false}
// only used id as a fallback key because after upload
// react thinks we've rendered a second item w/ a duplicate key
keyExtractor={item => item.uuid || item.id}
renderItem={renderItem}
ListEmptyComponent={renderEmptyList}
ItemSeparatorComponent={renderItemSeparator}
ListFooterComponent={renderFooter}
initialNumToRender={5}
<ObservationsFlashList
isFetchingNextPage={isFetchingNextPage}
layout={layout}
onEndReached={onEndReached}
onEndReachedThreshold={0.2}
onScroll={handleScroll}
refreshing={isFetchingNextPage}
accessible
allObsToUpload={allObsToUpload}
currentUser={currentUser}
testID="MyObservationsAnimatedList"
handleScroll={handleScroll}
renderEmptyList={renderEmptyList}
data={observations.filter( o => o.isValid() )}
showObservationsEmptyScreen
/>
</Animated.View>
</View>

View File

@@ -5,7 +5,7 @@ import type { Node } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
useCurrentUser,
useInfiniteScroll,
useInfiniteObservationsScroll,
useLocalObservations,
useObservationsUpdates
} from "sharedHooks";
@@ -16,9 +16,14 @@ const MyObservationsContainer = ( ): Node => {
const { observationList: observations, allObsToUpload } = useLocalObservations( );
const { getItem, setItem } = useAsyncStorage( "myObservationsLayout" );
const [layout, setLayout] = useState( null );
const { isFetchingNextPage, fetchNextPage } = useInfiniteScroll( );
const [showLoginSheet, setShowLoginSheet] = useState( false );
const currentUser = useCurrentUser();
const { isFetchingNextPage, fetchNextPage } = useInfiniteObservationsScroll( {
upsert: true,
params: {
user_id: currentUser?.id
}
} );
const [showLoginSheet, setShowLoginSheet] = useState( false );
useObservationsUpdates( !!currentUser );
const writeItemToStorage = useCallback( async newValue => {

View File

@@ -10,12 +10,12 @@ import {
markObservationUpdatesViewed,
unfaveObservation
} from "api/observations";
import ObsStatus from "components/MyObservations/ObsStatus";
import ActivityHeader from "components/ObsDetails/ActivityHeader";
import {
Button, DisplayTaxonName, ObservationLocation, PhotoCount, Tabs
} from "components/SharedComponents";
import HideView from "components/SharedComponents/HideView";
import ObsStatus from "components/SharedComponents/ObservationsFlashList/ObsStatus";
import PhotoScroll from "components/SharedComponents/PhotoScroll";
import ScrollViewWrapper from "components/SharedComponents/ScrollViewWrapper";
import {

View File

@@ -1,6 +1,6 @@
// @flow
import ObsPreviewImage from "components/MyObservations/ObsImagePreview";
import ObsPreviewImage from "components/SharedComponents/ObservationsFlashList/ObsImagePreview";
import { Pressable } from "components/styledComponents";
import type { Node } from "react";
import React from "react";

View File

@@ -1,8 +1,8 @@
// @flow
import ObsImagePreview from "components/MyObservations/ObsImagePreview";
import ObsStatus from "components/MyObservations/ObsStatus";
import { DisplayTaxonName } from "components/SharedComponents";
import ObsImagePreview from "components/SharedComponents/ObservationsFlashList/ObsImagePreview";
import ObsStatus from "components/SharedComponents/ObservationsFlashList/ObsStatus";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";

View File

@@ -1,7 +1,7 @@
// @flow
import classnames from "classnames";
import Heading4 from "components/SharedComponents/Typography/Heading4";
import { Heading4, INatIcon } from "components/SharedComponents";
import { Pressable, View } from "components/styledComponents";
import * as React from "react";
import { ActivityIndicator, useTheme } from "react-native-paper";
@@ -18,7 +18,8 @@ type ButtonProps = {
onPress: any,
style?: any,
testID?: string,
text: string
text: string,
dropdown?: boolean
}
const setStyles = ( {
@@ -36,7 +37,8 @@ const setStyles = ( {
"justify-center",
"px-[10px]",
"py-[13px]",
"rounded-lg"
"rounded-lg",
"font-Whitney-Bold"
];
const textClasses = [
"text-center",
@@ -111,7 +113,8 @@ const Button = ( {
onPress,
style,
testID,
text
text,
dropdown
}: ButtonProps ): React.Node => {
const isPrimary = level === "primary";
const isWarning = level === "warning";
@@ -160,6 +163,14 @@ const Button = ( {
>
{text}
</Heading4>
{dropdown && (
<View className="ml-2 mb-1">
<INatIcon
name="caret"
size={10}
/>
</View>
)}
</Pressable>
);
};

View File

@@ -14,89 +14,91 @@
"book": 61709,
"briefcase": 61710,
"camera": 61711,
"check": 61712,
"checkmark-circle-outline": 61713,
"checkmark-circle": 61714,
"chevron-left-circle": 61715,
"chevron-left": 61716,
"chevron-right-circle": 61717,
"circle-dots": 61718,
"clock-outline": 61719,
"close-bold": 61720,
"close": 61721,
"comments-outline": 61722,
"comments": 61723,
"compass-rose-outline": 61724,
"copyright": 61725,
"crop": 61726,
"door-exit": 61727,
"flag": 61728,
"flash-off": 61729,
"flash-on": 61730,
"flip": 61731,
"gallery": 61732,
"gear": 61733,
"globe-outline": 61734,
"grid-square": 61735,
"grid": 61736,
"hamburger-menu": 61737,
"heart": 61738,
"help-circle-outline": 61739,
"help-circle": 61740,
"help": 61741,
"iconic-actinopterygii": 61742,
"iconic-amphibia": 61743,
"iconic-animalia": 61744,
"iconic-arachnida": 61745,
"iconic-aves": 61746,
"iconic-chromista": 61747,
"iconic-fungi": 61748,
"iconic-insecta": 61749,
"iconic-mammalia": 61750,
"iconic-mollusica": 61751,
"iconic-plantae": 61752,
"iconic-protozoa": 61753,
"iconic-reptilia": 61754,
"iconic-unknown": 61755,
"id-agree": 61756,
"inaturalist": 61757,
"info-circle-outline": 61758,
"kebab-menu": 61759,
"label-outline": 61760,
"label": 61761,
"laptop": 61762,
"layers": 61763,
"leaf": 61764,
"list-square": 61765,
"location-crosshairs": 61766,
"magnifying-glass": 61767,
"map-marker-outline": 61768,
"microphone-circle": 61769,
"microphone": 61770,
"noevidence": 61771,
"notifications-bell": 61772,
"pause-circle": 61773,
"pencil-outline": 61774,
"pencil": 61775,
"person": 61776,
"photos-outline": 61777,
"photos": 61778,
"play-circle": 61779,
"play": 61780,
"plus-bold": 61781,
"plus": 61782,
"pot-outline": 61783,
"rotate-exclamation": 61784,
"rotate-right": 61785,
"rotate": 61786,
"sliders": 61787,
"sound-bold-outline": 61788,
"sound-outline": 61789,
"sounds": 61790,
"sparkly-label": 61791,
"star-bold-outline": 61792,
"star": 61793,
"trash-outline": 61794,
"trash": 61795,
"triangle-exclamation": 61796
"caret": 61712,
"check": 61713,
"checkmark-circle-outline": 61714,
"checkmark-circle": 61715,
"chevron-left-circle": 61716,
"chevron-left": 61717,
"chevron-right-circle": 61718,
"circle-dots": 61719,
"clock-outline": 61720,
"close-bold": 61721,
"close": 61722,
"comments-outline": 61723,
"comments": 61724,
"compass-rose-outline": 61725,
"copyright": 61726,
"crop": 61727,
"door-exit": 61728,
"flag": 61729,
"flash-off": 61730,
"flash-on": 61731,
"flip": 61732,
"gallery": 61733,
"gear": 61734,
"globe-outline": 61735,
"grid-square": 61736,
"grid": 61737,
"hamburger-menu": 61738,
"heart": 61739,
"help-circle-outline": 61740,
"help-circle": 61741,
"help": 61742,
"iconic-actinopterygii": 61743,
"iconic-amphibia": 61744,
"iconic-animalia": 61745,
"iconic-arachnida": 61746,
"iconic-aves": 61747,
"iconic-chromista": 61748,
"iconic-fungi": 61749,
"iconic-insecta": 61750,
"iconic-mammalia": 61751,
"iconic-mollusica": 61752,
"iconic-plantae": 61753,
"iconic-protozoa": 61754,
"iconic-reptilia": 61755,
"iconic-unknown": 61756,
"id-agree": 61757,
"inaturalist": 61758,
"info-circle-outline": 61759,
"kebab-menu": 61760,
"label-outline": 61761,
"label": 61762,
"laptop": 61763,
"layers": 61764,
"leaf": 61765,
"list-square": 61766,
"location-crosshairs": 61767,
"magnifying-glass": 61768,
"map-marker-outline": 61769,
"map": 61770,
"microphone-circle": 61771,
"microphone": 61772,
"noevidence": 61773,
"notifications-bell": 61774,
"pause-circle": 61775,
"pencil-outline": 61776,
"pencil": 61777,
"person": 61778,
"photos-outline": 61779,
"photos": 61780,
"play-circle": 61781,
"play": 61782,
"plus-bold": 61783,
"plus": 61784,
"pot-outline": 61785,
"rotate-exclamation": 61786,
"rotate-right": 61787,
"rotate": 61788,
"sliders": 61789,
"sound-bold-outline": 61790,
"sound-outline": 61791,
"sounds": 61792,
"sparkly-label": 61793,
"star-bold-outline": 61794,
"star": 61795,
"trash-outline": 61796,
"trash": 61797,
"triangle-exclamation": 61798
}

View File

@@ -1,7 +1,7 @@
// @flow
import classNames from "classnames";
import checkCamelAndSnakeCase from "components/ObsDetails/helpers/checkCamelAndSnakeCase";
import { Body4, INatIcon } from "components/SharedComponents";
import { Body3, Body4, INatIcon } from "components/SharedComponents";
import { View } from "components/styledComponents";
import * as React from "react";
import useTranslation from "sharedHooks/useTranslation";
@@ -9,10 +9,13 @@ import useTranslation from "sharedHooks/useTranslation";
type Props = {
observation: Object,
classNameMargin?: string,
details?:boolean
details?: boolean,
large?: boolean
};
const ObservationLocation = ( { observation, classNameMargin, details }: Props ): React.Node => {
const ObservationLocation = ( {
observation, classNameMargin, details, large
}: Props ): React.Node => {
const { t } = useTranslation( );
let displayLocation = checkCamelAndSnakeCase( observation, "placeGuess" );
@@ -45,6 +48,10 @@ const ObservationLocation = ( { observation, classNameMargin, details }: Props )
</View>
);
const TextComponent = large
? Body3
: Body4;
return (
<View
className={classNames( "flex flex-col", classNameMargin )}
@@ -56,13 +63,13 @@ const ObservationLocation = ( { observation, classNameMargin, details }: Props )
>
<View className="flex-row">
<INatIcon name="location" size={15} />
<Body4
<TextComponent
className="text-darkGray ml-[8px]"
numberOfLines={1}
ellipsizeMode="tail"
>
{displayLocation}
</Body4>
</TextComponent>
</View>
{details
&& (

View File

@@ -14,7 +14,7 @@ type Props = {
const MyObservationsPressable = ( { observation, children }: Props ): Node => {
const navigation = useNavigation( );
const { t } = useTranslation( );
const unsynced = !observation.wasSynced( );
const unsynced = typeof observation.wasSynced !== "undefined" && !observation.wasSynced( );
const navigateToObservation = ( ) => {
const { uuid } = observation;

View File

@@ -14,7 +14,8 @@ type Props = {
width?: string,
height?: string,
style?: Object,
setShowLoginSheet: Function
setShowLoginSheet: Function,
hideUploadStatus?: boolean
};
const ObsGridItem = ( {
@@ -22,12 +23,13 @@ const ObsGridItem = ( {
width = "w-full",
height,
style,
setShowLoginSheet
setShowLoginSheet,
hideUploadStatus
}: Props ): Node => (
<ObsImagePreview
source={{
uri: Photo.displayLocalOrRemoteMediumPhoto(
observation?.observationPhotos?.[0]?.photo
observation?.observationPhotos?.[0]?.photo || observation?.observation_photos?.[0]?.photo
)
}}
width={width}
@@ -39,13 +41,15 @@ const ObsGridItem = ( {
testID={`MyObservations.gridItem.${observation.uuid}`}
>
<View className="absolute bottom-0 flex p-2 w-full">
<ObsUploadStatus
observation={observation}
layout="horizontal"
white
classNameMargin="mb-1"
setShowLoginSheet={setShowLoginSheet}
/>
{!hideUploadStatus && (
<ObsUploadStatus
observation={observation}
layout="horizontal"
white
classNameMargin="mb-1"
setShowLoginSheet={setShowLoginSheet}
/>
)}
<DisplayTaxonName
keyBase={observation?.uuid}
taxon={observation?.taxon}

View File

@@ -14,8 +14,10 @@ type Props = {
};
const ObsListItem = ( { observation, setShowLoginSheet }: Props ): Node => {
const photo = observation?.observationPhotos?.[0]?.photo || null;
const needsSync = observation.needsSync( );
const photo = observation?.observationPhotos?.[0]?.photo
|| observation?.observation_photos?.[0]?.photo
|| null;
const needsSync = typeof observation.needsSync !== "undefined" && observation.needsSync( );
return (
<View

View File

@@ -41,9 +41,8 @@ const ObsUploadStatus = ( {
const needsSync = item => !item._synced_at
|| item._synced_at <= item._updated_at;
const totalProgressIncrements = needsSync( observation )
+ observation
.observationPhotos.map( obsPhoto => needsSync( obsPhoto ) ).length;
const obsPhotos = observation?.observationPhotos?.map( obsPhoto => needsSync( obsPhoto ) ).length;
const currentProgress = uploadProgress?.[observation.uuid];
const displayUploadStatus = ( ) => {
@@ -56,7 +55,10 @@ const ObsUploadStatus = ( {
/>
);
if ( !observation.id || typeof currentProgress === "number" ) {
if ( !observation.id ) {
const totalProgressIncrements = needsSync( observation ) + obsPhotos;
if ( typeof currentProgress === "number" ) { return null; }
const progress = currentProgress / totalProgressIncrements;
return (
<UploadStatus

View File

@@ -0,0 +1,201 @@
// @flow
import { FlashList } from "@shopify/flash-list";
import InfiniteScrollLoadingWheel from "components/MyObservations/InfiniteScrollLoadingWheel";
import MyObservationsEmpty from "components/MyObservations/MyObservationsEmpty";
import { Body3 } from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import { ActivityIndicator, Animated } from "react-native";
import { BREAKPOINTS } from "sharedHelpers/breakpoint";
import { useDeviceOrientation, useTranslation } from "sharedHooks";
import MyObservationsPressable from "./MyObservationsPressable";
import ObsGridItem from "./ObsGridItem";
import ObsListItem from "./ObsListItem";
const AnimatedFlashList = Animated.createAnimatedComponent( FlashList );
type Props = {
isFetchingNextPage?: boolean,
layout: "list" | "grid",
data: Array<Object>,
onEndReached: Function,
allObsToUpload?: Array<Object>,
currentUser?: ?Object,
testID: string,
handleScroll?: Function,
hideUploadStatus?: boolean,
status?: string,
showObservationsEmptyScreen?: boolean
};
const GUTTER = 15;
const Item = React.memo(
( {
observation, layout, gridItemWidth, setShowLoginSheet = false,
hideUploadStatus
} ) => (
<MyObservationsPressable observation={observation}>
{
layout === "grid"
? (
<ObsGridItem
observation={observation}
// 03022023 it seems like Flatlist is designed to work
// better with RN styles than with Tailwind classes
style={{
height: gridItemWidth,
width: gridItemWidth,
margin: GUTTER / 2
}}
setShowLoginSheet={setShowLoginSheet}
hideUploadStatus={hideUploadStatus}
/>
)
: (
<ObsListItem
observation={observation}
setShowLoginSheet={setShowLoginSheet}
/>
)
}
</MyObservationsPressable>
)
);
const ObservationsFlashList = ( {
isFetchingNextPage,
layout,
data,
onEndReached,
allObsToUpload,
currentUser,
testID,
handleScroll,
hideUploadStatus,
status,
showObservationsEmptyScreen
}: Props ): Node => {
const {
isLandscapeMode,
isTablet,
screenHeight,
screenWidth
} = useDeviceOrientation( );
const { t } = useTranslation( );
const [numColumns, setNumColumns] = useState( 0 );
const [gridItemWidth, setGridItemWidth] = useState( 0 );
useEffect( ( ) => {
const calculateGridItemWidth = columns => {
const combinedGutter = ( columns + 1 ) * GUTTER;
const gridWidth = isTablet
? screenWidth
: Math.min( screenWidth, screenHeight );
return Math.floor(
( gridWidth - combinedGutter ) / columns
);
};
const calculateNumColumns = ( ) => {
if ( layout === "list" || screenWidth <= BREAKPOINTS.md ) {
return 1;
}
if ( !isTablet ) return 2;
if ( isLandscapeMode ) return 6;
if ( screenWidth <= BREAKPOINTS.xl ) return 2;
return 4;
};
const columns = calculateNumColumns( );
setGridItemWidth( calculateGridItemWidth( columns ) );
setNumColumns( columns );
}, [
isLandscapeMode,
isTablet,
layout,
screenHeight,
screenWidth
] );
const renderItem = ( { item } ) => (
<Item
observation={item}
layout={layout}
gridItemWidth={gridItemWidth}
allObsToUpload={allObsToUpload}
testID={testID}
hideUploadStatus={hideUploadStatus}
/>
);
const renderItemSeparator = ( ) => {
if ( layout === "grid" ) {
return null;
}
return <View className="border-b border-lightGray" />;
};
const renderFooter = ( ) => (
<InfiniteScrollLoadingWheel
isFetchingNextPage={isFetchingNextPage}
currentUser={currentUser}
layout={layout}
/>
);
const contentContainerStyle = layout === "list"
? {}
: {
paddingLeft: GUTTER / 2,
paddingRight: GUTTER / 2
};
if ( numColumns === 0 ) { return null; }
const showEmptyScreen = showObservationsEmptyScreen
? <MyObservationsEmpty isFetchingNextPage={isFetchingNextPage} />
: <Body3 className="self-center mt-[150px]">{t( "No-results-found" )}</Body3>;
if ( !data || data.length === 0 ) {
return status === "loading"
? (
<View className="self-center mt-[150px]">
<ActivityIndicator size="large" />
</View>
)
: showEmptyScreen;
}
return (
<AnimatedFlashList
contentContainerStyle={contentContainerStyle}
data={data}
key={layout}
estimatedItemSize={
layout === "grid"
? gridItemWidth
: 98
}
testID={testID}
numColumns={numColumns}
horizontal={false}
// only used id as a fallback key because after upload
// react thinks we've rendered a second item w/ a duplicate key
keyExtractor={item => item.uuid || item.id}
renderItem={renderItem}
ItemSeparatorComponent={renderItemSeparator}
ListFooterComponent={renderFooter}
initialNumToRender={5}
onEndReached={onEndReached}
onEndReachedThreshold={0.2}
onScroll={handleScroll}
refreshing={isFetchingNextPage}
accessible
/>
);
};
export default ObservationsFlashList;

View File

@@ -2,8 +2,8 @@
import { useNavigation } from "@react-navigation/native";
import classnames from "classnames";
import ObsImagePreview from "components/MyObservations/ObsImagePreview";
import { DisplayTaxonName, INatIconButton } from "components/SharedComponents";
import ObsImagePreview from "components/SharedComponents/ObservationsFlashList/ObsImagePreview";
import { Pressable, View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";

View File

@@ -9,11 +9,12 @@ type Props = {
uri: Object,
small?: boolean,
active?: boolean,
large?: boolean
large?: boolean,
medium?: boolean
}
const UserIcon = ( {
uri, small, active, large
uri, small, active, large, medium
}: Props ): React.Node => {
const getSize = ( ) => {
if ( small ) {
@@ -22,6 +23,9 @@ const UserIcon = ( {
if ( large ) {
return "w-[134px] h-[134px]";
}
if ( medium ) {
return "w-[62px] h-[62px]";
}
return "w-[40px] h-[40px]";
};

View File

@@ -0,0 +1,39 @@
// @flow
import {
Body1, List2, UserIcon
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import User from "realmModels/User";
import { useTranslation } from "sharedHooks";
type Props = {
item: Object,
count: number,
countText: string
};
const UserListItem = ( { item, count, countText }: Props ): Node => {
const { t } = useTranslation( );
const user = item?.user;
if ( !user ) { return null; }
return (
<View
className="flex-row items-center mx-3 my-2"
testID={`UserProfile.${user.id}`}
>
<UserIcon uri={User.uri( user )} medium />
<View className="ml-3">
<Body1 className="mt-3">{user.login}</Body1>
<List2 className="mt-1">
{t( countText, { count } )}
</List2>
</View>
</View>
);
};
export default UserListItem;

View File

@@ -17,6 +17,7 @@ export { default as InlineUser } from "./InlineUser/InlineUser";
export { default as KebabMenu } from "./KebabMenu";
export { default as Map } from "./Map";
export { default as ObservationLocation } from "./ObservationLocation";
export { default as ObservationsFlashList } from "./ObservationsFlashList/ObservationsFlashList";
export { default as PhotoCount } from "./PhotoCount";
export { default as QualityGradeStatus } from "./QualityGradeStatus/QualityGradeStatus";
export { default as ScrollViewWrapper } from "./ScrollViewWrapper";

View File

@@ -1,4 +1,3 @@
import ObsStatus from "components/MyObservations/ObsStatus";
import ActivityItem from "components/ObsDetails/ActivityItem";
import {
ActivityCount,
@@ -38,6 +37,7 @@ import {
} from "components/SharedComponents";
import AddObsButton from "components/SharedComponents/Buttons/AddObsButton";
import glyphmap from "components/SharedComponents/INatIcon/glyphmap.json";
import ObsStatus from "components/SharedComponents/ObservationsFlashList/ObsStatus";
import UserText from "components/SharedComponents/UserText";
import ViewWrapper from "components/SharedComponents/ViewWrapper";
import { fontMonoClass, ScrollView, View } from "components/styledComponents";

View File

View File

@@ -995,6 +995,26 @@ x-identifications = {$count ->
*[other] {$count} identifications
}
X-Identifications = {$count ->
[one] {$count} Identification
*[other] {$count} Identifications
}
X-Identifiers = {$count ->
[one] {$count} Identifier
*[other] {$count} Identifiers
}
X-Observers = {$count ->
[one] {$count} Observer
*[other] {$count} Observers
}
X-Species = {$count ->
[one] {$count} Species
*[other] {$count} Species
}
## Accessibility hints: these are used by screen readers to describe what happens when the user interacts with an element iOS: https://developer.apple.com/documentation/uikit/uiaccessibilityelement/1619585-accessibilityhint
## iOS Guidelines "A string that briefly describes the result of performing an action on the accessibility element." Third person singular ending with a period.
Agrees-with-identification = Agrees with this identification.
@@ -1195,3 +1215,6 @@ Log-in-to-iNaturalist = Log in to iNaturalist
Try-searching-for-a-location-name = Try searching for a location name to see the map
Scan-the-area-around-you-for-organisms = Scan the area around you for organisms.
Loading-iNaturalists-AR-Camera = Loading iNaturalists AR Camera
# Used for explore screen when search params lead to a search with no data
No-results-found = No results found

View File

@@ -631,6 +631,10 @@
"Observation-Name": "Observation { $scientificName }",
"x-comments": "{ $count ->\n [one] { $count } comment\n *[other] { $count } comments\n}",
"x-identifications": "{ $count ->\n [one] { $count } identification\n *[other] { $count } identifications\n}",
"X-Identifications": "{ $count ->\n [one] { $count } Identification\n *[other] { $count } Identifications\n}",
"X-Identifiers": "{ $count ->\n [one] { $count } Identifier\n *[other] { $count } Identifiers\n}",
"X-Observers": "{ $count ->\n [one] { $count } Observer\n *[other] { $count } Observers\n}",
"X-Species": "{ $count ->\n [one] { $count } Species\n *[other] { $count } Species\n}",
"Agrees-with-identification": "Agrees with this identification.",
"Navigates-to-bulk-importer": "Navigates to bulk importer.",
"Navigates-to-camera": "Navigates to camera.",
@@ -824,5 +828,9 @@
"Log-in-to-iNaturalist": "Log in to iNaturalist",
"Try-searching-for-a-location-name": "Try searching for a location name to see the map",
"Scan-the-area-around-you-for-organisms": "Scan the area around you for organisms.",
"Loading-iNaturalists-AR-Camera": "Loading iNaturalists AR Camera"
"Loading-iNaturalists-AR-Camera": "Loading iNaturalists AR Camera",
"No-results-found": {
"comment": "Used for explore screen when search params lead to a search with no data",
"val": "No results found"
}
}

View File

@@ -995,6 +995,26 @@ x-identifications = {$count ->
*[other] {$count} identifications
}
X-Identifications = {$count ->
[one] {$count} Identification
*[other] {$count} Identifications
}
X-Identifiers = {$count ->
[one] {$count} Identifier
*[other] {$count} Identifiers
}
X-Observers = {$count ->
[one] {$count} Observer
*[other] {$count} Observers
}
X-Species = {$count ->
[one] {$count} Species
*[other] {$count} Species
}
## Accessibility hints: these are used by screen readers to describe what happens when the user interacts with an element iOS: https://developer.apple.com/documentation/uikit/uiaccessibilityelement/1619585-accessibilityhint
## iOS Guidelines "A string that briefly describes the result of performing an action on the accessibility element." Third person singular ending with a period.
Agrees-with-identification = Agrees with this identification.
@@ -1195,3 +1215,6 @@ Log-in-to-iNaturalist = Log in to iNaturalist
Try-searching-for-a-location-name = Try searching for a location name to see the map
Scan-the-area-around-you-for-organisms = Scan the area around you for organisms.
Loading-iNaturalists-AR-Camera = Loading iNaturalists AR Camera
# Used for explore screen when search params lead to a search with no data
No-results-found = No results found

View File

@@ -0,0 +1,4 @@
<?xml version="1.0"?>
<svg width="24" height="24" viewBox="0 0 13 7">
<path d="M6.06209 6.5L-8.7738e-05 0.5L12.1243 0.5L6.06209 6.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 145 B

4
src/images/icons/map.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0"?>
<svg width="24" height="24" viewBox="0 0 20 22">
<path d="M19.5946 16.6412C19.8496 16.4527 20 16.1543 20 15.8372V2.71139C20 1.89043 19.0654 1.41922 18.4054 1.90739L13.928 5.2188C13.5746 5.48013 13.0921 5.48013 12.7387 5.2188L7.26129 1.16781C6.90794 0.906477 6.42539 0.906477 6.07204 1.16781L0.405374 5.35877C0.150412 5.54734 0 5.84566 0 6.16277V19.2886C0 20.1096 0.934572 20.5808 1.59463 20.0926L6.07204 16.7812C6.42539 16.5199 6.90794 16.5199 7.26129 16.7812L12.7387 20.8322C13.0921 21.0935 13.5746 21.0935 13.928 20.8322L19.5946 16.6412ZM7.48739 14.9307C7.13557 14.7145 6.80048 14.3867 6.80046 13.9738L6.80008 4.83572C6.80004 4.01476 7.73459 3.54352 8.39466 4.03165L12.3984 6.99246L12.5126 7.06955C12.8647 7.28589 13.2003 7.61401 13.2003 8.0273L13.2 17.1647C13.2 17.9856 12.2655 18.4568 11.6055 17.9687L7.60155 15.0078L7.48739 14.9307Z"/>
</svg>

After

Width:  |  Height:  |  Size: 872 B

View File

@@ -2,7 +2,7 @@ import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import About from "components/About";
import AddID from "components/AddID/AddID";
import CameraContainer from "components/Camera/CameraContainer";
import Explore from "components/Explore/Explore";
import ExploreContainer from "components/Explore/ExploreContainer";
import Identify from "components/Identify/Identify";
import LocationPickerContainer from "components/LocationPicker/LocationPickerContainer";
import ForgotPassword from "components/LoginSignUp/ForgotPassword";
@@ -182,9 +182,9 @@ const BottomTabs = ( ) => {
>
<Tab.Screen
name="Explore"
component={Explore}
component={ExploreContainer}
options={{
...hideHeaderLeft,
...hideHeader,
meta: {
icon: "compass-rose-outline",
testID: EXPLORE_SCREEN_ID,

View File

@@ -63,7 +63,7 @@ class Observation extends Realm.Object {
}
static createLinkedObjects = ( list, createFunction, realm ) => {
if ( list.length === 0 ) { return list; }
if ( !list || list.length === 0 ) { return list; }
return list.map( item => createFunction.mapApiToRealm( item, realm ) );
};

View File

@@ -5,6 +5,7 @@ export { default as useCoords } from "./useCoords";
export { default as useCurrentObservationLocation } from "./useCurrentObservationLocation";
export { default as useCurrentUser } from "./useCurrentUser";
export { default as useDeviceOrientation } from "./useDeviceOrientation";
export { default as useInfiniteObservationsScroll } from "./useInfiniteObservationsScroll";
export { default as useInfiniteScroll } from "./useInfiniteScroll";
export { default as useIsConnected } from "./useIsConnected";
export { default as useIsForeground } from "./useIsForeground";

View File

@@ -0,0 +1,82 @@
// @flow
import { useInfiniteQuery } from "@tanstack/react-query";
import { searchObservations } from "api/observations";
import { getJWT } from "components/LoginSignUp/AuthenticationService";
import { flatten, last, noop } from "lodash";
import { RealmContext } from "providers/contexts";
import { useEffect } from "react";
import Observation from "realmModels/Observation";
import useCurrentUser from "sharedHooks/useCurrentUser";
const { useRealm } = RealmContext;
const useInfiniteObservationsScroll = ( { upsert, params: newInputParams }: Object ): Object => {
const realm = useRealm( );
const currentUser = useCurrentUser( );
const baseParams = {
...newInputParams,
per_page: 50,
fields: Observation.FIELDS,
ttl: -1
};
const {
data: observations,
isFetchingNextPage,
fetchNextPage,
status
} = useInfiniteQuery( {
// eslint-disable-next-line
queryKey: ["searchObservations", baseParams],
keepPreviousData: false,
queryFn: async ( { pageParam } ) => {
const apiToken = await getJWT( );
const options = {
api_token: apiToken
};
const params = {
...baseParams
};
if ( pageParam ) {
// $FlowIgnore
params.id_below = pageParam;
} else {
// $FlowIgnore
params.page = 1;
}
return searchObservations( params, options );
},
getNextPageParam: lastPage => last( lastPage )?.id,
enabled: !!currentUser
} );
useEffect( ( ) => {
if ( observations?.pages && upsert ) {
Observation.upsertRemoteObservations(
flatten( last( observations.pages ) ),
realm
);
}
}, [realm, observations, upsert] );
return currentUser
? {
isFetchingNextPage,
fetchNextPage,
observations: flatten( observations?.pages ),
status
}
: {
isFetchingNextPage: false,
fetchNextPage: noop,
observations: flatten( observations?.pages ),
status
};
};
export default useInfiniteObservationsScroll;

View File

@@ -1,72 +1,54 @@
// @flow
import { useInfiniteQuery } from "@tanstack/react-query";
import { searchObservations } from "api/observations";
import { getJWT } from "components/LoginSignUp/AuthenticationService";
import { flatten, last, noop } from "lodash";
import { RealmContext } from "providers/contexts";
import { useEffect } from "react";
import Observation from "realmModels/Observation";
import useCurrentUser from "sharedHooks/useCurrentUser";
const { useRealm } = RealmContext;
const useInfiniteScroll = ( ): Object => {
const realm = useRealm( );
const currentUser = useCurrentUser( );
import { flatten } from "lodash";
const useInfiniteScroll = (
queryKey: string,
apiCall: Function,
newInputParams: Object
): Object => {
const baseParams = {
user_id: currentUser?.id,
per_page: 50,
fields: Observation.FIELDS,
...newInputParams,
per_page: 10,
ttl: -1
};
const {
data: observations,
data,
isFetchingNextPage,
fetchNextPage
fetchNextPage,
status
} = useInfiniteQuery( {
queryKey: ["searchObservations", baseParams],
// eslint-disable-next-line
queryKey: [queryKey, baseParams],
keepPreviousData: false,
queryFn: async ( { pageParam } ) => {
const apiToken = await getJWT( );
const options = {
api_token: apiToken
};
queryFn: async ( { pageParam = 0 } ) => {
const params = {
...baseParams
};
if ( pageParam ) {
// $FlowIgnore
params.id_below = pageParam;
} else {
// $FlowIgnore
params.page = 1;
}
params.page = pageParam;
return searchObservations( params, options );
return apiCall( params );
},
getNextPageParam: lastPage => last( lastPage )?.id,
enabled: !!currentUser
getNextPageParam: lastPage => ( lastPage
? lastPage.page + 1
: 1 )
} );
useEffect( ( ) => {
if ( observations?.pages ) {
Observation.upsertRemoteObservations(
flatten( last( observations.pages ) ),
realm
);
}
}, [realm, observations] );
const pages = data?.pages;
const allResults = pages?.map( page => page?.results );
return currentUser
? {
isFetchingNextPage,
fetchNextPage
}
: { isFetchingNextPage: false, fetchNextPage: noop };
return {
isFetchingNextPage,
fetchNextPage,
data: flatten( allResults ),
totalResults: pages?.[0]
? pages?.[0].total_results
: 0,
status
};
};
export default useInfiniteScroll;

View File

@@ -6,14 +6,10 @@ import { Platform } from "react-native";
import { PERMISSIONS, request } from "react-native-permissions";
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
type Props = {
skipPlaceGuess?: bool
}
// Max time to wait while fetching current location
const CURRENT_LOCATION_TIMEOUT_MS = 30000;
const useUserLocation = ( { skipPlaceGuess = false }: Props ): Object => {
const useUserLocation = ( { skipPlaceGuess = false }: Object ): Object => {
const [latLng, setLatLng] = useState( null );
const [isLoading, setIsLoading] = useState( true );

View File

@@ -36,7 +36,7 @@ exports[`ActivityCount renders reliably 1`] = `
]
}
>
</Text>
<Text
style={

View File

@@ -1,75 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CommentsCount renders default reliably 1`] = `
<View
accessibilityLabel="1 comment"
accessible={true}
style={
[
[
{
"flexDirection": "row",
},
{
"alignItems": "center",
},
],
]
}
>
<Text
allowFontScaling={false}
selectable={false}
style={
[
{
"color": "rgba(103, 80, 164, 1)",
"fontSize": 14,
},
undefined,
{
"fontFamily": "INatIcon",
"fontStyle": "normal",
"fontWeight": "normal",
},
{},
]
}
>
</Text>
<Text
style={
[
{
"fontFamily": "Whitney-Light",
},
[
{
"fontSize": 14,
"lineHeight": 18,
},
{
"fontWeight": "400",
},
{
"color": "#454545",
},
[
{
"marginLeft": 6,
},
],
],
]
}
>
1
</Text>
</View>
`;
exports[`CommentsCount renders filled reliably 1`] = `
<View
accessibilityLabel="1 comment"
accessible={true}
@@ -138,6 +69,75 @@ exports[`CommentsCount renders filled reliably 1`] = `
</View>
`;
exports[`CommentsCount renders filled reliably 1`] = `
<View
accessibilityLabel="1 comment"
accessible={true}
style={
[
[
{
"flexDirection": "row",
},
{
"alignItems": "center",
},
],
]
}
>
<Text
allowFontScaling={false}
selectable={false}
style={
[
{
"color": "rgba(103, 80, 164, 1)",
"fontSize": 14,
},
undefined,
{
"fontFamily": "INatIcon",
"fontStyle": "normal",
"fontWeight": "normal",
},
{},
]
}
>
</Text>
<Text
style={
[
{
"fontFamily": "Whitney-Light",
},
[
{
"fontSize": 14,
"lineHeight": 18,
},
{
"fontWeight": "400",
},
{
"color": "#454545",
},
[
{
"marginLeft": 6,
},
],
],
]
}
>
1
</Text>
</View>
`;
exports[`CommentsCount renders white reliably 1`] = `
<View
accessibilityLabel="1 comment"
@@ -174,7 +174,7 @@ exports[`CommentsCount renders white reliably 1`] = `
]
}
>
</Text>
<Text
style={

View File

@@ -1,75 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`IdentificationsCount renders default reliably 1`] = `
<View
accessibilityLabel="1 identification"
accessible={true}
style={
[
[
{
"flexDirection": "row",
},
{
"alignItems": "center",
},
],
]
}
>
<Text
allowFontScaling={false}
selectable={false}
style={
[
{
"color": "rgba(103, 80, 164, 1)",
"fontSize": 14,
},
undefined,
{
"fontFamily": "INatIcon",
"fontStyle": "normal",
"fontWeight": "normal",
},
{},
]
}
>
</Text>
<Text
style={
[
{
"fontFamily": "Whitney-Light",
},
[
{
"fontSize": 14,
"lineHeight": 18,
},
{
"fontWeight": "400",
},
{
"color": "#454545",
},
[
{
"marginLeft": 6,
},
],
],
]
}
>
1
</Text>
</View>
`;
exports[`IdentificationsCount renders filled reliably 1`] = `
<View
accessibilityLabel="1 identification"
accessible={true}
@@ -138,6 +69,75 @@ exports[`IdentificationsCount renders filled reliably 1`] = `
</View>
`;
exports[`IdentificationsCount renders filled reliably 1`] = `
<View
accessibilityLabel="1 identification"
accessible={true}
style={
[
[
{
"flexDirection": "row",
},
{
"alignItems": "center",
},
],
]
}
>
<Text
allowFontScaling={false}
selectable={false}
style={
[
{
"color": "rgba(103, 80, 164, 1)",
"fontSize": 14,
},
undefined,
{
"fontFamily": "INatIcon",
"fontStyle": "normal",
"fontWeight": "normal",
},
{},
]
}
>
</Text>
<Text
style={
[
{
"fontFamily": "Whitney-Light",
},
[
{
"fontSize": 14,
"lineHeight": 18,
},
{
"fontWeight": "400",
},
{
"color": "#454545",
},
[
{
"marginLeft": 6,
},
],
],
]
}
>
1
</Text>
</View>
`;
exports[`IdentificationsCount renders white reliably 1`] = `
<View
accessibilityLabel="1 identification"
@@ -174,7 +174,7 @@ exports[`IdentificationsCount renders white reliably 1`] = `
]
}
>
</Text>
<Text
style={

View File

@@ -174,7 +174,7 @@ exports[`InlineUser when offline renders reliably 1`] = `
}
testID="InlineUser.NoInternetPicture"
>
</Text>
</View>
<Text
@@ -285,7 +285,7 @@ exports[`InlineUser when user has no icon set renders reliably 1`] = `
}
testID="InlineUser.FallbackPicture"
>
</Text>
</View>
<Text

View File

@@ -167,7 +167,7 @@ exports[`UploadStatus displays progress bar when progress is less than 5% correc
]
}
>
</Text>
</View>
</View>