Changes to the way permissions are asked for (#1793)

* Replace name in permission requests

* TakePhoto TS

* PermissionGate TS

* Type

* PermissionGateContainer TS

* Interface

* Types

* LocationGate TS

* Remove LocationPermissionGate from Camera

* Remove write only permission

* Type

* ObsPhotoSelectionList TS

* Code style

* Show the improve with location button

* Create useLocationPermission.tsx

* Use new hook on suggestions

* Doc comment

* Use new hook in camera view

* Add strings

* Refactor Explore main content

* Use permission hook on RootExplore

* Add no location permission component

* Rename function

* Prop request permissions and use with button

* Default to Nearby label

* Remove Node type

* Projects TS

* Use useLocationPermission hook in projects screen

* Add string

* Prop permission down

* Refactor list render

* Refactor tab id into enum

* Tab type

* On nearby tab if without permission show button to prompt

* Leftovers

* Remove location permission gate from ObsEdit

* Use location permission hook on evidence section

* SearchBar TS

* Do not autoFocus on search bar in location picker.

Closes #1743

* Update type

* LocationSearch TS

* Show location permission gate on location picker's mount

* Add location permission to CurrentLocationButton

* Remove unused props of Map

* Remove unused exports from useMapLocation

* Migration

* Revert "Show location permission gate on location picker's mount"

This reverts commit 30ff75698c53d54d0b14cd2bd629f7155b743bf8.

* Add callbacks to useLocationPermission hook

* Show location permission ask on Obs Edit

* Remove unused string

* Reset explore filters should set location always to worldwide

* Add helper function to show place text in Explore

* Remove unused state of filter modal

* Show place text in filters modal with helper

* Show location permission button only for Nearby explore state

* Add a placeMode state

* Do not send placeMode to API

* Also treat limited permission as yes

* useLocationPermission in ExploreLocationSearch

* Refactor to setting place mode

Instead of logic based on the translated text of the place_guess string that is stored in ExploreContext, we are switching to an enum state that signifies which mode to show on explore:
1.) Nearby: Filters explore results based on the user's location. This also has a state without location permission that does not query the API.
2.) Place: Filtering by a specific place (as retrieved by /places API).
3.) Worldwide: Retrieve worldwide results, i.e. not having a place filter set.
4.) Map area: Filtering explore results precisely to the map rectangle shown on the explore map.

* Remove import from test

* Remove export

* Use blocked title only for blocked permission asks

* Move gallery permission container to Tab navigator as are the others

* Add gallery save title

* Split location permission explanation into two

* Update strings.ftl

* Only nav to location picker if permission was not  granted

* Check permission on app being foregrounded

* The location permission part is handled by useLocationPermission

* Do not store permission result in hook

* Use hasPermission from permissions hook

* Update fetchUserLocation.e2e-mock

* Move hook one higher

* Show user location if permission is given

* PermissionGate callbacks should use useCallback

* Add permission hook to map usage

* Fix test

* Update layout to be asserted

* Add location permission hook to Explore

* Remove console.log

* Few TS fixes

* Indentation

* Remove superficial check

* Update Podfile.lock
This commit is contained in:
Johannes Klein
2024-07-12 11:00:24 +02:00
committed by GitHub
parent b3143d06ee
commit a52996f535
58 changed files with 810 additions and 702 deletions

View File

@@ -32,7 +32,7 @@ setup_permissions([
# 'Motion',
# 'Notifications',
'PhotoLibrary',
'PhotoLibraryAddOnly',
# 'PhotoLibraryAddOnly',
# 'Reminders',
# 'Siri',
# 'SpeechRecognition',

View File

@@ -1477,12 +1477,12 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: d3f49c53809116a5d38da093a8aa78bf551aed09
BVLinearGradient: 880f91a7854faff2df62518f0281afb1c60d49a3
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953
FasterImage: 60d0750ddbcefff0070c4c17309c2d1d6cc650f0
FBLazyVector: 9f533d5a4c75ca77c8ed774aced1a91a0701781e
FBReactNativeSpec: 40b791f4a1df779e7e4aa12c000319f4f216d40a
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
hermes-engine: 39589e9c297d024e90fe68f6830ff86c4e01498a
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
MMKV: 36a22a9ec84c9bb960613a089ddf6f48be9312b0
@@ -1560,7 +1560,7 @@ SPEC CHECKSUMS:
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: bc2cdb2dc42facdf34992ae364b8a728e19a3686
RNLocalize: e8694475db034bf601e17bd3dfa8986565e769eb
RNPermissions: b3d6efca086546e29a2920cd649a0ab04ca77794
RNPermissions: a123c47480a5f5d7a04d40637ad1f7360a41b465
RNReanimated: 6cfa556540186ce7ae7a0b048f369236b1d86ebb
RNScreens: b6b64d956af3715adbfe84808694ae82d3fec74f
RNShareMenu: cb9dac548c8bf147d06f0bf07296ad51ea9f5fc3
@@ -1572,6 +1572,6 @@ SPEC CHECKSUMS:
VisionCameraPluginInatVision: 8480b3955bc608e913135d3bebaa57939911fb82
Yoga: c716aea2ee01df6258550c7505fa61b248145ced
PODFILE CHECKSUM: eebd76aa39f99b44754431ed68ce0cfbfc5ec2f7
PODFILE CHECKSUM: ebb6b37cf92e00a96e3123d4db14c5658b4e5929
COCOAPODS: 1.15.2

View File

@@ -54,7 +54,7 @@
<key>NSAppleMusicUsageDescription</key>
<string>Add existing photos and sounds to your observations.</string>
<key>NSCameraUsageDescription</key>
<string>${PRODUCT_NAME} uses the camera to add photos to your observations of nature.</string>
<string>iNaturalist Next uses the camera to add photos to your observations of nature.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We do not intentionally request this permission. If you are seeing this, please take a screenshot and email it to help@inaturalist.org</string>
<key>NSLocationWhenInUseUsageDescription</key>
@@ -62,9 +62,9 @@
<key>NSMicrophoneUsageDescription</key>
<string>Record sound observations of nature.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Export ${PRODUCT_NAME} photos to your library.</string>
<string>Export iNaturalist Next photos to your library.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Export and import ${PRODUCT_NAME} photos to and from your library.</string>
<string>Export and import iNaturalist Next photos to and from your library.</string>
<key>UIAppFonts</key>
<array>
<string>Lato-Bold.ttf</string>

View File

@@ -42,7 +42,7 @@ type Props = {
camera: Object,
device: Object,
flipCamera: Function,
handleCheckmarkPress: Function,
handleCheckmarkPress: ( _result: any | null ) => void,
isLandscapeMode: boolean
};

View File

@@ -3,7 +3,7 @@
import CameraFlip from "components/Camera/Buttons/CameraFlip";
import Close from "components/Camera/Buttons/Close";
import Flash from "components/Camera/Buttons/Flash";
import TakePhoto from "components/Camera/Buttons/TakePhoto";
import TakePhoto from "components/Camera/Buttons/TakePhoto.tsx";
import Zoom from "components/Camera/Buttons/Zoom";
import TabletButtons from "components/Camera/TabletButtons";
import { View } from "components/styledComponents";
@@ -60,7 +60,7 @@ type Props = {
setNumStoredResults?: Function,
showPrediction: boolean,
showZoomButton: boolean,
takePhoto: Function,
takePhoto: () => Promise<void>,
takePhotoOptions: Object,
toggleFlash: Function,
zoomTextValue: string

View File

@@ -1,5 +1,3 @@
// @flow
import classnames from "classnames";
import {
INatIcon
@@ -7,7 +5,6 @@ import {
import {
Pressable, View
} from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { useTheme } from "react-native-paper";
import { useTranslation } from "sharedHooks";
@@ -16,17 +13,17 @@ import colors from "styles/tailwindColors";
const DROP_SHADOW = getShadowForColor( colors.darkGray );
type Props = {
takePhoto: Function,
disabled: boolean,
showPrediction?: boolean
interface Props {
takePhoto: () => Promise<void>;
disabled: boolean;
showPrediction?: boolean;
}
const TakePhoto = ( {
takePhoto,
disabled,
showPrediction
}: Props ): Node => {
}: Props ) => {
const { t } = useTranslation( );
const theme = useTheme( );

View File

@@ -1,11 +1,7 @@
import { useIsFocused, useNavigation } from "@react-navigation/native";
import LocationPermissionGate from "components/SharedComponents/LocationPermissionGate";
import PermissionGateContainer, {
LOCATION_PERMISSIONS,
permissionResultFromMultiple,
WRITE_MEDIA_PERMISSIONS
}
from "components/SharedComponents/PermissionGateContainer";
READ_WRITE_MEDIA_PERMISSIONS
} from "components/SharedComponents/PermissionGateContainer.tsx";
import { View } from "components/styledComponents";
import React, {
useCallback, useEffect, useRef, useState
@@ -13,11 +9,6 @@ import React, {
import { StatusBar } from "react-native";
import DeviceInfo from "react-native-device-info";
import Orientation from "react-native-orientation-locker";
import {
checkMultiple,
Permission,
RESULTS
} from "react-native-permissions";
import { Camera, CameraDevice } from "react-native-vision-camera";
// import { log } from "sharedHelpers/logger";
import { useTranslation } from "sharedHooks";
@@ -25,6 +16,7 @@ import useDeviceOrientation, {
LANDSCAPE_LEFT,
LANDSCAPE_RIGHT
} from "sharedHooks/useDeviceOrientation.ts";
import useLocationPermission from "sharedHooks/useLocationPermission.tsx";
import AICamera from "./AICamera/AICamera";
import usePrepareStoreAndNavigate from "./hooks/usePrepareStoreAndNavigate";
@@ -69,7 +61,16 @@ const CameraWithDevice = ( {
// permission gate on ObsEdit
const [addPhotoPermissionGateWasClosed, setAddPhotoPermissionGateWasClosed] = useState( false );
const isFocused = useIsFocused( );
const [locationPermissionGranted, setLocationPermissionGranted] = useState( false );
// Check if location permission granted b/c usePrepareStoreAndNavigate and
// useUserLocation need to know if permission has been granted to fetch the
// user's location while the camera is active. We don't want to *ask* for
// permission here b/c we want to avoid overloading a new user with
// permission requests and they will just have seen the camera permission
// request before landing here, so it's ok if we're not fetching the
// location here for the user's first observation (suggestions might be a
// bit off and we'll fetch the obs coordinates on ObsEdit)
const { hasPermissions } = useLocationPermission( );
// logger.debug( `isFocused: ${isFocused}` );
const prepareStoreAndNavigate = usePrepareStoreAndNavigate( {
@@ -80,7 +81,7 @@ const CameraWithDevice = ( {
// up and use that to pass along to Suggestions when the user navigates
// there... but we only want to do that while the camera has focus and we
// have permission
shouldFetchLocation: isFocused && locationPermissionGranted
shouldFetchLocation: isFocused && !!hasPermissions
} );
const isLandscapeMode = [LANDSCAPE_LEFT, LANDSCAPE_RIGHT].includes( deviceOrientation );
@@ -154,66 +155,25 @@ const CameraWithDevice = ( {
return unsubscribe;
}, [navigation] );
// Check if location permission granted b/c usePrepareStoreAndNavigate and
// useUserLocation need to know if permission has been granted to fetch the
// user's location while the camera is active. We don't want to *ask* for
// permission here b/c we want to avoid overloading a new user with
// permission requests and they will just have seen the camera permission
// request before landing here, so it's ok if we're not fetching the
// location here for the user's first observation (suggestions might be a
// bit off and we'll fetch the obs coordinates on ObsEdit)
useEffect( ( ) => {
async function checkLocationPermissions() {
const permissionsResult = permissionResultFromMultiple(
await checkMultiple( LOCATION_PERMISSIONS as Permission[] )
);
if ( permissionsResult === RESULTS.GRANTED ) {
setLocationPermissionGranted( true );
} else {
console.warn(
"Location permissions have not been granted. You probably need to use a PermissionGate"
);
}
}
checkLocationPermissions( );
}, [] );
return (
<View
className={`flex-1 bg-black ${flexDirection}`}
testID="CameraWithDevice"
>
{/* TODO why is this even here? The camera doesn't need location
permissions. Suggestions does. ~~~~kueda20240611 */}
{/* a weird quirk of react-native-modal is you can show subsequent modals
when a modal is nested in another modal. location permission is shown first
because the save photo modal pops up a second system alert on iOS asking
how much access to give */}
<LocationPermissionGate
permissionNeeded={checkmarkTapped}
<PermissionGateContainer
permissions={READ_WRITE_MEDIA_PERMISSIONS}
title={t( "Save-photos-to-your-gallery" )}
titleDenied={t( "Save-photos-to-your-gallery" )}
body={t( "iNaturalist-can-save-photos-you-take-in-the-app-to-your-devices-gallery" )}
buttonText={t( "SAVE-PHOTOS" )}
icon="gallery"
image={require( "images/birger-strahl-ksiGE4hMiso-unsplash.jpg" )}
onModalHide={( ) => setAddPhotoPermissionGateWasClosed( true )}
onPermissionGranted={onPhotoPermissionGranted}
onPermissionDenied={onPhotoPermissionDenied}
withoutNavigation
onPermissionGranted={( ) => {
// This probably doesn't do anything, but on the off chance we're
// able to grab coordinates immediately after the user grants
// permission, that will probably yield better suggestions on the
// next screen than nothing.
setLocationPermissionGranted( true );
}}
>
<PermissionGateContainer
permissions={WRITE_MEDIA_PERMISSIONS}
titleDenied={t( "Save-photos-to-your-gallery" )}
body={t( "iNaturalist-can-save-photos-you-take-in-the-app-to-your-devices-gallery" )}
buttonText={t( "SAVE-PHOTOS" )}
icon="gallery"
image={require( "images/birger-strahl-ksiGE4hMiso-unsplash.jpg" )}
onModalHide={( ) => setAddPhotoPermissionGateWasClosed( true )}
onPermissionGranted={onPhotoPermissionGranted}
onPermissionDenied={onPhotoPermissionDenied}
withoutNavigation
permissionNeeded={checkmarkTapped}
/>
</LocationPermissionGate>
permissionNeeded={checkmarkTapped}
/>
{cameraType === "Standard"
? (
<StandardCamera

View File

@@ -1,6 +1,6 @@
// @flow
import TakePhoto from "components/Camera/Buttons/TakePhoto";
import TakePhoto from "components/Camera/Buttons/TakePhoto.tsx";
import { MediaNavButtons } from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
@@ -15,7 +15,7 @@ type Props = {
handleClose: Function,
photosTaken: boolean,
rotatableAnimatedStyle: Object,
takePhoto: Function
takePhoto: () => Promise<void>,
}
const CameraNavButtons = ( {

View File

@@ -13,7 +13,7 @@ import Animated from "react-native-reanimated";
const isTablet = DeviceInfo.isTablet();
type Props = {
takePhoto: Function,
takePhoto: () => Promise<void>,
handleClose: Function,
disabled: boolean,
photosTaken: boolean,

View File

@@ -47,7 +47,7 @@ type Props = {
rotatableAnimatedStyle: Object,
showPrediction?: boolean,
showZoomButton: boolean,
takePhoto: Function,
takePhoto: () => Promise<void>,
takePhotoOptions: Object,
toggleFlash: Function,
zoomTextValue: string

View File

@@ -4,12 +4,15 @@ import { refresh } from "@react-native-community/netinfo";
import classnames from "classnames";
import ExploreFiltersModal from "components/Explore/Modals/ExploreFiltersModal";
import {
Body2,
Button,
INatIconButton,
OfflineNotice,
RadioButtonSheet,
ViewWrapper
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import { PLACE_MODE } from "providers/ExploreContext.tsx";
import type { Node } from "react";
import React, { useState } from "react";
import { Alert } from "react-native";
@@ -56,7 +59,11 @@ type Props = {
updateTaxon: Function,
updateLocation: Function,
updateUser: Function,
updateProject: Function
updateProject: Function,
// TODO: change to PLACE_MODE in Typescript
placeMode: string,
hasLocationPermissions: ?boolean,
requestLocationPermissions: Function
}
const Explore = ( {
@@ -73,7 +80,10 @@ const Explore = ( {
updateTaxon,
updateLocation,
updateUser,
updateProject
updateProject,
placeMode,
hasLocationPermissions,
requestLocationPermissions
}: Props ): Node => {
const theme = useTheme( );
const { t } = useTranslation( );
@@ -107,6 +117,69 @@ const Explore = ( {
/>
);
const renderMainContent = ( ) => {
if ( !isOnline ) {
return (
<OfflineNotice
onPress={() => refresh()}
/>
);
}
// hasLocationPermissions === undefined means we haven't checked for location permissions yet
if ( placeMode === PLACE_MODE.NEARBY && hasLocationPermissions === false ) {
return (
<View className="flex-1 justify-center p-4">
<View className="items-center">
<Body2>{t( "To-view-nearby-organisms-please-enable-location" )}</Body2>
</View>
<Button
className="mt-5"
text={t( "ALLOW-LOCATION-ACCESS" )}
accessibilityHint={t( "Opens-location-permission-prompt" )}
level="focus"
onPress={( ) => requestLocationPermissions()}
/>
</View>
);
}
return (
<View className="flex-1">
{currentExploreView === "observations" && (
<ObservationsView
count={count}
layout={layout}
queryParams={queryParams}
updateCount={updateCount}
/>
)}
{currentExploreView === "species" && (
<SpeciesView
count={count}
isOnline={isOnline}
queryParams={queryParams}
updateCount={updateCount}
/>
)}
{currentExploreView === "observers" && (
<ObserversView
count={count}
isOnline={isOnline}
queryParams={queryParams}
updateCount={updateCount}
/>
)}
{currentExploreView === "identifiers" && (
<IdentifiersView
count={count}
isOnline={isOnline}
queryParams={queryParams}
updateCount={updateCount}
/>
)}
</View>
);
};
const renderSheet = () => {
if ( !showExploreBottomSheet ) {
return null;
@@ -170,48 +243,7 @@ const Explore = ( {
updateObservationsView={writeLayoutToStorage}
/>
)}
{ isOnline
? (
<View className="flex-1">
{currentExploreView === "observations" && (
<ObservationsView
count={count}
layout={layout}
queryParams={queryParams}
updateCount={updateCount}
/>
)}
{currentExploreView === "species" && (
<SpeciesView
count={count}
isOnline={isOnline}
queryParams={queryParams}
updateCount={updateCount}
/>
)}
{currentExploreView === "observers" && (
<ObserversView
count={count}
isOnline={isOnline}
queryParams={queryParams}
updateCount={updateCount}
/>
)}
{currentExploreView === "identifiers" && (
<IdentifiersView
count={count}
isOnline={isOnline}
queryParams={queryParams}
updateCount={updateCount}
/>
)}
</View>
)
: (
<OfflineNotice
onPress={() => refresh()}
/>
)}
{renderMainContent()}
{isDebug && (
<INatIconButton
icon="triangle-exclamation"

View File

@@ -8,7 +8,8 @@ import {
} from "providers/ExploreContext.tsx";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import { useCurrentUser, useIsConnected, useTranslation } from "sharedHooks";
import { useCurrentUser, useIsConnected } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission.tsx";
import useStore from "stores/useStore";
import Explore from "./Explore";
@@ -17,30 +18,34 @@ import useHeaderCount from "./hooks/useHeaderCount";
import useParams from "./hooks/useParams";
const ExploreContainerWithContext = ( ): Node => {
const { t } = useTranslation( );
const navigation = useNavigation( );
const isOnline = useIsConnected( );
const setStoredParams = useStore( state => state.setStoredParams );
const {
hasPermissions: hasLocationPermissions,
renderPermissionsGate,
requestPermissions: requestLocationPermissions
} = useLocationPermission( );
const currentUser = useCurrentUser();
const { state, dispatch, makeSnapshot } = useExplore();
const [showFiltersModal, setShowFiltersModal] = useState( false );
const worldwidePlaceText = t( "Worldwide" );
useParams( );
const updateLocation = ( place: Object ) => {
if ( place === "worldwide" ) {
dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_WORLDWIDE } );
dispatch( {
type: EXPLORE_ACTION.SET_PLACE,
placeId: null,
placeGuess: worldwidePlaceText
placeId: null
} );
} else {
navigation.setParams( { place } );
dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_PLACE } );
dispatch( {
type: EXPLORE_ACTION.SET_PLACE,
place,
@@ -93,24 +98,30 @@ const ExploreContainerWithContext = ( ): Node => {
}, [navigation, setStoredParams, state] );
return (
<Explore
closeFiltersModal={closeFiltersModal}
count={count}
hideBackButton={false}
filterByIconicTaxonUnknown={
() => dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
}
isOnline={isOnline}
loadingStatus={loadingStatus}
openFiltersModal={openFiltersModal}
queryParams={queryParams}
showFiltersModal={showFiltersModal}
updateCount={updateCount}
updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
/>
<>
<Explore
closeFiltersModal={closeFiltersModal}
count={count}
hideBackButton={false}
filterByIconicTaxonUnknown={
() => dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
}
isOnline={isOnline}
loadingStatus={loadingStatus}
openFiltersModal={openFiltersModal}
queryParams={queryParams}
showFiltersModal={showFiltersModal}
updateCount={updateCount}
updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
placeMode={state.placeMode}
hasLocationPermissions={hasLocationPermissions}
requestLocationPermissions={requestLocationPermissions}
/>
{renderPermissionsGate( )}
</>
);
};

View File

@@ -19,6 +19,7 @@ import { Surface, useTheme } from "react-native-paper";
import { useTranslation } from "sharedHooks";
import colors from "styles/tailwindColors";
import placeGuessText from "../helpers/placeGuessText";
import HeaderCount from "./HeaderCount";
type Props = {
@@ -49,10 +50,11 @@ const Header = ( {
const { state, numberOfFilters } = useExplore( );
const { taxon } = state;
const iconicTaxonNames = state.iconic_taxa || [];
const placeGuess = state.place_guess;
const [showTaxonSearch, setShowTaxonSearch] = useState( false );
const [showLocationSearch, setShowLocationSearch] = useState( false );
const placeGuess = placeGuessText( state.placeMode, t, state.place_guess );
const surfaceStyle = {
backgroundColor: theme.colors.primary,
borderBottomLeftRadius: 20,

View File

@@ -39,10 +39,6 @@ const MapView = ( {
const {
onPanDrag,
onPermissionBlocked,
onPermissionDenied,
onPermissionGranted,
permissionRequested,
onZoomToNearby,
redoSearchInMapArea,
region,
@@ -153,10 +149,6 @@ const MapView = ( {
switchMapTypeButtonClassName="left-20 bottom-20"
tileMapParams={tileMapParams}
withPressableObsTiles={tileMapParams !== null}
onPermissionBlocked={onPermissionBlocked}
onPermissionDenied={onPermissionDenied}
onPermissionGranted={onPermissionGranted}
permissionRequested={permissionRequested}
currentLocationZoomLevel={15}
/>
</View>

View File

@@ -44,6 +44,7 @@ import { useCurrentUser, useTranslation } from "sharedHooks";
import { getShadowForColor } from "styles/global";
import colors from "styles/tailwindColors";
import placeGuessText from "../helpers/placeGuessText";
import ExploreLocationSearchModal from "./ExploreLocationSearchModal";
import ExploreProjectSearchModal from "./ExploreProjectSearchModal";
import ExploreTaxonSearchModal from "./ExploreTaxonSearchModal";
@@ -86,8 +87,7 @@ const FilterModal = ( {
differsFromSnapshot,
discardChanges,
isNotInitialState,
numberOfFilters,
defaultExploreLocation
numberOfFilters
} = useExplore();
const {
casual,
@@ -108,6 +108,7 @@ const FilterModal = ( {
observed_on: observedOn,
photoLicense,
place_guess: placeGuess,
placeMode,
project,
researchGrade,
reviewedFilter,
@@ -667,8 +668,7 @@ const FilterModal = ( {
<Body3
accessibilityRole="button"
onPress={async ( ) => {
const exploreLocation = await defaultExploreLocation( );
dispatch( { type: EXPLORE_ACTION.RESET, exploreLocation } );
dispatch( { type: EXPLORE_ACTION.RESET } );
}}
>
{t( "Reset-verb" )}
@@ -741,31 +741,19 @@ const FilterModal = ( {
<View className="mb-7">
<Heading4 className="mb-5">{t( "LOCATION" )}</Heading4>
<View className="mb-5">
{placeGuess
? (
<View>
<View className="flex-row items-center mb-5">
<INatIcon name="location" size={15} />
<Body3 className="ml-4">{placeGuess}</Body3>
</View>
<Button
text={t( "EDIT-LOCATION" )}
onPress={() => {
setShowLocationSearchModal( true );
}}
accessibilityLabel={t( "Edit" )}
/>
</View>
)
: (
<Button
text={t( "SEARCH-FOR-A-LOCATION" )}
onPress={() => {
setShowLocationSearchModal( true );
}}
accessibilityLabel={t( "Search" )}
/>
)}
<View>
<View className="flex-row items-center mb-5">
<INatIcon name="location" size={15} />
<Body3 className="ml-4">{placeGuessText( placeMode, t, placeGuess )}</Body3>
</View>
<Button
text={t( "EDIT-LOCATION" )}
onPress={() => {
setShowLocationSearchModal( true );
}}
accessibilityLabel={t( "Edit" )}
/>
</View>
</View>
</View>

View File

@@ -1,7 +1,6 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import LocationPermissionGate from "components/SharedComponents/LocationPermissionGate";
import {
EXPLORE_ACTION,
ExploreProvider,
@@ -9,11 +8,11 @@ import {
} from "providers/ExploreContext.tsx";
import type { Node } from "react";
import React, {
useCallback,
useEffect,
useState
} from "react";
import { useCurrentUser, useIsConnected, useTranslation } from "sharedHooks";
import { useCurrentUser, useIsConnected } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission.tsx";
import useStore from "stores/useStore";
import Explore from "./Explore";
@@ -22,29 +21,32 @@ import useHeaderCount from "./hooks/useHeaderCount";
const RootExploreContainerWithContext = ( ): Node => {
const navigation = useNavigation( );
const { t } = useTranslation( );
const isOnline = useIsConnected( );
const currentUser = useCurrentUser( );
const rootStoredParams = useStore( state => state.rootStoredParams );
const setRootStoredParams = useStore( state => state.setRootStoredParams );
const worldwidePlaceText = t( "Worldwide" );
const {
hasPermissions: hasLocationPermissions,
renderPermissionsGate,
requestPermissions: requestLocationPermissions
} = useLocationPermission( );
const {
state, dispatch, makeSnapshot, defaultExploreLocation
state, dispatch, makeSnapshot
} = useExplore( );
const [showFiltersModal, setShowFiltersModal] = useState( false );
const updateLocation = ( place: Object ) => {
if ( place === "worldwide" ) {
dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_WORLDWIDE } );
dispatch( {
type: EXPLORE_ACTION.SET_PLACE,
placeId: null,
placeGuess: worldwidePlaceText
placeId: null
} );
} else {
navigation.setParams( { place } );
dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_PLACE } );
dispatch( {
type: EXPLORE_ACTION.SET_PLACE,
place,
@@ -90,24 +92,6 @@ const RootExploreContainerWithContext = ( ): Node => {
makeSnapshot( );
};
const onPermissionGranted = useCallback( async ( ) => {
const exploreLocation = await defaultExploreLocation( );
dispatch( {
type: EXPLORE_ACTION.SET_EXPLORE_LOCATION,
exploreLocation
} );
}, [
defaultExploreLocation,
dispatch
] );
const resetToWorldWide = useCallback( ( ) => {
dispatch( {
type: EXPLORE_ACTION.SET_PLACE,
placeGuess: worldwidePlaceText
} );
}, [dispatch, worldwidePlaceText] );
useEffect( ( ) => {
navigation.addListener( "focus", ( ) => {
const storedState = Object.keys( rootStoredParams ).length > 0 || false;
@@ -141,14 +125,11 @@ const RootExploreContainerWithContext = ( ): Node => {
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
placeMode={state.placeMode}
hasLocationPermissions={hasLocationPermissions}
requestLocationPermissions={requestLocationPermissions}
/>
<LocationPermissionGate
permissionNeeded
onPermissionGranted={onPermissionGranted}
onPermissionDenied={resetToWorldWide}
onPermissionBlocked={resetToWorldWide}
withoutNavigation
/>
{renderPermissionsGate( )}
</>
);
};

View File

@@ -9,7 +9,6 @@ import {
SearchBar,
ViewWrapper
} from "components/SharedComponents";
import LocationPermissionGate from "components/SharedComponents/LocationPermissionGate";
import { Pressable, View } from "components/styledComponents";
import inatPlaceTypes from "dictionaries/places";
import {
@@ -23,6 +22,7 @@ import React, {
} from "react";
import { FlatList } from "react-native";
import { useAuthenticatedQuery, useTranslation } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission.tsx";
import { getShadowForColor } from "styles/global";
import colors from "styles/tailwindColors";
@@ -40,7 +40,8 @@ const ExploreLocationSearch = ( { closeModal, updateLocation }: Props ): Node =>
const { dispatch, defaultExploreLocation } = useExplore( );
const [locationName, setLocationName] = useState( "" );
const [permissionNeeded, setPermissionNeeded] = useState( false );
const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission( );
const resetPlace = useCallback(
( ) => {
@@ -94,6 +95,25 @@ const ExploreLocationSearch = ( { closeModal, updateLocation }: Props ): Node =>
const data = placeResults || [];
const setNearbyLocation = useCallback( ( ) => {
async function getNearbyLocation( ) {
const exploreLocation = await defaultExploreLocation( );
// exploreLocation has a placeMode already
// dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_NEARBY } );
dispatch( { type: EXPLORE_ACTION.SET_EXPLORE_LOCATION, exploreLocation } );
closeModal();
}
getNearbyLocation( );
}, [dispatch, defaultExploreLocation, closeModal] );
const onNearbyPressed = () => {
if ( !hasPermissions ) {
requestPermissions( );
} else {
setNearbyLocation( );
}
};
return (
<ViewWrapper testID="explore-location-search">
<View className="flex-row justify-center p-5 bg-white">
@@ -107,7 +127,7 @@ const ExploreLocationSearch = ( { closeModal, updateLocation }: Props ): Node =>
/>
<Heading4>{t( "SEARCH-LOCATION" )}</Heading4>
<Body3 onPress={resetPlace} className="absolute top-4 right-4">
{t( "Reset" )}
{t( "Reset-verb" )}
</Body3>
</View>
<View
@@ -124,7 +144,7 @@ const ExploreLocationSearch = ( { closeModal, updateLocation }: Props ): Node =>
<View className="flex-row px-3 mt-5 justify-evenly">
<Button
className="w-1/2"
onPress={( ) => setPermissionNeeded( true )}
onPress={onNearbyPressed}
text={t( "NEARBY" )}
/>
<View className="px-2" />
@@ -141,22 +161,7 @@ const ExploreLocationSearch = ( { closeModal, updateLocation }: Props ): Node =>
renderItem={renderItem}
keyExtractor={item => item.id}
/>
<LocationPermissionGate
permissionNeeded={permissionNeeded}
withoutNavigation
onPermissionGranted={async ( ) => {
setPermissionNeeded( false );
const exploreLocation = await defaultExploreLocation( );
dispatch( { type: EXPLORE_ACTION.SET_EXPLORE_LOCATION, exploreLocation } );
closeModal();
}}
onPermissionDenied={( ) => {
setPermissionNeeded( false );
}}
onPermissionBlocked={( ) => {
setPermissionNeeded( false );
}}
/>
{renderPermissionsGate( { onPermissionGranted: setNearbyLocation } )}
</ViewWrapper>
);
};

View File

@@ -118,6 +118,7 @@ const mapParamsToAPI = ( params: Object, currentUser: Object ): Object => {
delete filteredParams.taxon;
delete filteredParams.place_guess;
delete filteredParams.placeMode;
delete filteredParams.user;
delete filteredParams.project;
delete filteredParams.sortBy;

View File

@@ -0,0 +1,25 @@
import { PLACE_MODE } from "providers/ExploreContext.tsx";
// eslint-disable-next-line max-len
function placeGuessText( placeMode: PLACE_MODE, t: ( _key: string ) => string, exploreStatePlaceGuess: string ): string {
let placeGuess = "";
switch ( placeMode ) {
case PLACE_MODE.NEARBY:
placeGuess = t( "Nearby" );
break;
case PLACE_MODE.WORLDWIDE:
placeGuess = t( "Worldwide" );
break;
case PLACE_MODE.MAP_AREA:
placeGuess = t( "Map-Area" );
break;
case PLACE_MODE.PLACE:
placeGuess = exploreStatePlaceGuess;
break;
default:
break;
}
return placeGuess;
}
export default placeGuessText;

View File

@@ -1,7 +1,7 @@
import { useFocusEffect, useRoute } from "@react-navigation/native";
import { RealmContext } from "providers/contexts";
import {
EXPLORE_ACTION,
PLACE_MODE,
useExplore
} from "providers/ExploreContext.tsx";
import {
@@ -9,41 +9,34 @@ import {
} from "react";
import { BoundingBox, Region } from "react-native-maps";
// import { log } from "sharedHelpers/logger";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import { useTranslation } from "sharedHooks";
import { initialMapRegion } from "stores/createExploreSlice.ts";
import useCurrentMapRegion from "./useCurrentMapRegion";
// const logger = log.extend( "useMapLocation" );
const { useRealm } = RealmContext;
const useMapLocation = ( ) => {
const { params } = useRoute( );
const worldwide = params?.worldwide;
const realm = useRealm( );
const { dispatch, state } = useExplore( );
const [mapBoundaries, setMapBoundaries] = useState<{
swlat: number | undefined;
swlng: number | undefined;
nelat: number | undefined;
nelng: number | undefined;
place_guess: string;
}>( );
const [showMapBoundaryButton, setShowMapBoundaryButton] = useState( false );
const [permissionRequested, setPermissionRequested] = useState<boolean>( );
const { currentMapRegion, setCurrentMapRegion } = useCurrentMapRegion( );
const place = state?.place;
const hasPlace = state.swlat || state.place_id || state.lat;
const [startAtNearby, setStartAtNearby] = useState( !hasPlace && !worldwide );
const { t } = useTranslation( );
const onPanDrag = ( ) => setShowMapBoundaryButton( true );
const mapWasReset = state.place_guess === t( "Nearby" ) || state.place_guess === t( "Worldwide" );
const mapWasReset = state.placeMode === PLACE_MODE.NEARBY
|| state.placeMode === PLACE_MODE.WORLDWIDE;
const placeIdWasSet = state.place_id;
// eslint-disable-next-line max-len
@@ -52,15 +45,13 @@ const useMapLocation = ( ) => {
swlat: boundaries?.southWest?.latitude,
swlng: boundaries?.southWest?.longitude,
nelat: boundaries?.northEast?.latitude,
nelng: boundaries?.northEast?.longitude,
place_guess: t( "Map-Area" )
nelng: boundaries?.northEast?.longitude
};
setMapBoundaries( boundaryAPIParams );
setCurrentMapRegion( newRegion );
return boundaryAPIParams;
}, [
t,
setMapBoundaries,
setCurrentMapRegion
] );
@@ -68,6 +59,7 @@ const useMapLocation = ( ) => {
const redoSearchInMapArea = ( ) => {
if ( !mapBoundaries ) return;
setShowMapBoundaryButton( false );
dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_MAP_AREA } );
dispatch( { type: EXPLORE_ACTION.SET_MAP_BOUNDARIES, mapBoundaries } );
};
@@ -77,26 +69,10 @@ const useMapLocation = ( ) => {
}, [] )
);
useEffect( ( ) => {
// ensure LocationPermissionGate only pops up on fresh install of the app
const localPrefs = realm.objects( "LocalPreferences" )[0];
if ( !localPrefs || localPrefs?.explore_location_permission_shown === false ) {
// logger.debug( "showing LocationPermissionGate in Explore, first install only" );
setPermissionRequested( true );
safeRealmWrite( realm, ( ) => {
if ( !localPrefs ) {
realm.create( "LocalPreferences", { explore_location_permission_shown: true } );
} else {
localPrefs.explore_location_permission_shown = true;
}
}, "setting explore location permission shown to true in ExploreContainer" );
}
}, [realm] );
// eslint-disable-next-line max-len
const onZoomToNearby = useCallback( async ( newRegion: Region, nearbyBoundaries: BoundingBox | undefined ) => {
const newMapBoundaries = await updateMapBoundaries( newRegion, nearbyBoundaries );
newMapBoundaries.place_guess = t( "Nearby" );
dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_NEARBY } );
dispatch( {
type: EXPLORE_ACTION.SET_MAP_BOUNDARIES,
mapBoundaries: newMapBoundaries
@@ -104,28 +80,10 @@ const useMapLocation = ( ) => {
setStartAtNearby( false );
}, [
dispatch,
updateMapBoundaries,
t
updateMapBoundaries
] );
// PermissionGate callbacks need to use useCallback, otherwise they'll
// trigger re-renders if/when they change
const onPermissionGranted = useCallback( ( ) => {
// logger.debug( "onPermissionGranted" );
setPermissionRequested( false );
}, [setPermissionRequested] );
const onPermissionBlocked = useCallback( ( ) => {
// logger.debug( "onPermissionBlocked" );
setPermissionRequested( false );
}, [setPermissionRequested] );
const onPermissionDenied = useCallback( ( ) => {
// logger.debug( "onPermissionDenied" );
setPermissionRequested( false );
}, [setPermissionRequested] );
const previousPlaceGuess = useRef( state.place_guess );
const previousPlaceGuess = useRef( state.placeMode );
useEffect( ( ) => {
// region gets set when a user is navigating from ExploreLocationSearch
if ( placeIdWasSet ) {
@@ -137,9 +95,9 @@ const useMapLocation = ( ) => {
longitude: coordinates[0]
} );
} else if ( mapWasReset ) {
// map gets set or reset back to nearby/worldwide, but only if the place_guess
// map gets set or reset back to nearby/worldwide, but only if the placeMode
// has changed
if ( previousPlaceGuess.current === state.place_guess ) {
if ( previousPlaceGuess.current === state.placeMode ) {
return;
}
// logger.debug( "setting initial nearby or worldwide map region" );
@@ -148,7 +106,7 @@ const useMapLocation = ( ) => {
latitude: state?.lat,
longitude: state?.lng
} );
previousPlaceGuess.current = state.place_guess;
previousPlaceGuess.current = state.placeMode;
}
}, [
mapWasReset,
@@ -160,11 +118,7 @@ const useMapLocation = ( ) => {
return {
onPanDrag,
onPermissionBlocked,
onPermissionDenied,
onPermissionGranted,
onZoomToNearby,
permissionRequested,
redoSearchInMapArea,
region: currentMapRegion,
showMapBoundaryButton,

View File

@@ -6,25 +6,21 @@ import {
useExplore
} from "providers/ExploreContext.tsx";
import { useCallback, useEffect } from "react";
import { useTranslation } from "sharedHooks";
import useStore from "stores/useStore";
const useParams = ( ): Object => {
const { t } = useTranslation( );
const { params } = useRoute( );
const { dispatch, defaultExploreLocation } = useExplore( );
const storedParams = useStore( state => state.storedParams );
const worldwidePlaceText = t( "Worldwide" );
const updateContextWithParams = useCallback( async ( storedState = { } ) => {
const setWorldwide = ( ) => {
dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_WORLDWIDE } );
dispatch( {
type: EXPLORE_ACTION.SET_PLACE,
storedState,
place: null,
placeId: null,
placeGuess: worldwidePlaceText
placeId: null
} );
};
@@ -33,6 +29,8 @@ const useParams = ( ): Object => {
}
if ( params?.nearby ) {
const exploreLocation = await defaultExploreLocation( );
// exploreLocation has a placeMode already
// dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_NEARBY } );
dispatch( {
type: EXPLORE_ACTION.SET_EXPLORE_LOCATION,
exploreLocation
@@ -48,6 +46,7 @@ const useParams = ( ): Object => {
} );
}
if ( params?.place ) {
dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_PLACE } );
dispatch( {
type: EXPLORE_ACTION.SET_PLACE,
storedState,
@@ -75,8 +74,7 @@ const useParams = ( ): Object => {
}, [
dispatch,
params,
defaultExploreLocation,
worldwidePlaceText
defaultExploreLocation
] );
useEffect( ( ) => {

View File

@@ -1,5 +1,3 @@
// @flow
import { useQueryClient } from "@tanstack/react-query";
import fetchSearchResults from "api/search";
import {
@@ -7,27 +5,33 @@ import {
SearchBar
} from "components/SharedComponents";
import { Pressable, View } from "components/styledComponents";
import type { Node } from "react";
import React, { useRef } from "react";
import { Keyboard } from "react-native";
import { Keyboard, TextInput } from "react-native";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import { getShadowForColor } from "styles/global";
import colors from "styles/tailwindColors";
const DROP_SHADOW = getShadowForColor( colors.darkGray );
type Props = {
locationName: string,
updateLocationName: Function,
selectPlaceResult: Function,
hidePlaceResults: boolean
};
interface Place {
id: string;
display_name: string;
point_geojson: {
coordinates: [number, number];
};
}
interface Props {
locationName: string;
updateLocationName: ( _text: string ) => void;
selectPlaceResult: ( _place: Place ) => void;
hidePlaceResults: boolean;
}
const LocationSearch = ( {
locationName = "", updateLocationName, selectPlaceResult, hidePlaceResults
}: Props ): Node => {
}: Props ) => {
const queryClient = useQueryClient( );
const locationInput = useRef( );
const locationInput = useRef<TextInput>( );
// this seems necessary for clearing the cache between searches
queryClient.invalidateQueries( { queryKey: ["fetchSearchResults"] } );
@@ -46,6 +50,7 @@ const LocationSearch = ( {
return (
<>
<SearchBar
autoFocus={false}
handleTextChange={locationText => {
// only update location name when a user is typing,
// not when a user selects a location from the dropdown
@@ -63,7 +68,7 @@ const LocationSearch = ( {
className="absolute top-[65px] right-[26px] left-[26px] bg-white rounded-lg z-100"
style={DROP_SHADOW}
>
{!hidePlaceResults && placeResults?.map( place => (
{!hidePlaceResults && placeResults?.map( ( place: Place ) => (
<Pressable
accessibilityRole="button"
key={place.id}

View File

@@ -65,7 +65,6 @@ const LocationSection = ( { observation }: Props ): Node => {
obsLongitude={longitude}
obscured={isObscured}
openMapScreen={openMapScreen}
permissionRequested={false}
positionalAccuracy={positionalAccuracy}
scrollEnabled={false}
showLocationIndicator

View File

@@ -7,11 +7,10 @@ import {
ActivityIndicator,
Body3, Body4, Heading4, INatIcon
} from "components/SharedComponents";
import LocationPermissionGate from "components/SharedComponents/LocationPermissionGate";
import { MAX_SOUNDS_ALLOWED } from "components/SoundRecorder/SoundRecorder";
import { Pressable, View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import React, { useCallback } from "react";
import { useTheme } from "react-native-paper";
import useTranslation from "sharedHooks/useTranslation";
@@ -22,11 +21,7 @@ import AddEvidenceSheet from "./Sheets/AddEvidenceSheet";
type Props = {
currentObservation: Object,
isFetchingLocation: boolean,
locationPermissionNeeded: boolean,
locationTextClassNames: Array<string>,
onLocationPermissionBlocked: Function,
onLocationPermissionDenied: Function,
onLocationPermissionGranted: Function,
passesEvidenceTest: Function,
observationPhotos: Array<Object>,
setShowAddEvidenceSheet: Function,
@@ -39,21 +34,23 @@ type Props = {
uuid: string
}>,
updateObservationKeys: Function,
renderPermissionsGate: Function,
requestPermissions: Function,
hasPermissions: boolean
}
const EvidenceSection = ( {
currentObservation,
isFetchingLocation,
locationPermissionNeeded,
locationTextClassNames,
onLocationPermissionBlocked,
onLocationPermissionDenied,
onLocationPermissionGranted,
passesEvidenceTest,
setShowAddEvidenceSheet,
showAddEvidenceSheet,
observationSounds,
updateObservationKeys
updateObservationKeys,
renderPermissionsGate,
requestPermissions,
hasPermissions
}: Props ): Node => {
const { t } = useTranslation( );
const theme = useTheme( );
@@ -65,8 +62,18 @@ const EvidenceSection = ( {
const obsSounds = currentObservation?.observationSounds || currentObservation?.observation_sounds;
const navigation = useNavigation( );
const navToLocationPicker = ( ) => {
const navToLocationPicker = useCallback( ( ) => {
navigation.navigate( "LocationPicker", { goBackOnSave: true } );
}, [navigation] );
const onLocationPress = ( ) => {
// If we have location permissions, navigate to the location picker
if ( hasPermissions ) {
navToLocationPicker();
} else {
// If we don't have location permissions, request them
requestPermissions( );
}
};
const latitude = currentObservation?.latitude;
@@ -127,7 +134,7 @@ const EvidenceSection = ( {
<Pressable
accessibilityRole="link"
className="flex-row flex-nowrap pb-3"
onPress={navToLocationPicker}
onPress={onLocationPress}
accessibilityLabel={t( "Edit-location" )}
>
<View className="w-[30px] items-center mr-1">
@@ -169,17 +176,19 @@ const EvidenceSection = ( {
}
</View>
</Pressable>
{renderPermissionsGate( {
// If the user does not give location permissions in any form,
// navigate to the location picker (if granted we just continue fetching the location)
onRequestDenied: navToLocationPicker,
onRequestBlocked: navToLocationPicker,
onModalHide: ( ) => {
if ( !hasPermissions ) navToLocationPicker();
}
} )}
<DatePicker
currentObservation={currentObservation}
updateObservationKeys={updateObservationKeys}
/>
<LocationPermissionGate
permissionNeeded={locationPermissionNeeded}
onPermissionGranted={onLocationPermissionGranted}
onPermissionDenied={onLocationPermissionDenied}
onPermissionBlocked={onLocationPermissionBlocked}
withoutNavigation
/>
</View>
);
};

View File

@@ -12,13 +12,12 @@ import React, {
useCallback,
useEffect,
useMemo,
useRef, useState
useRef,
useState
} from "react";
import {
RESULTS as PERMISSION_RESULTS
} from "react-native-permissions";
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
import useCurrentObservationLocation from "sharedHooks/useCurrentObservationLocation";
import useLocationPermission from "sharedHooks/useLocationPermission.tsx";
import useStore from "stores/useStore";
import EvidenceSection from "./EvidenceSection";
@@ -51,7 +50,8 @@ const EvidenceSectionContainer = ( {
const [
shouldRetryCurrentObservationLocation,
setShouldRetryCurrentObservationLocation
] = useState( false );
] = useState( true );
const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission( );
// Hook version of componentWillUnmount. We use a ref to track mounted
// state (not useState, which might get frozen in a closure for other
@@ -71,14 +71,14 @@ const EvidenceSectionContainer = ( {
const {
hasLocation,
isFetchingLocation,
permissionResult: locationPermissionResult
isFetchingLocation
} = useCurrentObservationLocation(
mountedRef,
currentObservation,
updateObservationKeys,
hasPermissions,
{
retry: shouldRetryCurrentObservationLocation
retry: hasPermissions && shouldRetryCurrentObservationLocation
}
);
@@ -88,10 +88,8 @@ const EvidenceSectionContainer = ( {
useEffect( ( ) => {
if ( latitude ) {
setShouldRetryCurrentObservationLocation( false );
} else if ( locationPermissionResult === "granted" ) {
setShouldRetryCurrentObservationLocation( true );
}
}, [latitude, locationPermissionResult] );
}, [latitude] );
const hasPhotoOrSound = useMemo( ( ) => {
if ( currentObservation?.observationPhotos?.length > 0
@@ -220,16 +218,9 @@ const EvidenceSectionContainer = ( {
setShowAddEvidenceSheet={setShowAddEvidenceSheet}
showAddEvidenceSheet={showAddEvidenceSheet}
observationSounds={observationSounds}
onLocationPermissionGranted={( ) => {
setShouldRetryCurrentObservationLocation( true );
}}
onLocationPermissionDenied={( ) => {
setShouldRetryCurrentObservationLocation( false );
}}
onLocationPermissionBlocked={( ) => {
setShouldRetryCurrentObservationLocation( false );
}}
locationPermissionNeeded={locationPermissionResult === PERMISSION_RESULTS.DENIED}
hasPermissions={hasPermissions}
renderPermissionsGate={renderPermissionsGate}
requestPermissions={requestPermissions}
/>
);
};

View File

@@ -4,8 +4,6 @@ import {
} from "appConstants/paths.ts";
import navigateToObsDetails from "components/ObsDetails/helpers/navigateToObsDetails";
import { ActivityAnimation, ViewWrapper } from "components/SharedComponents";
import PermissionGateContainer, { READ_MEDIA_PERMISSIONS }
from "components/SharedComponents/PermissionGateContainer";
import { t } from "i18next";
import type { Node } from "react";
import React, {
@@ -29,7 +27,6 @@ const MAX_PHOTOS_ALLOWED = 20;
const PhotoGallery = ( ): Node => {
const navigation = useNavigation( );
const [photoGalleryShown, setPhotoGalleryShown] = useState( false );
const [permissionGranted, setPermissionGranted] = useState( false );
const setPhotoImporterState = useStore( state => state.setPhotoImporterState );
const setGroupedPhotos = useStore( state => state.setGroupedPhotos );
const groupedPhotos = useStore( state => state.groupedPhotos );
@@ -195,10 +192,6 @@ const PhotoGallery = ( ): Node => {
params
] );
const onPermissionGranted = () => {
setPermissionGranted( true );
};
useFocusEffect(
React.useCallback( () => {
// This will run when the screen comes into focus.
@@ -206,7 +199,7 @@ const PhotoGallery = ( ): Node => {
// Wait for screen to finish transition
interactionHandle = InteractionManager.runAfterInteractions( () => {
if ( permissionGranted && !photoGalleryShown ) {
if ( !photoGalleryShown ) {
showPhotoGallery();
}
} );
@@ -217,26 +210,13 @@ const PhotoGallery = ( ): Node => {
interactionHandle.cancel();
}
};
}, [permissionGranted, photoGalleryShown, showPhotoGallery] )
}, [photoGalleryShown, showPhotoGallery] )
);
return (
<ViewWrapper testID="PhotoGallery">
<View className="flex-1 w-full h-full justify-center items-center">
<ActivityAnimation />
{!permissionGranted && (
<PermissionGateContainer
permissions={READ_MEDIA_PERMISSIONS}
title={t( "Observe-and-identify-organisms-from-your-gallery" )}
titleDenied={t( "Please-Allow-Gallery-Access" )}
body={t( "Upload-photos-from-your-gallery-and-create-observations" )}
blockedPrompt={t( "Youve-previously-denied-gallery-permissions" )}
buttonText={t( "CHOOSE-PHOTOS" )}
icon="gallery"
image={require( "images/viviana-rishe-j2330n6bg3I-unsplash.jpg" )}
onPermissionGranted={onPermissionGranted}
/>
)}
</View>
</ViewWrapper>
);

View File

@@ -1,9 +1,8 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import {
ActivityIndicator,
Body1,
Body2,
Button,
Heading1,
INatIcon,
@@ -12,8 +11,8 @@ import {
Tabs,
ViewWrapper
} from "components/SharedComponents";
import { Tab } from "components/SharedComponents/Tabs/Tabs.tsx";
import { Pressable, View } from "components/styledComponents";
import type { Node } from "react";
import React, { useEffect } from "react";
import {
FlatList
@@ -22,19 +21,27 @@ import {
useTranslation
} from "sharedHooks";
type Props = {
searchInput: string,
setSearchInput: Function,
tabs: Array<Object>,
currentTabId: string,
projects: Array<Object>,
isLoading: boolean,
memberId: ?number
import { TAB_ID } from "./ProjectsContainer";
interface Props {
searchInput: string;
setSearchInput: ( _text: string ) => void;
tabs: Tab[],
currentTabId: TAB_ID;
projects: Object[],
isLoading: boolean;
memberId?: number;
hasPermissions: boolean | undefined;
requestPermissions: () => void;
}
const Projects = ( {
searchInput, setSearchInput, tabs, currentTabId, projects, isLoading, memberId
}: Props ): Node => {
searchInput,
setSearchInput,
tabs, currentTabId, projects, isLoading, memberId,
hasPermissions,
requestPermissions
}: Props ) => {
const { t } = useTranslation( );
const navigation = useNavigation( );
@@ -86,7 +93,7 @@ const Projects = ( {
}
if ( searchInput.length === 0 ) {
if ( currentTabId === "JOINED" && !memberId ) {
if ( currentTabId === TAB_ID.JOINED && !memberId ) {
return (
<View className="items-center">
<Body1>{t( "You-havent-joined-any-projects-yet" )}</Body1>
@@ -103,6 +110,36 @@ const Projects = ( {
flexGrow: 1,
justifyContent: "center",
alignItems: "center"
} as const;
const renderList = ( ) => {
// hasPermission undefined means we haven't checked for location permissions yet
// false means the user has denied or not yet given location permissions
if ( currentTabId === TAB_ID.NEARBY && hasPermissions === false ) {
return (
<View className="flex-1 justify-center p-4">
<View className="items-center">
<Body2>{t( "To-view-nearby-projects-please-enable-location" )}</Body2>
</View>
<Button
className="mt-5"
text={t( "ALLOW-LOCATION-ACCESS" )}
accessibilityHint={t( "Opens-location-permission-prompt" )}
level="focus"
onPress={( ) => requestPermissions()}
/>
</View>
);
}
return (
<FlatList
contentContainerStyle={projects?.length === 0 && emptyListStyles}
data={projects}
renderItem={renderProject}
testID="Project.list"
ListEmptyComponent={renderEmptyList}
/>
);
};
return (
@@ -122,13 +159,7 @@ const Projects = ( {
<View className="mb-3" />
</>
)}
<FlatList
contentContainerStyle={projects?.length === 0 && emptyListStyles}
data={projects}
renderItem={renderProject}
testID="Project.list"
ListEmptyComponent={renderEmptyList}
/>
{renderList( )}
</ViewWrapper>
);
};

View File

@@ -1,7 +1,5 @@
import { searchProjects } from "api/projects";
import LocationPermissionGate from "components/SharedComponents/LocationPermissionGate";
import _ from "lodash";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import {
useAuthenticatedQuery,
@@ -9,24 +7,30 @@ import {
useTranslation,
useUserLocation
} from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission.tsx";
import Projects from "./Projects";
const JOINED_TAB_ID = "JOINED";
const FEATURED_TAB_ID = "FEATURED";
const NEARBY_TAB_ID = "NEARBY";
export enum TAB_ID {
// eslint-disable-next-line no-unused-vars
JOINED = "JOINED",
// eslint-disable-next-line no-unused-vars
FEATURED = "FEATURED",
// eslint-disable-next-line no-unused-vars
NEARBY = "NEARBY"
}
const ProjectsContainer = ( ): Node => {
const ProjectsContainer = ( ) => {
const [searchInput, setSearchInput] = useState( "" );
const currentUser = useCurrentUser( );
const memberId = currentUser?.id;
const { t } = useTranslation( );
const [apiParams, setApiParams] = useState( { } );
const [currentTabId, setCurrentTabId] = useState( JOINED_TAB_ID );
const [permissionsGranted, setPermissionsGranted] = useState( false );
const [currentTabId, setCurrentTabId] = useState( TAB_ID.JOINED );
const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission();
const { userLocation } = useUserLocation( {
skipName: true,
permissionsGranted
permissionsGranted: hasPermissions
} );
const {
@@ -41,11 +45,11 @@ const ProjectsContainer = ( ): Node => {
);
useEffect( ( ) => {
if ( currentTabId === JOINED_TAB_ID ) {
if ( currentTabId === TAB_ID.JOINED ) {
setApiParams( { member_id: memberId } );
} else if ( currentTabId === FEATURED_TAB_ID ) {
} else if ( currentTabId === TAB_ID.FEATURED ) {
setApiParams( { featured: true } );
} else if ( currentTabId === NEARBY_TAB_ID && userLocation ) {
} else if ( currentTabId === TAB_ID.NEARBY && userLocation ) {
setApiParams( {
lat: userLocation.latitude,
lng: userLocation.longitude
@@ -66,24 +70,24 @@ const ProjectsContainer = ( ): Node => {
const tabs = [
{
id: JOINED_TAB_ID,
id: TAB_ID.JOINED,
text: t( "JOINED" ),
onPress: () => {
setCurrentTabId( JOINED_TAB_ID );
setCurrentTabId( TAB_ID.JOINED );
}
},
{
id: FEATURED_TAB_ID,
id: TAB_ID.FEATURED,
text: t( "FEATURED" ),
onPress: () => {
setCurrentTabId( FEATURED_TAB_ID );
setCurrentTabId( TAB_ID.FEATURED );
}
},
{
id: NEARBY_TAB_ID,
id: TAB_ID.NEARBY,
text: t( "NEARBY" ),
onPress: () => {
setCurrentTabId( NEARBY_TAB_ID );
setCurrentTabId( TAB_ID.NEARBY );
}
}
];
@@ -102,14 +106,10 @@ const ProjectsContainer = ( ): Node => {
projects={projects}
isLoading={isLoading}
memberId={memberId}
hasPermissions={hasPermissions}
requestPermissions={requestPermissions}
/>
<LocationPermissionGate
permissionNeeded={currentTabId === NEARBY_TAB_ID}
withoutNavigation
onPermissionGranted={( ) => setPermissionsGranted( true )}
onPermissionDenied={( ) => setPermissionsGranted( false )}
onPermissionBlocked={( ) => setPermissionsGranted( false )}
/>
{renderPermissionsGate()}
</>
);
};

View File

@@ -1,21 +1,17 @@
// @flow
import PermissionGateContainer, {
LOCATION_PERMISSIONS
} from "components/SharedComponents/PermissionGateContainer";
} from "components/SharedComponents/PermissionGateContainer.tsx";
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import React, { PropsWithChildren } from "react";
type Props = {
children?: Node,
permissionNeeded?: boolean,
onModalHide?: Function,
onPermissionGranted?: Function,
onPermissionDenied?: Function,
onPermissionBlocked?: Function,
withoutNavigation?: boolean
};
interface Props extends PropsWithChildren {
permissionNeeded?: boolean;
onModalHide?: () => void;
onPermissionBlocked?: () => void;
onPermissionDenied?: () => void;
onPermissionGranted?: () => void;
withoutNavigation?: boolean;
}
const LocationPermissionGate = ( {
children,
@@ -25,12 +21,13 @@ const LocationPermissionGate = ( {
onPermissionDenied,
onPermissionBlocked,
withoutNavigation
}: Props ): Node => (
}: Props ) => (
<PermissionGateContainer
permissions={LOCATION_PERMISSIONS}
title={t( "Get-more-accurate-suggestions-create-useful-data" )}
titleDenied={t( "Please-allow-Location-Access" )}
body={t( "iNaturalist-uses-your-location-to-give-you" )}
body2={t( "Youre-always-in-control-of-the-location-privacy" )}
blockedPrompt={t( "Youve-previously-denied-location-permissions" )}
buttonText={t( "USE-LOCATION" )}
icon="map-marker-outline"

View File

@@ -11,25 +11,40 @@ interface Props {
currentLocationButtonClassName?: string;
handlePress: () => void;
showCurrentLocationButton?: boolean;
hasPermissions: boolean | undefined;
renderPermissionsGate: () => React.JSX.Element;
requestPermissions: () => void;
}
const CurrentLocationButton = ( {
currentLocationButtonClassName,
handlePress,
showCurrentLocationButton
showCurrentLocationButton,
hasPermissions,
renderPermissionsGate,
requestPermissions
}: Props ) => {
const { t } = useTranslation( );
const onPress = ( ) => {
if ( !hasPermissions ) {
requestPermissions( );
}
handlePress( );
};
return showCurrentLocationButton && (
<INatIconButton
icon="location-crosshairs"
className={classnames(
"absolute bottom-5 right-5 bg-white rounded-full",
currentLocationButtonClassName
)}
style={DROP_SHADOW}
accessibilityLabel={t( "Zoom-to-current-location" )}
onPress={handlePress}
/>
<>
<INatIconButton
icon="location-crosshairs"
className={classnames(
"absolute bottom-5 right-5 bg-white rounded-full",
currentLocationButtonClassName
)}
style={DROP_SHADOW}
accessibilityLabel={t( "Zoom-to-current-location" )}
onPress={onPress}
/>
{renderPermissionsGate( )}
</>
);
};

View File

@@ -111,7 +111,6 @@ const DetailsMap = ( {
obsLatitude={latitude}
obsLongitude={longitude}
obscured={obscured}
permissionRequested={false}
positionalAccuracy={positionalAccuracy}
region={region}
showCurrentLocationButton

View File

@@ -1,6 +1,5 @@
import { useNavigation } from "@react-navigation/native";
import classnames from "classnames";
import LocationPermissionGate from "components/SharedComponents/LocationPermissionGate";
import { View } from "components/styledComponents";
import React, {
useCallback,
@@ -14,6 +13,7 @@ import MapView, {
BoundingBox, LatLng, MapType, Region
} from "react-native-maps";
import { useDeviceOrientation } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission.tsx";
import CurrentLocationButton from "./CurrentLocationButton";
import {
@@ -46,14 +46,10 @@ interface Props {
onCurrentLocationPress?: () => void;
onMapReady?: () => void;
onPanDrag?: () => void;
onPermissionBlocked?: () => void;
onPermissionDenied?: () => void;
onPermissionGranted?: () => void;
onRegionChangeComplete?: ( _r: Region, _b: BoundingBox | undefined ) => void;
onZoomChange?: ( _z: number ) => void;
onZoomToNearby?: Function;
openMapScreen?: () => void;
permissionRequested?: boolean;
positionalAccuracy?: number;
region?: Region;
scrollEnabled?: boolean;
@@ -90,14 +86,10 @@ const Map = ( {
onCurrentLocationPress,
onMapReady = ( ) => undefined,
onPanDrag = ( ) => undefined,
onPermissionBlocked: onPermissionBlockedProp,
onPermissionDenied: onPermissionDeniedProp,
onPermissionGranted: onPermissionGrantedProp,
onRegionChangeComplete,
onZoomChange,
onZoomToNearby,
openMapScreen,
permissionRequested: permissionRequestedProp,
positionalAccuracy,
region,
scrollEnabled = true,
@@ -123,8 +115,7 @@ const Map = ( {
: 5
);
const navigation = useNavigation( );
const [permissionRequested, setPermissionRequested] = useState( permissionRequestedProp );
const [showsUserLocation, setShowsUserLocation] = useState( false );
const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission( );
const [userLocation, setUserLocation] = useState<{
accuracy: number;
latitude: number;
@@ -158,14 +149,6 @@ const Map = ( {
startAtNearby
);
// Prop kind of functions as a signal. Would make more sense if it was
// declarative and not reactive, but hey, it's React
useEffect( ( ) => {
if ( permissionRequestedProp && permissionRequested === null ) {
setPermissionRequested( true );
}
}, [permissionRequestedProp, permissionRequested] );
useEffect( ( ) => {
if ( startAtNearby && zoomToNearbyRequested === null ) {
setZoomToNearbyRequested( true );
@@ -225,45 +208,15 @@ const Map = ( {
zoomToNearbyRequested
] );
// Kludge for the fact that the onUserLocationChange callback in MapView
// won't fire if showsUserLocation is true on the first render
useEffect( ( ) => {
setShowsUserLocation( true );
}, [] );
// PermissionGate callbacks need to use useCallback, otherwise they'll
// trigger re-renders if/when they change
// TODO: Johannes: I don't know if this is necessary anymore
const onPermissionGranted = useCallback( ( ) => {
if ( typeof ( onPermissionGrantedProp ) === "function" ) onPermissionGrantedProp( );
setPermissionRequested( false );
setShowsUserLocation( true );
if ( startAtNearby ) {
setZoomToNearbyRequested( true );
}
}, [
onPermissionGrantedProp,
setPermissionRequested,
setZoomToNearbyRequested,
startAtNearby
] );
const onPermissionBlocked = useCallback( ( ) => {
if ( typeof ( onPermissionBlockedProp ) === "function" ) onPermissionBlockedProp( );
setPermissionRequested( false );
setShowsUserLocation( false );
}, [
onPermissionBlockedProp,
setPermissionRequested,
setShowsUserLocation
] );
const onPermissionDenied = useCallback( ( ) => {
if ( typeof ( onPermissionDeniedProp ) === "function" ) onPermissionDeniedProp( );
setPermissionRequested( false );
setShowsUserLocation( false );
}, [
onPermissionDeniedProp,
setPermissionRequested,
setShowsUserLocation
] );
const params = useMemo( ( ) => {
const newTileParams = { ...tileMapParams };
@@ -296,6 +249,10 @@ const Map = ( {
}
}, [currentZoom, onZoomChange] );
const renderCurrentLocationPermissionsGate = () => renderPermissionsGate( {
onPermissionGranted
} );
return (
<View
style={[
@@ -320,7 +277,7 @@ const Map = ( {
const coordinate = locationChangeEvent?.nativeEvent?.coordinate;
setUserLocation( coordinate );
}}
showsUserLocation={showsUserLocation}
showsUserLocation={hasPermissions}
showsMyLocationButton={false}
loadingEnabled
onRegionChangeComplete={async newRegion => {
@@ -368,11 +325,13 @@ const Map = ( {
<CurrentLocationButton
showCurrentLocationButton={showCurrentLocationButton}
currentLocationButtonClassName={currentLocationButtonClassName}
hasPermissions={hasPermissions}
renderPermissionsGate={renderCurrentLocationPermissionsGate}
requestPermissions={requestPermissions}
onPermissionGranted={onPermissionGranted}
handlePress={( ) => {
if ( onCurrentLocationPress ) { onCurrentLocationPress( ); }
setZoomToUserLocationRequested( true );
setShowsUserLocation( true );
setPermissionRequested( true );
}}
/>
<SwitchMapTypeButton
@@ -382,13 +341,6 @@ const Map = ( {
showSwitchMapTypeButton={showSwitchMapTypeButton}
switchMapTypeButtonClassName={switchMapTypeButtonClassName}
/>
<LocationPermissionGate
permissionNeeded={permissionRequested}
onPermissionGranted={onPermissionGranted}
onPermissionBlocked={onPermissionBlocked}
onPermissionDenied={onPermissionDenied}
withoutNavigation
/>
{children}
</View>
);

View File

@@ -18,16 +18,31 @@ import {
StatusBar
} from "react-native";
import DeviceInfo from "react-native-device-info";
import { RESULTS } from "react-native-permissions";
import { PermissionStatus, RESULTS } from "react-native-permissions";
import colors from "styles/tailwindColors";
const BACKGROUND_IMAGE_STYLE = {
opacity: 0.33,
backgroundColor: "black"
};
} as const;
const isTablet = DeviceInfo.isTablet();
interface Props {
requestPermission: () => void;
grantStatus: PermissionStatus;
icon: string;
title?: string;
titleDenied?: string;
body?: string;
body2?: string;
blockedPrompt?: string;
buttonText?: string;
image?: number;
onClose: () => void;
testID?: string;
}
const PermissionGate = ( {
requestPermission,
grantStatus,
@@ -35,12 +50,13 @@ const PermissionGate = ( {
title = t( "Grant-Permission-title" ),
titleDenied = t( "Please-Grant-Permission" ),
body,
body2,
blockedPrompt = t( "Youve-denied-permission-prompt" ),
buttonText = t( "GRANT-PERMISSION" ),
image = require( "images/bart-zimny-W5XTTLpk1-I-unsplash.jpg" ),
onClose,
testID
} ) => (
}: Props ) => (
<ViewWrapper wrapperClassName="bg-black" testID={testID}>
<StatusBar barStyle="light-content" backgroundColor="black" />
<ImageBackground
@@ -82,13 +98,16 @@ const PermissionGate = ( {
/>
) }
<Heading2 className="text-center text-white mt-8 mb-5">
{ grantStatus === null
? title
: titleDenied}
{ grantStatus === RESULTS.BLOCKED
? titleDenied
: title}
</Heading2>
{ body && (
<Body2 className="text-center text-white">{ body }</Body2>
) }
{ body2 && (
<Body2 className="text-center text-white mt-5">{ body2 }</Body2>
) }
{ grantStatus === RESULTS.BLOCKED && (
<Body2 className="text-center text-white mt-5">
{ blockedPrompt }

View File

@@ -1,13 +1,16 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import Modal from "components/SharedComponents/Modal.tsx";
import _ from "lodash";
import type { Node } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { Platform } from "react-native";
import {
checkMultiple, PERMISSIONS, requestMultiple, RESULTS
AndroidPermission,
checkMultiple,
Permission,
PERMISSIONS,
PermissionStatus,
requestMultiple,
RESULTS
} from "react-native-permissions";
import PermissionGate from "./PermissionGate";
@@ -15,61 +18,70 @@ import PermissionGate from "./PermissionGate";
const usesAndroid10Permissions = Platform.OS === "android" && Platform.Version <= 29;
const usesAndroid13Permissions = Platform.OS === "android" && Platform.Version >= 33;
let androidReadPermissions = [
let androidReadWritePermissions: AndroidPermission[] = [
PERMISSIONS.ANDROID.ACCESS_MEDIA_LOCATION
];
if ( usesAndroid10Permissions ) {
androidReadPermissions = [...androidReadPermissions, PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE];
androidReadWritePermissions = [
...androidReadWritePermissions,
PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE
];
} else if ( usesAndroid13Permissions ) {
androidReadPermissions = [...androidReadPermissions, PERMISSIONS.ANDROID.READ_MEDIA_IMAGES];
androidReadWritePermissions = [
...androidReadWritePermissions,
PERMISSIONS.ANDROID.READ_MEDIA_IMAGES
];
} else {
androidReadPermissions = [...androidReadPermissions, PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE];
androidReadWritePermissions = [
...androidReadWritePermissions,
PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE
];
}
const androidCameraPermissions = usesAndroid10Permissions
? [PERMISSIONS.ANDROID.CAMERA, PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE]
: [PERMISSIONS.ANDROID.CAMERA];
export const CAMERA_PERMISSIONS: Array<string> = Platform.OS === "ios"
export const CAMERA_PERMISSIONS = Platform.OS === "ios"
? [PERMISSIONS.IOS.CAMERA]
: androidCameraPermissions;
export const AUDIO_PERMISSIONS: Array<string> = Platform.OS === "ios"
export const AUDIO_PERMISSIONS = Platform.OS === "ios"
? [PERMISSIONS.IOS.MICROPHONE]
: [...androidReadPermissions, PERMISSIONS.ANDROID.RECORD_AUDIO];
: [...androidReadWritePermissions, PERMISSIONS.ANDROID.RECORD_AUDIO];
export const READ_MEDIA_PERMISSIONS: Array<string> = Platform.OS === "ios"
export const READ_WRITE_MEDIA_PERMISSIONS = Platform.OS === "ios"
? [PERMISSIONS.IOS.PHOTO_LIBRARY]
: androidReadPermissions;
: androidReadWritePermissions;
export const WRITE_MEDIA_PERMISSIONS: Array<string> = Platform.OS === "ios"
? [PERMISSIONS.IOS.PHOTO_LIBRARY_ADD_ONLY]
: androidReadPermissions;
export const LOCATION_PERMISSIONS: Array<string> = Platform.OS === "ios"
export const LOCATION_PERMISSIONS = Platform.OS === "ios"
? [PERMISSIONS.IOS.LOCATION_WHEN_IN_USE]
: [PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION];
type Props = {
blockedPrompt?: string,
body?: string,
buttonText?: string,
children?: Node,
icon?: string,
image?: Object,
onModalHide?: Function,
onPermissionBlocked?: Function,
onPermissionDenied?: Function,
onPermissionGranted?: Function,
permissionNeeded?: boolean,
permissions: Array<string>,
testID?: string,
title?: string,
titleDenied: string,
withoutNavigation?: boolean
blockedPrompt?: string;
body?: string;
body2?: string;
buttonText?: string;
children?: React.ReactNode,
icon: string;
image?: number;
onModalHide?: () => void;
onPermissionBlocked?: () => void;
onPermissionDenied?: () => void;
onPermissionGranted?: () => void;
permissionNeeded?: boolean;
permissions: Permission[];
testID?: string;
title?: string;
titleDenied?: string;
withoutNavigation?: boolean;
};
export function permissionResultFromMultiple( multiResults: Object ): string {
interface MultiResult {
[permission: string]: PermissionStatus;
}
export function permissionResultFromMultiple( multiResults: MultiResult ) {
if ( typeof ( multiResults ) !== "object" ) {
throw new Error(
"permissionResultFromMultiple received something other than an object. "
@@ -85,6 +97,7 @@ export function permissionResultFromMultiple( multiResults: Object ): string {
if ( _.find( multiResults, ( permResult, _perm ) => permResult === RESULTS.UNAVAILABLE ) ) {
return RESULTS.UNAVAILABLE;
}
// Note: we're not checking for RESULTS.LIMITED here and treat it as GRANTED
return RESULTS.GRANTED;
}
@@ -96,6 +109,7 @@ export function permissionResultFromMultiple( multiResults: Object ): string {
const PermissionGateContainer = ( {
blockedPrompt,
body,
body2,
buttonText,
children,
icon,
@@ -111,8 +125,8 @@ const PermissionGateContainer = ( {
title,
titleDenied,
withoutNavigation
}: Props ): Node => {
const [result, setResult] = useState( null );
}: Props ) => {
const [result, setResult] = useState<PermissionStatus | null>( null );
const [modalShown, setModalShown] = useState( false );
const navigation = useNavigation();
@@ -217,6 +231,7 @@ const PermissionGateContainer = ( {
title={title}
titleDenied={titleDenied}
body={body}
body2={body2}
blockedPrompt={blockedPrompt}
buttonText={buttonText}
image={image}

View File

@@ -1,8 +1,6 @@
// @flow
import { fontRegular } from "appConstants/fontFamilies.ts";
import { INatIcon, INatIconButton } from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { TextInput, useTheme } from "react-native-paper";
import { useTranslation } from "sharedHooks";
@@ -11,17 +9,17 @@ import colors from "styles/tailwindColors";
const DROP_SHADOW = getShadowForColor( colors.darkGray );
type Props = {
autoFocus?: boolean,
clearSearch?: Function,
containerClass?: string,
handleTextChange: Function,
hasShadow?: boolean,
// $FlowIgnore
input?: unknown,
placeholder?: string,
testID?: string,
value: string,
interface Props {
autoFocus?: boolean;
clearSearch?: () => void;
containerClass?: string;
handleTextChange: ( _text: string ) => void;
hasShadow?: boolean;
// TODO: check react-native-paper docs for correct type
input?: any,
placeholder?: string;
testID?: string;
value: string;
}
// Ensure this component is placed outside of scroll views
@@ -36,7 +34,7 @@ const SearchBar = ( {
placeholder,
testID,
value
}: Props ): Node => {
}: Props ) => {
const theme = useTheme( );
const { t } = useTranslation( );
@@ -44,7 +42,7 @@ const SearchBar = ( {
borderColor: "lightgray",
borderRadius: 8,
borderWidth: 1
};
} as const;
const style = {
...( hasShadow
@@ -53,7 +51,7 @@ const SearchBar = ( {
fontSize: 16,
lineHeight: 18,
paddingRight: 28
};
} as const;
// kind of tricky to change the font here:
// https://github.com/callstack/react-native-paper/issues/3615#issuecomment-1402025033
@@ -65,7 +63,7 @@ const SearchBar = ( {
fontFamily: fontRegular
}
}
};
} as const;
return (
<View className={containerClass}>

View File

@@ -5,7 +5,7 @@ import React from "react";
import { GestureResponderEvent, TouchableOpacity } from "react-native";
import useTranslation from "sharedHooks/useTranslation";
interface Tab {
export interface Tab {
id: string;
text: string;
testID?: string;

View File

@@ -27,7 +27,6 @@ export { default as InlineUser } from "./InlineUser/InlineUser";
export { default as InputField } from "./InputField";
export { default as KebabMenu } from "./KebabMenu";
export { default as KeyboardDismissableView } from "./KeyboardDismissableView";
export { default as LocationPermissionGate } from "./LocationPermissionGate";
export { default as DetailsMap } from "./Map/DetailsMap";
export { default as Map } from "./Map/Map";
export { default as MediaNavButtons } from "./MediaNavButtons";

View File

@@ -1,23 +1,20 @@
// @flow
import classnames from "classnames";
import {
Image, Pressable, View
} from "components/styledComponents";
import type { Node } from "react";
import React, { useCallback } from "react";
import { FlatList } from "react-native";
import { useTranslation } from "sharedHooks";
type Props = {
photoUris: Array<string>,
selectedPhotoUri: string,
onPressPhoto: Function
};
interface Props {
photoUris: string[];
selectedPhotoUri: string;
onPressPhoto: ( _uri: string ) => void;
}
const ObsPhotoSelectionList = ( {
photoUris, selectedPhotoUri, onPressPhoto
}: Props ): Node => {
}: Props ) => {
const { t } = useTranslation( );
const renderPhoto = useCallback( ( { item } ) => (
@@ -36,7 +33,7 @@ const ObsPhotoSelectionList = ( {
className={classnames(
"rounded-lg overflow-hidden",
{
"border border-inatGreen border-[3px]": selectedPhotoUri === item
"border-inatGreen border-[3px]": selectedPhotoUri === item
}
)}
testID={`ObsPhotoSelectionList.border.${item}`}

View File

@@ -24,8 +24,8 @@ type Props = {
photoUris: Array<string>,
reloadSuggestions: Function,
selectedPhotoUri: string,
// setLocationPermissionNeeded: Function,
// showImproveWithLocationButton: boolean,
improveWithLocationButtonOnPress: () => void;
showImproveWithLocationButton: boolean;
suggestions: Object
};
@@ -36,8 +36,8 @@ const Suggestions = ( {
photoUris,
reloadSuggestions,
selectedPhotoUri,
// setLocationPermissionNeeded,
// showImproveWithLocationButton,
improveWithLocationButtonOnPress,
showImproveWithLocationButton,
suggestions
}: Props ): Node => {
const { t } = useTranslation( );
@@ -86,16 +86,16 @@ const Suggestions = ( {
reloadSuggestions={reloadSuggestions}
selectedPhotoUri={selectedPhotoUri}
suggestions={suggestions}
// setLocationPermissionNeeded={setLocationPermissionNeeded}
// showImproveWithLocationButton={showImproveWithLocationButton}
improveWithLocationButtonOnPress={improveWithLocationButtonOnPress}
showImproveWithLocationButton={showImproveWithLocationButton}
/>
), [
onPressPhoto,
photoUris,
reloadSuggestions,
selectedPhotoUri,
// setLocationPermissionNeeded,
// showImproveWithLocationButton,
improveWithLocationButtonOnPress,
showImproveWithLocationButton,
suggestions
] );

View File

@@ -1,7 +1,5 @@
import MediaViewerModal from "components/MediaViewer/MediaViewerModal";
// import LocationPermissionGate from "components/SharedComponents/LocationPermissionGate";
import _ from "lodash";
import type { Node } from "react";
import React, {
useCallback,
useEffect,
@@ -11,6 +9,7 @@ import ObservationPhoto from "realmModels/ObservationPhoto";
import {
useIsConnected
} from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission.tsx";
// import { log } from "sharedHelpers/logger";
import useStore from "stores/useStore";
@@ -31,7 +30,7 @@ const initialSuggestions = {
isLoading: true
};
const SuggestionsContainer = ( ): Node => {
const SuggestionsContainer = ( ) => {
const isOnline = useIsConnected( );
// clearing the cache of resized images for the score_image API
// placing this here means we can keep the app size small
@@ -49,9 +48,13 @@ const SuggestionsContainer = ( ): Node => {
...initialSuggestions,
showSuggestionsWithLocation: evidenceHasLocation
} );
// const [locationPermissionNeeded, setLocationPermissionNeeded] = useState( false );
const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission( );
const showImproveWithLocationButton = hasPermissions === false;
// const showImproveWithLocationButton = !evidenceHasLocation
// && params?.lastScreen === "CameraWithDevice";
const improveWithLocationButtonOnPress = useCallback( ( ) => {
requestPermissions( );
}, [requestPermissions] );
const {
showSuggestionsWithLocation,
@@ -232,8 +235,8 @@ const SuggestionsContainer = ( ): Node => {
photoUris={photoUris}
reloadSuggestions={reloadSuggestions}
selectedPhotoUri={selectedPhotoUri}
// setLocationPermissionNeeded={setLocationPermissionNeeded}
// showImproveWithLocationButton={showImproveWithLocationButton}
improveWithLocationButtonOnPress={improveWithLocationButtonOnPress}
showImproveWithLocationButton={showImproveWithLocationButton}
suggestions={suggestions}
/>
<MediaViewerModal
@@ -242,13 +245,7 @@ const SuggestionsContainer = ( ): Node => {
uri={selectedPhotoUri}
photos={innerPhotos}
/>
{/* <LocationPermissionGate
permissionNeeded={locationPermissionNeeded}
withoutNavigation
onPermissionGranted={( ) => console.log( "permission granted" )}
onPermissionDenied={( ) => console.log( "permission denied" )}
onPermissionBlocked={( ) => console.log( "permission blocked" )}
/> */}
{renderPermissionsGate()}
</>
);
};

View File

@@ -2,11 +2,11 @@ import { useNavigation, useRoute } from "@react-navigation/native";
import {
Body2,
Body3,
Button,
INatIcon,
INatIconButton
} from "components/SharedComponents";
import { Pressable, View } from "components/styledComponents";
import type { Node } from "react";
import React, { useCallback, useEffect } from "react";
import { useTheme } from "react-native-paper";
import { useTranslation } from "sharedHooks";
@@ -15,21 +15,26 @@ import AddCommentPrompt from "./AddCommentPrompt";
import CommentBox from "./CommentBox";
import ObsPhotoSelectionList from "./ObsPhotoSelectionList";
type Props = {
onPressPhoto: Function,
photoUris: Array<string>,
reloadSuggestions: Function,
selectedPhotoUri: string,
suggestions: Object
};
interface Props {
onPressPhoto: ( _uri: string ) => void;
photoUris: string[];
// eslint-disable-next-line no-unused-vars
reloadSuggestions: ( { showLocation }: { showLocation: boolean } ) => void;
selectedPhotoUri: string;
suggestions: Object;
improveWithLocationButtonOnPress: () => void;
showImproveWithLocationButton: boolean;
}
const SuggestionsHeader = ( {
onPressPhoto,
photoUris,
reloadSuggestions,
selectedPhotoUri,
suggestions
}: Props ): Node => {
suggestions,
improveWithLocationButtonOnPress,
showImproveWithLocationButton
}: Props ) => {
const { t } = useTranslation( );
const navigation = useNavigation( );
const { params } = useRoute( );
@@ -68,16 +73,16 @@ const SuggestionsHeader = ( {
onPressPhoto={onPressPhoto}
/>
</View>
{/* {showImproveWithLocationButton && (
{showImproveWithLocationButton && (
<View className="mx-5 mt-5">
<Button
text={t( "IMPROVE-THESE-SUGGESTIONS-BY-USING-YOUR-LOCATION" )}
accessibilityHint={t( "Opens-location-permission-prompt" )}
level="focus"
onPress={( ) => setLocationPermissionNeeded( true )}
onPress={( ) => improveWithLocationButtonOnPress()}
/>
</View>
)} */}
)}
{showOfflineText && (
<Pressable
accessibilityRole="button"

View File

@@ -56,7 +56,6 @@ const TaxonMapPreview = ( {
mapHeight={230}
mapViewClassName="-mx-3"
openMapScreen={() => setShowMapModal( true )}
permissionRequested={false}
region={region}
scrollEnabled={false}
tileMapParams={obsParams}

View File

@@ -76,6 +76,7 @@ All-observations = All observations
All-organisms = All organisms
# As in intellectual property rights over a photo or other creative work
all-rights-reserved = all rights reserved
ALLOW-LOCATION-ACCESS = ALLOW LOCATION ACCESS
# As in automated identification suggestions
Almost-done = Almost done!
Already-have-an-account = Already have an account? Log in
@@ -471,7 +472,7 @@ INATURALIST-STORE = INATURALIST STORE
INATURALIST-TEAM = INATURALIST TEAM
iNaturalist-users-who-have-left-an-identification = iNaturalist users who have left an identification on another user's observation
iNaturalist-users-who-have-observed = iNaturalist users who have observed a particular taxon at a particular time and place
iNaturalist-uses-your-location-to-give-you = iNaturalist uses your location to give you better identification suggestions and we can automatically add a location to your observations, which helps scientists. We also use it to help you find organisms observed near your location. Youre always in control of the location privacy of every observation you create.
iNaturalist-uses-your-location-to-give-you = iNaturalist uses your location to give you better identification suggestions and we can automatically add a location to your observations, which helps scientists. We also use it to help you find organisms observed near your location.
iNaturalists-apps-are-designed-and-developed = iNaturalist's apps are designed, developed, and supported by the iNaturalist team: Yaron Budowski, Amanda Bullington, Tony Iwane, Johannes Klein, Patrick Leary, Scott Loarie, Abhas Misraraj, Sylvain Morin, Carrie Seltzer, Alex Shepard, Angie Ta, Ken-ichi Ueda, Jason Walthall, & Jane Weeden.
iNaturalists-vision-is-a-world = iNaturalist's vision is a world where everyone can understand and sustain biodiversity through the practice of observing wild organisms and sharing information about them.
Individual-encounters-with-organisms = Individual encounters with organisms at a particular time and location, usually with evidence
@@ -788,7 +789,6 @@ Removes-your-vote-of-agreement = Removes your vote of agreement
Removes-your-vote-of-disagreement = Removes your vote of disagreement
# Quality grade option
Research-Grade = Research Grade
Reset = Reset
# Reset password button
RESET-PASSWORD = RESET PASSWORD
# Label for a button that resets a sound recording
@@ -827,7 +827,6 @@ Scientific-Name-Common-Name = Scientific Name (Common Name)
SEARCH = SEARCH
# Title for a search interface
Search = Search
SEARCH-FOR-A-LOCATION = SEARCH FOR A LOCATION
Search-for-a-project = Search for a project
SEARCH-FOR-A-TAXON = SEARCH FOR A TAXON
Search-for-a-taxon = Search for a taxon
@@ -950,6 +949,8 @@ This-organism-was-placed-by-humans = This organism was placed in this location b
To-access-all-other-settings = To access all other account settings, click here:
To-learn-more-about-what-information = To learn more about what information we collect and how we use it, please see our Privacy Policy and our Terms of Use.
To-sync-your-observations-to-iNaturalist = To sync your observations to iNaturalist, please log in.
To-view-nearby-organisms-please-enable-location = To view nearby organisms, please enable location.
To-view-nearby-projects-please-enable-location = To view nearby projects, please enable location.
Toggle-map-type = Toggle map type
TOP-ID-SUGGESTION = TOP ID SUGGESTION
Traditional-Project = Traditional Project
@@ -1157,6 +1158,7 @@ Your-donation-to-iNaturalist = Your donation to iNaturalist supports the improve
Your-email-is-confirmed = Your email is confirmed! Please log in to continue.
Your-identification-will-be-posted-with-the-following-comment = Your identification will be posted with the following comment:
Your-location-uncertainty-is-over-x-km = Your location uncertainty is over { $x } km, which is too high to be helpful to identifiers. Edit the location and zoom in until the accuracy circle turns green and is centered on where you observed the organism.
Youre-always-in-control-of-the-location-privacy = Youre always in control of the location privacy of every observation you create.
# Text prompting the user to open Settings to grant permission after
# permission has been denied
Youve-denied-permission-prompt = Youve denied permission. Please grant permission in the settings app.

View File

@@ -84,6 +84,7 @@
"comment": "As in intellectual property rights over a photo or other creative work",
"val": "all rights reserved"
},
"ALLOW-LOCATION-ACCESS": "ALLOW LOCATION ACCESS",
"Almost-done": {
"comment": "As in automated identification suggestions",
"val": "Almost done!"
@@ -632,7 +633,7 @@
"INATURALIST-TEAM": "INATURALIST TEAM",
"iNaturalist-users-who-have-left-an-identification": "iNaturalist users who have left an identification on another user's observation",
"iNaturalist-users-who-have-observed": "iNaturalist users who have observed a particular taxon at a particular time and place",
"iNaturalist-uses-your-location-to-give-you": "iNaturalist uses your location to give you better identification suggestions and we can automatically add a location to your observations, which helps scientists. We also use it to help you find organisms observed near your location. Youre always in control of the location privacy of every observation you create.",
"iNaturalist-uses-your-location-to-give-you": "iNaturalist uses your location to give you better identification suggestions and we can automatically add a location to your observations, which helps scientists. We also use it to help you find organisms observed near your location.",
"iNaturalists-apps-are-designed-and-developed": "iNaturalist's apps are designed, developed, and supported by the iNaturalist team: Yaron Budowski, Amanda Bullington, Tony Iwane, Johannes Klein, Patrick Leary, Scott Loarie, Abhas Misraraj, Sylvain Morin, Carrie Seltzer, Alex Shepard, Angie Ta, Ken-ichi Ueda, Jason Walthall, & Jane Weeden.",
"iNaturalists-vision-is-a-world": "iNaturalist's vision is a world where everyone can understand and sustain biodiversity through the practice of observing wild organisms and sharing information about them.",
"Individual-encounters-with-organisms": "Individual encounters with organisms at a particular time and location, usually with evidence",
@@ -1072,7 +1073,6 @@
"comment": "Quality grade option",
"val": "Research Grade"
},
"Reset": "Reset",
"RESET-PASSWORD": {
"comment": "Reset password button",
"val": "RESET PASSWORD"
@@ -1130,7 +1130,6 @@
"comment": "Title for a search interface",
"val": "Search"
},
"SEARCH-FOR-A-LOCATION": "SEARCH FOR A LOCATION",
"Search-for-a-project": "Search for a project",
"SEARCH-FOR-A-TAXON": "SEARCH FOR A TAXON",
"Search-for-a-taxon": "Search for a taxon",
@@ -1287,6 +1286,8 @@
"To-access-all-other-settings": "To access all other account settings, click here:",
"To-learn-more-about-what-information": "To learn more about what information we collect and how we use it, please see our Privacy Policy and our Terms of Use.",
"To-sync-your-observations-to-iNaturalist": "To sync your observations to iNaturalist, please log in.",
"To-view-nearby-organisms-please-enable-location": "To view nearby organisms, please enable location.",
"To-view-nearby-projects-please-enable-location": "To view nearby projects, please enable location.",
"Toggle-map-type": "Toggle map type",
"TOP-ID-SUGGESTION": "TOP ID SUGGESTION",
"Traditional-Project": "Traditional Project",
@@ -1453,6 +1454,7 @@
"Your-email-is-confirmed": "Your email is confirmed! Please log in to continue.",
"Your-identification-will-be-posted-with-the-following-comment": "Your identification will be posted with the following comment:",
"Your-location-uncertainty-is-over-x-km": "Your location uncertainty is over { $x } km, which is too high to be helpful to identifiers. Edit the location and zoom in until the accuracy circle turns green and is centered on where you observed the organism.",
"Youre-always-in-control-of-the-location-privacy": "Youre always in control of the location privacy of every observation you create.",
"Youve-denied-permission-prompt": {
"comment": "Text prompting the user to open Settings to grant permission after\npermission has been denied",
"val": "Youve denied permission. Please grant permission in the settings app."

View File

@@ -76,6 +76,7 @@ All-observations = All observations
All-organisms = All organisms
# As in intellectual property rights over a photo or other creative work
all-rights-reserved = all rights reserved
ALLOW-LOCATION-ACCESS = ALLOW LOCATION ACCESS
# As in automated identification suggestions
Almost-done = Almost done!
Already-have-an-account = Already have an account? Log in
@@ -471,7 +472,7 @@ INATURALIST-STORE = INATURALIST STORE
INATURALIST-TEAM = INATURALIST TEAM
iNaturalist-users-who-have-left-an-identification = iNaturalist users who have left an identification on another user's observation
iNaturalist-users-who-have-observed = iNaturalist users who have observed a particular taxon at a particular time and place
iNaturalist-uses-your-location-to-give-you = iNaturalist uses your location to give you better identification suggestions and we can automatically add a location to your observations, which helps scientists. We also use it to help you find organisms observed near your location. Youre always in control of the location privacy of every observation you create.
iNaturalist-uses-your-location-to-give-you = iNaturalist uses your location to give you better identification suggestions and we can automatically add a location to your observations, which helps scientists. We also use it to help you find organisms observed near your location.
iNaturalists-apps-are-designed-and-developed = iNaturalist's apps are designed, developed, and supported by the iNaturalist team: Yaron Budowski, Amanda Bullington, Tony Iwane, Johannes Klein, Patrick Leary, Scott Loarie, Abhas Misraraj, Sylvain Morin, Carrie Seltzer, Alex Shepard, Angie Ta, Ken-ichi Ueda, Jason Walthall, & Jane Weeden.
iNaturalists-vision-is-a-world = iNaturalist's vision is a world where everyone can understand and sustain biodiversity through the practice of observing wild organisms and sharing information about them.
Individual-encounters-with-organisms = Individual encounters with organisms at a particular time and location, usually with evidence
@@ -788,7 +789,6 @@ Removes-your-vote-of-agreement = Removes your vote of agreement
Removes-your-vote-of-disagreement = Removes your vote of disagreement
# Quality grade option
Research-Grade = Research Grade
Reset = Reset
# Reset password button
RESET-PASSWORD = RESET PASSWORD
# Label for a button that resets a sound recording
@@ -827,7 +827,6 @@ Scientific-Name-Common-Name = Scientific Name (Common Name)
SEARCH = SEARCH
# Title for a search interface
Search = Search
SEARCH-FOR-A-LOCATION = SEARCH FOR A LOCATION
Search-for-a-project = Search for a project
SEARCH-FOR-A-TAXON = SEARCH FOR A TAXON
Search-for-a-taxon = Search for a taxon
@@ -950,6 +949,8 @@ This-organism-was-placed-by-humans = This organism was placed in this location b
To-access-all-other-settings = To access all other account settings, click here:
To-learn-more-about-what-information = To learn more about what information we collect and how we use it, please see our Privacy Policy and our Terms of Use.
To-sync-your-observations-to-iNaturalist = To sync your observations to iNaturalist, please log in.
To-view-nearby-organisms-please-enable-location = To view nearby organisms, please enable location.
To-view-nearby-projects-please-enable-location = To view nearby projects, please enable location.
Toggle-map-type = Toggle map type
TOP-ID-SUGGESTION = TOP ID SUGGESTION
Traditional-Project = Traditional Project
@@ -1157,6 +1158,7 @@ Your-donation-to-iNaturalist = Your donation to iNaturalist supports the improve
Your-email-is-confirmed = Your email is confirmed! Please log in to continue.
Your-identification-will-be-posted-with-the-following-comment = Your identification will be posted with the following comment:
Your-location-uncertainty-is-over-x-km = Your location uncertainty is over { $x } km, which is too high to be helpful to identifiers. Edit the location and zoom in until the accuracy circle turns green and is centered on where you observed the organism.
Youre-always-in-control-of-the-location-privacy = Youre always in control of the location privacy of every observation you create.
# Text prompting the user to open Settings to grant permission after
# permission has been denied
Youve-denied-permission-prompt = Youve denied permission. Please grant permission in the settings app.

View File

@@ -9,8 +9,9 @@ import { Heading4 } from "components/SharedComponents";
import Mortal from "components/SharedComponents/Mortal";
import PermissionGateContainer, {
AUDIO_PERMISSIONS,
CAMERA_PERMISSIONS
} from "components/SharedComponents/PermissionGateContainer";
CAMERA_PERMISSIONS,
READ_WRITE_MEDIA_PERMISSIONS
} from "components/SharedComponents/PermissionGateContainer.tsx";
import SoundRecorder from "components/SoundRecorder/SoundRecorder";
import { t } from "i18next";
import {
@@ -69,6 +70,21 @@ const CameraContainerWithPermission = ( ) => (
</PermissionGateContainer>
);
const GalleryContainerWithPermission = ( ) => (
<PermissionGateContainer
permissions={READ_WRITE_MEDIA_PERMISSIONS}
title={t( "Observe-and-identify-organisms-from-your-gallery" )}
titleDenied={t( "Please-Allow-Gallery-Access" )}
body={t( "Upload-photos-from-your-gallery-and-create-observations" )}
blockedPrompt={t( "Youve-previously-denied-gallery-permissions" )}
buttonText={t( "CHOOSE-PHOTOS" )}
icon="gallery"
image={require( "images/viviana-rishe-j2330n6bg3I-unsplash.jpg" )}
>
<PhotoGallery />
</PermissionGateContainer>
);
const SoundRecorderWithPermission = ( ) => (
<Mortal>
<PermissionGateContainer
@@ -105,7 +121,7 @@ const NoBottomTabStackNavigator = ( ): Node => (
/>
<Stack.Screen
name="PhotoGallery"
component={PhotoGallery}
component={GalleryContainerWithPermission}
options={hideHeader}
/>
<Stack.Screen

View File

@@ -1,6 +1,5 @@
/* eslint-disable no-unused-vars */
/* eslint-disable no-shadow */
import { t } from "i18next";
import { isEqual } from "lodash";
import * as React from "react";
import { LatLng } from "react-native-maps";
@@ -30,6 +29,10 @@ export enum EXPLORE_ACTION {
SET_MEDIA = "SET_MEDIA",
SET_PHOTO_LICENSE = "SET_PHOTO_LICENSE",
SET_PLACE = "SET_PLACE",
SET_PLACE_MODE_NEARBY = "SET_PLACE_MODE_NEARBY",
SET_PLACE_MODE_WORLDWIDE = "SET_PLACE_MODE_WORLDWIDE",
SET_PLACE_MODE_MAP_AREA = "SET_PLACE_MODE_MAP_AREA",
SET_PLACE_MODE_PLACE = "SET_PLACE_MODE_PLACE",
SET_PROJECT = "SET_PROJECT",
SET_REVIEWED = "SET_REVIEWED",
SET_TAXON_NAME = "SET_TAXON_NAME",
@@ -142,12 +145,18 @@ export enum PHOTO_LICENSE {
CCBYNCND = "CCBYNCND",
}
export enum PLACE_MODE {
NEARBY = "NEARBY",
WORLDWIDE = "WORLDWIDE",
PLACE = "PLACE",
MAP_AREA = "MAP_AREA"
}
interface MapBoundaries {
swlat?: LatLng["latitude"],
swlng?: LatLng["longitude"],
nelat?: LatLng["latitude"],
nelng?: LatLng["longitude"],
place_guess: string
swlat?: LatLng["latitude"];
swlng?: LatLng["longitude"];
nelat?: LatLng["latitude"];
nelng?: LatLng["longitude"];
}
interface PLACE {
@@ -160,6 +169,13 @@ interface PLACE {
type: string
}
interface DefaultLocation {
placeMode: PLACE_MODE,
lat?: number,
lng?: number,
radius?: number
}
type ExploreProviderProps = {children: React.ReactNode}
type State = {
casual: boolean,
@@ -185,6 +201,7 @@ type State = {
photoLicense: PHOTO_LICENSE,
place: PLACE | null | undefined,
place_guess: string,
placeMode: PLACE_MODE,
place_id: number | null | undefined,
// TODO: technically this is not any Object but a "Project"
// and should be typed as such (e.g., in realm model)
@@ -217,17 +234,21 @@ type Action = {type: EXPLORE_ACTION.RESET}
storedState: State
}
| { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN }
| {type: EXPLORE_ACTION.SET_EXPLORE_LOCATION, exploreLocation: Object}
| {type: EXPLORE_ACTION.SET_EXPLORE_LOCATION, exploreLocation: DefaultLocation}
| {
type: EXPLORE_ACTION.SET_PLACE,
place: PLACE,
placeId: number,
placeGuess: string,
placeGuess?: string,
lat: number,
lng: number,
radius: number,
storedState: State
}
| {type: EXPLORE_ACTION.SET_PLACE_MODE_NEARBY}
| {type: EXPLORE_ACTION.SET_PLACE_MODE_WORLDWIDE}
| {type: EXPLORE_ACTION.SET_PLACE_MODE_MAP_AREA}
| {type: EXPLORE_ACTION.SET_PLACE_MODE_PLACE}
| {type: EXPLORE_ACTION.SET_PROJECT, project: Object, projectId: number, storedState: State}
| {type: EXPLORE_ACTION.CHANGE_SORT_BY, sortBy: SORT_BY}
| {type: EXPLORE_ACTION.TOGGLE_RESEARCH_GRADE}
@@ -260,12 +281,7 @@ const ExploreContext = React.createContext<
isNotInitialState: boolean;
discardChanges(): void;
numberOfFilters: number;
defaultExploreLocation(): Promise<{
place_guess: string;
lat?: number;
lng?: number;
radius?: number;
}>;
defaultExploreLocation(): Promise<DefaultLocation>;
} | undefined>( undefined );
// Every key in this object represents a numbered filter in the UI
@@ -304,7 +320,12 @@ const defaultFilters = {
const initialState: State = {
...defaultFilters,
// The very first time the user opens the explore screen, we want the place mode to be
// nearby (even if we don't have location permission yet and not actually make any API request)
placeMode: PLACE_MODE.NEARBY,
place: undefined,
// String to display as the human-readable place name, not to be used for default explore search
// modes, like Nearby or Worldwide
place_guess: "",
place_id: undefined,
return_bounds: true,
@@ -319,15 +340,15 @@ function isValidDateFormat( date: string ): boolean {
return regex.test( date );
}
async function defaultExploreLocation( ) {
async function defaultExploreLocation( ): Promise<DefaultLocation> {
const location = await fetchUserLocation( );
if ( !location || !location.latitude ) {
return {
place_guess: t( "Worldwide" )
placeMode: PLACE_MODE.WORLDWIDE
};
}
return {
place_guess: t( "Nearby" ),
placeMode: PLACE_MODE.NEARBY,
lat: location?.latitude,
lng: location?.longitude,
radius: 50
@@ -339,10 +360,12 @@ async function defaultExploreLocation( ) {
// state
function exploreReducer( state: State, action: Action ) {
switch ( action.type ) {
// Reset the state to the initial state, but place mode
// should be set to worldwide no matter if location and not nearby
case EXPLORE_ACTION.RESET:
return {
...initialState,
...action.exploreLocation
placeMode: PLACE_MODE.WORLDWIDE
};
case EXPLORE_ACTION.DISCARD:
return action.snapshot;
@@ -381,6 +404,27 @@ function exploreReducer( state: State, action: Action ) {
...state,
...action.exploreLocation
};
case EXPLORE_ACTION.SET_PLACE_MODE_NEARBY:
return {
...state,
placeMode: PLACE_MODE.NEARBY
};
case EXPLORE_ACTION.SET_PLACE_MODE_WORLDWIDE:
return {
...state,
placeMode: PLACE_MODE.WORLDWIDE
};
case EXPLORE_ACTION.SET_PLACE_MODE_MAP_AREA:
return {
...state,
placeMode: PLACE_MODE.MAP_AREA
};
case EXPLORE_ACTION.SET_PLACE_MODE_PLACE:
return {
...state,
placeMode: PLACE_MODE.PLACE
};
case EXPLORE_ACTION.SET_PLACE:
return {
...state,

View File

@@ -5,8 +5,7 @@ class LocalPreferences extends Realm.Object {
name: "LocalPreferences",
properties: {
last_deleted_sync_time: "date?",
last_sync_time: "date?",
explore_location_permission_shown: { type: "bool", default: false }
last_sync_time: "date?"
}
};
}

View File

@@ -30,13 +30,21 @@ export default {
User,
Vote
],
schemaVersion: 51,
schemaVersion: 52,
path: `${RNFS.DocumentDirectoryPath}/db.realm`,
// https://github.com/realm/realm-js/pull/6076 embedded constraints
migrationOptions: {
resolveEmbeddedConstraints: true
},
migration: ( oldRealm, newRealm ) => {
if ( oldRealm.schemaVersion < 52 ) {
const oldPrefs = oldRealm.objects( "LocalPreferences" );
const newPrefs = newRealm.objects( "LocalPreferences" );
oldPrefs.keys( ).forEach( objectIndex => {
const newObsSound = newPrefs[objectIndex];
delete newObsSound.explore_location_permission_shown;
} );
}
if ( oldRealm.schemaVersion < 51 ) {
// const oldIdentifications = oldRealm.objects( "Identification" );
// const newIdentifications = newRealm.objects( "Identification" );

View File

@@ -6,7 +6,7 @@ type UserLocation = {
}
const fetchUserLocation = async ( ): Promise<UserLocation> => new Promise( resolve => {
setTimeout( ( ) => resolve( {
// Chuck's house. Note that the e2e tests run in a UTC environment, so the
// Darwin's house. Note that the e2e tests run in a UTC environment, so the
// observed_on_string will be set to a UTC time. If these coordinates
// fall within a time zone west of that (i.e. in the past),
// observation creation will fail during the period of the day when UTC

View File

@@ -2,11 +2,10 @@ import Geolocation, { GeolocationResponse } from "@react-native-community/geoloc
import {
LOCATION_PERMISSIONS,
permissionResultFromMultiple
} from "components/SharedComponents/PermissionGateContainer";
} from "components/SharedComponents/PermissionGateContainer.tsx";
import { Platform } from "react-native";
import {
checkMultiple,
Permission,
RESULTS
} from "react-native-permissions";
@@ -30,9 +29,7 @@ interface UserLocation {
const fetchUserLocation = async ( ): Promise<UserLocation | null> => {
const permissionResult = permissionResultFromMultiple(
// TODO if/when we convert PermissionGateContainer to typescript, this
// case should be unnecessary
await checkMultiple( LOCATION_PERMISSIONS as Permission[] )
await checkMultiple( LOCATION_PERMISSIONS )
);
// TODO: handle case where iOS permissions are not granted

View File

@@ -1,16 +1,11 @@
// @flow
import { galleryPhotosPath } from "appConstants/paths.ts";
import {
LOCATION_PERMISSIONS,
permissionResultFromMultiple
} from "components/SharedComponents/PermissionGateContainer";
import {
useEffect, useRef,
useState
} from "react";
import RNFS from "react-native-fs";
import { checkMultiple, RESULTS } from "react-native-permissions";
// Please don't change this to an aliased path or the e2e mock will not get
// used in our e2e tests on Github Actions
@@ -29,6 +24,7 @@ const useCurrentObservationLocation = (
mountedRef: unknown,
currentObservation: Object,
updateObservationKeys: Function,
hasPermissions: boolean,
options: Object = { }
): Object => {
const latitude = currentObservation?.latitude;
@@ -64,7 +60,6 @@ const useCurrentObservationLocation = (
const [fetchingLocation, setFetchingLocation] = useState( false );
const [positionalAccuracy, setPositionalAccuracy] = useState( INITIAL_POSITIONAL_ACCURACY );
const [lastLocationFetchTime, setLastLocationFetchTime] = useState( 0 );
const [permissionResult, setPermissionResult] = useState( null );
const [currentLocation, setCurrentLocation] = useState( null );
useEffect( () => {
@@ -97,11 +92,7 @@ const useCurrentObservationLocation = (
if ( !mountedRef.current ) return;
if ( !shouldFetchLocation ) return;
const newPermissionResult = permissionResultFromMultiple(
await checkMultiple( LOCATION_PERMISSIONS )
);
setPermissionResult( newPermissionResult );
if ( newPermissionResult !== RESULTS.GRANTED ) {
if ( !hasPermissions ) {
setFetchingLocation( false );
setShouldFetchLocation( false );
return;
@@ -155,10 +146,10 @@ const useCurrentObservationLocation = (
}, [
currentObservation,
fetchingLocation,
hasPermissions,
lastLocationFetchTime,
mountedRef,
numLocationFetches,
permissionResult,
positionalAccuracy,
setFetchingLocation,
shouldFetchLocation,
@@ -186,7 +177,6 @@ const useCurrentObservationLocation = (
// location requests is in flight, but this tells the external consumer
// whether the overall location fetching process is happening
isFetchingLocation: shouldFetchLocation,
permissionResult,
numLocationFetches
};
};

View File

@@ -0,0 +1,108 @@
import LocationPermissionGate from "components/SharedComponents/LocationPermissionGate.tsx";
import {
LOCATION_PERMISSIONS,
permissionResultFromMultiple
} from "components/SharedComponents/PermissionGateContainer.tsx";
import React, { useEffect, useState } from "react";
import { AppState } from "react-native";
import {
checkMultiple,
RESULTS
} from "react-native-permissions";
// PermissionGate callbacks need to use useCallback, otherwise they'll
// trigger re-renders if/when they change
interface LocationPermissionCallbacks {
onPermissionGranted?: ( ) => void;
onPermissionDenied?: ( ) => void;
onPermissionBlocked?: ( ) => void;
onModalHide?: ( ) => void;
}
/**
* A hook to check and request location permissions.
* @returns {boolean} hasPermissions - Undefined if permissions have not been checked yet.
* True if permissions have been granted. False if permissions have been denied.
* @returns {Function} renderPermissionsGate - A function to render the permissions gate.
* @returns {Function} requestPermissions - A function to request location permissions.
* Essentially just a wrapper around toggling permissionNeeded for the LocationPermissionGate.
*/
const useLocationPermission = ( ) => {
const [hasPermissions, setHasPermissions] = useState<boolean>( );
const [showPermissionGate, setShowPermissionGate] = useState( false );
// PermissionGate callbacks need to use useCallback, otherwise they'll
// trigger re-renders if/when they change
const renderPermissionsGate = ( callbacks?: LocationPermissionCallbacks ) => {
const {
onPermissionGranted,
onPermissionDenied,
onPermissionBlocked,
onModalHide
} = callbacks || { };
return (
<LocationPermissionGate
permissionNeeded={showPermissionGate}
withoutNavigation
onModalHide={( ) => {
setShowPermissionGate( false );
if ( onModalHide ) onModalHide( );
}}
onPermissionGranted={( ) => {
setShowPermissionGate( false );
setHasPermissions( true );
if ( onPermissionGranted ) onPermissionGranted( );
}}
onPermissionDenied={( ) => {
setShowPermissionGate( false );
setHasPermissions( false );
if ( onPermissionDenied ) onPermissionDenied( );
}}
onPermissionBlocked={( ) => {
setShowPermissionGate( false );
setHasPermissions( false );
if ( onPermissionBlocked ) onPermissionBlocked( );
}}
/>
);
};
function requestPermissions() {
setShowPermissionGate( true );
}
async function checkPermissions() {
const permissionsResult = permissionResultFromMultiple(
await checkMultiple( LOCATION_PERMISSIONS )
);
if ( permissionsResult === RESULTS.GRANTED ) {
setHasPermissions( true );
} else {
setHasPermissions( false );
console.log( "Location permissions have not been granted." );
}
}
// Check permissions every time the app is back in the foreground. The user could
// have changed permissions in the settings app while the current screen was in the background.
useEffect( () => {
const subscription = AppState.addEventListener( "change", appState => {
if ( appState === "active" ) {
checkPermissions();
}
} );
return () => {
subscription.remove();
};
}, [] );
// Check permissions on mount
useEffect( () => {
checkPermissions();
}, [] );
return { hasPermissions, renderPermissionsGate, requestPermissions };
};
export default useLocationPermission;

View File

@@ -5,11 +5,10 @@ import Geolocation, {
import {
LOCATION_PERMISSIONS,
permissionResultFromMultiple
} from "components/SharedComponents/PermissionGateContainer";
} from "components/SharedComponents/PermissionGateContainer.tsx";
import { useEffect, useRef, useState } from "react";
import {
checkMultiple,
Permission,
RESULTS
} from "react-native-permissions";
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
@@ -64,7 +63,7 @@ function useUserLocation(
useEffect( ( ) => {
async function checkPermissions() {
const permissionsResult = permissionResultFromMultiple(
await checkMultiple( LOCATION_PERMISSIONS as Permission[] )
await checkMultiple( LOCATION_PERMISSIONS )
);
if ( permissionsResult === RESULTS.GRANTED ) {
setPermissionsGranted( true );

View File

@@ -86,11 +86,6 @@ describe( "ObsEdit offline", ( ) => {
renderAppWithComponent(
<ObsEdit />
);
await waitFor( ( ) => {
expect(
screen.getByTestId( "EvidenceSection.fetchingLocationIndicator" )
).toBeTruthy( );
} );
await waitFor( ( ) => {
expect( mockGetCurrentPosition ).toHaveBeenCalled( );
}, { timeout: LOCATION_FETCH_INTERVAL * 2 } );

View File

@@ -290,12 +290,8 @@ describe( "logged in", ( ) => {
await navigateToRootExplore( );
const speciesViewIcon = await screen.findByLabelText( /Species View/ );
expect( speciesViewIcon ).toBeVisible( );
const locationPermission = await screen.findByText( /Please allow Location Access/ );
expect( locationPermission ).toBeVisible( );
const closeButton = await screen.findByLabelText( /Close permission request screen/ );
await actor.press( closeButton );
const defaultGlobalLocation = await screen.findByText( /Worldwide/ );
expect( defaultGlobalLocation ).toBeVisible( );
const defaultNearbyLocationText = await screen.findByText( /Nearby/ );
expect( defaultNearbyLocationText ).toBeVisible( );
const backButton = screen.queryByTestId( "Explore.BackButton" );
expect( backButton ).toBeFalsy( );
} );

View File

@@ -12,9 +12,6 @@ jest.mock( "sharedHooks/useIsConnected", ( ) => ( {
default: ( ) => true
} ) );
// Don't need permission gates if we're just testing DetailsTab
jest.mock( "components/SharedComponents/LocationPermissionGate", ( ) => "" );
// Before migrating to Jest 27 this line was:
// jest.useFakeTimers();
// TODO: replace with modern usage of jest.useFakeTimers

View File

@@ -1,5 +1,5 @@
import { render, screen } from "@testing-library/react-native";
import PermissionGate from "components/SharedComponents/PermissionGate";
import PermissionGate from "components/SharedComponents/PermissionGate.tsx";
import React from "react";
import { RESULTS } from "react-native-permissions";