mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2025-12-23 22:18:36 -05:00
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:
committed by
GitHub
parent
5f55285ee4
commit
20d8308e5f
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
"data": [
|
||||
{
|
||||
"path": "assets/fonts/INatIcon.ttf",
|
||||
"sha1": "94ddb30161ddbd61eda2524a57b2c5eec804401e"
|
||||
"sha1": "039642e4aed65748aa3285e16527608a4b8c3168"
|
||||
},
|
||||
{
|
||||
"path": "assets/fonts/Whitney-BookItalic-Pro.otf",
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
16
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
171
src/components/Explore/ExploreContainer.js
Normal file
171
src/components/Explore/ExploreContainer.js
Normal 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;
|
||||
89
src/components/Explore/ExploreFlashList.js
Normal file
89
src/components/Explore/ExploreFlashList.js
Normal 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;
|
||||
203
src/components/Explore/Header.js
Normal file
203
src/components/Explore/Header.js
Normal 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;
|
||||
75
src/components/Explore/IdentifiersView.js
Normal file
75
src/components/Explore/IdentifiersView.js
Normal 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;
|
||||
53
src/components/Explore/ObservationsView.js
Normal file
53
src/components/Explore/ObservationsView.js
Normal 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;
|
||||
72
src/components/Explore/ObservationsViewBar.js
Normal file
72
src/components/Explore/ObservationsViewBar.js
Normal 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;
|
||||
73
src/components/Explore/ObserversView.js
Normal file
73
src/components/Explore/ObserversView.js
Normal 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;
|
||||
48
src/components/Explore/SpeciesSeenCheckmark.js
Normal file
48
src/components/Explore/SpeciesSeenCheckmark.js
Normal 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;
|
||||
122
src/components/Explore/SpeciesView.js
Normal file
122
src/components/Explore/SpeciesView.js
Normal 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;
|
||||
51
src/components/Explore/TaxonGridItem.js
Normal file
51
src/components/Explore/TaxonGridItem.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -10,8 +10,8 @@ import { useIsConnected, useTranslation } from "sharedHooks";
|
||||
|
||||
type Props = {
|
||||
isFetchingNextPage?: boolean,
|
||||
currentUser: ?Object,
|
||||
layout: string
|
||||
currentUser?: ?Object,
|
||||
layout?: string
|
||||
}
|
||||
|
||||
const InfiniteScrollLoadingWheel = ( {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
&& (
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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]";
|
||||
};
|
||||
|
||||
|
||||
39
src/components/SharedComponents/UserListItem.js
Normal file
39
src/components/SharedComponents/UserListItem.js
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 iNaturalist’s AR Camera
|
||||
|
||||
# Used for explore screen when search params lead to a search with no data
|
||||
No-results-found = No results found
|
||||
@@ -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 iNaturalist’s AR Camera"
|
||||
"Loading-iNaturalists-AR-Camera": "Loading iNaturalist’s AR Camera",
|
||||
"No-results-found": {
|
||||
"comment": "Used for explore screen when search params lead to a search with no data",
|
||||
"val": "No results found"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 iNaturalist’s AR Camera
|
||||
|
||||
# Used for explore screen when search params lead to a search with no data
|
||||
No-results-found = No results found
|
||||
4
src/images/icons/caret.svg
Normal file
4
src/images/icons/caret.svg
Normal 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
4
src/images/icons/map.svg
Normal 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 |
@@ -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,
|
||||
|
||||
@@ -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 ) );
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
82
src/sharedHooks/useInfiniteObservationsScroll.js
Normal file
82
src/sharedHooks/useInfiniteObservationsScroll.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 );
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ exports[`ActivityCount renders reliably 1`] = `
|
||||
]
|
||||
}
|
||||
>
|
||||
|
||||
|
||||
</Text>
|
||||
<Text
|
||||
style={
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,7 +167,7 @@ exports[`UploadStatus displays progress bar when progress is less than 5% correc
|
||||
]
|
||||
}
|
||||
>
|
||||
|
||||
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user