Permission gate layouts (#743)

Primarily adds designed layouts for permission gates (also referred to as permissions priming).

* moved permission gate business logic into a container
* use react-native-permissions exclusively
* Show PermissionGate as a modal
* Basic unit tests for PermissionGate
* Consistent content width on tablet, other minor style changes
* Allow PermissionGate to be used outside of nav hierarchy
* Use user location on Explore after getting permission
* Remove redundant 'always' location perm in ios
* Isolate current location button in the Map component, which uses location fetching functionality from react-native-maps instead of our own
* Updated cocoapods; matched INatIcon.ttf to sha1 hashes
This commit is contained in:
Ken-ichi
2023-10-18 16:47:12 -07:00
committed by GitHub
parent d15f0bd6ee
commit dacd8788ec
45 changed files with 1430 additions and 4965 deletions

View File

@@ -3,12 +3,16 @@ GEM
specs:
CFPropertyList (3.0.6)
rexml
activesupport (6.1.5)
activesupport (7.1.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
algoliasearch (1.27.5)
@@ -33,16 +37,18 @@ GEM
aws-sigv4 (1.6.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.1.1)
bigdecimal (3.1.4)
claide (1.1.0)
cocoapods (1.11.3)
cocoapods (1.13.0)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.11.3)
cocoapods-core (= 1.13.0)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.4.0, < 2.0)
cocoapods-downloader (>= 1.6.0, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-trunk (>= 1.6.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
@@ -50,10 +56,10 @@ GEM
gh_inspector (~> 1.0)
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (>= 1.0, < 3.0)
xcodeproj (>= 1.21.0, < 2.0)
cocoapods-core (1.11.3)
activesupport (>= 5.0, < 7)
ruby-macho (>= 2.3.0, < 3.0)
xcodeproj (>= 1.23.0, < 2.0)
cocoapods-core (1.13.0)
activesupport (>= 5.0, < 8)
addressable (~> 2.8)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
@@ -75,16 +81,19 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.1.10)
concurrent-ruby (1.2.2)
connection_pool (2.4.1)
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
drb (2.1.1)
ruby2_keywords
emoji_regex (3.2.3)
escape (0.0.4)
ethon (0.15.0)
ethon (0.16.0)
ffi (>= 1.15.0)
excon (0.103.0)
faraday (1.10.3)
@@ -156,7 +165,7 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
ffi (1.15.5)
ffi (1.16.3)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
@@ -201,7 +210,7 @@ GEM
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
i18n (1.10.0)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.6.3)
@@ -209,10 +218,11 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.1)
minitest (5.15.0)
minitest (5.20.0)
molinillo (0.8.0)
multi_json (1.15.0)
multipart-post (2.3.0)
mutex_m (0.1.2)
nanaimo (0.3.0)
nap (1.1.0)
naturally (2.2.1)
@@ -255,7 +265,7 @@ GEM
tty-cursor (~> 0.7)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (2.0.4)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
unf (0.1.4)
@@ -264,7 +274,7 @@ GEM
unicode-display_width (2.4.2)
webrick (1.8.1)
word_wrap (1.0.0)
xcodeproj (1.22.0)
xcodeproj (1.23.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
@@ -275,7 +285,6 @@ GEM
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
zeitwerk (2.5.4)
PLATFORMS
ruby

View File

@@ -289,7 +289,7 @@ PODS:
- React-Core
- react-native-mail (6.1.1):
- React-Core
- react-native-maps (1.7.1):
- react-native-maps (1.8.0):
- React-Core
- react-native-netinfo (9.4.1):
- React-Core
@@ -730,7 +730,7 @@ SPEC CHECKSUMS:
react-native-image-resizer: 681f7607418b97c084ba2d0999b153b103040d8a
react-native-keep-awake: acbee258db16483744910f0da3ace39eb9ab47fd
react-native-mail: 8fdcd3aef007c33a6877a18eb4cf7447a1d4ce4a
react-native-maps: 667f9b975549c6fa9b1631bf859440f68ebd3b8f
react-native-maps: f699e0753c22c4d5c3a44d03895b193a4dbca6c2
react-native-netinfo: fefd4e98d75cbdd6e85fc530f7111a8afdf2b0c5
react-native-orientation-locker: 851f6510d8046ea2f14aa169b1e01fcd309a94ba
react-native-render-html: 984dfe2294163d04bf5fe25d7c9f122e60e05ebe
@@ -776,4 +776,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 77ed9526d4011b245ce5afa1ea331dea4c67d753
COCOAPODS: 1.13.0
COCOAPODS: 1.12.1

View File

@@ -43,10 +43,6 @@
<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>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Add GPS coordinates to observations, show your location on maps, and more.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Add GPS coordinates to observations, show your location on maps, and more.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Add GPS coordinates to observations, show your location on maps, and more.</string>
<key>NSMicrophoneUsageDescription</key>

24
package-lock.json generated
View File

@@ -6693,9 +6693,9 @@
}
},
"node_modules/@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA=="
"version": "7946.0.11",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.11.tgz",
"integrity": "sha512-L7A0AINMXQpVwxHJ4jxD6/XjZ4NDufaRlUJHjNIFKYUFBH1SvOW+neaqb0VTRSLW5suSrSu19ObFEFnfNcr+qg=="
},
"node_modules/@types/graceful-fs": {
"version": "4.1.6",
@@ -23468,9 +23468,9 @@
"license": "MIT"
},
"node_modules/react-native-maps": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.7.1.tgz",
"integrity": "sha512-CHVLzL+Q2jiUGgbt4/vosxVI1SukWyaLGjD62VLgR/wZpnH4Umi9ql1bmKDPWcfj2C1QZwMU0Yc7rXTbvZUIiw==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.8.0.tgz",
"integrity": "sha512-KfVZ03L42+a22Fkcl2uDe5d+73BweTlcRUzm2G+aQUJ7fHrBxj7CuXWtKMSNpVDiO3YDCWcshiiyjSXxkd/qOw==",
"dependencies": {
"@types/geojson": "^7946.0.10"
},
@@ -32271,9 +32271,9 @@
}
},
"@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA=="
"version": "7946.0.11",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.11.tgz",
"integrity": "sha512-L7A0AINMXQpVwxHJ4jxD6/XjZ4NDufaRlUJHjNIFKYUFBH1SvOW+neaqb0VTRSLW5suSrSu19ObFEFnfNcr+qg=="
},
"@types/graceful-fs": {
"version": "4.1.6",
@@ -44852,9 +44852,9 @@
"from": "react-native-mail@github:chirag04/react-native-mail"
},
"react-native-maps": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.7.1.tgz",
"integrity": "sha512-CHVLzL+Q2jiUGgbt4/vosxVI1SukWyaLGjD62VLgR/wZpnH4Umi9ql1bmKDPWcfj2C1QZwMU0Yc7rXTbvZUIiw==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.8.0.tgz",
"integrity": "sha512-KfVZ03L42+a22Fkcl2uDe5d+73BweTlcRUzm2G+aQUJ7fHrBxj7CuXWtKMSNpVDiO3YDCWcshiiyjSXxkd/qOw==",
"requires": {
"@types/geojson": "^7946.0.10"
}

View File

@@ -13,7 +13,7 @@
"lint": "npm run lint:eslint && npm run lint:flow",
"lint:eslint": "eslint . --fix --quiet",
"lint:flow": "flow check",
"postinstall": "husky install && patch-package",
"postinstall": "husky install && patch-package && react-native setup-ios-permissions",
"translate": "node src/i18n/i18ncli.js build",
"e2e:android": "npm run e2e:build:android && npm run e2e:test:android",
"e2e:build:android": "MOCK_MODE=e2e npx detox build --configuration android.release",
@@ -204,5 +204,13 @@
"hooks": {
"pre-commit": "npm run lint"
}
}
},
"reactNativePermissionsIOS": [
"Camera",
"LocationAccuracy",
"LocationWhenInUse",
"MediaLibrary",
"Microphone",
"PhotoLibrary"
]
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@
import { useRoute } from "@react-navigation/native";
import type { Node } from "react";
import React, { useEffect, useReducer } from "react";
import { useUserLocation } from "sharedHooks";
import Explore from "./Explore";
@@ -97,7 +96,6 @@ const reducer = ( state, action ) => {
};
const ExploreContainer = ( ): Node => {
const { latLng } = useUserLocation( { skipPlaceGuess: false } );
const { params } = useRoute( );
const [state, dispatch] = useReducer( reducer, initialState );
@@ -123,20 +121,6 @@ const ExploreContainer = ( ): Node => {
}
}, [params] );
useEffect( ( ) => {
if ( region.latitude === 0.0 && latLng?.latitude ) {
dispatch( {
type: "SET_LOCATION",
region: {
...region,
latitude: latLng.latitude,
longitude: latLng.longitude,
place_guess: latLng.place_guess
}
} );
}
}, [latLng, region] );
const changeExploreView = newView => {
dispatch( {
type: "CHANGE_EXPLORE_VIEW",

View File

@@ -44,9 +44,9 @@ const ObservationsView = ( {
className="h-full"
showsCompass={false}
region={region}
hideMap={region.latitude === 0.0}
observations={observations}
tileMapParams={tileMapParams}
showCurrentLocationButton
/>
)
: (

View File

@@ -18,10 +18,7 @@ const CrosshairCircle = ( { accuracyTest, getShadow }: Props ): Node => {
const theme = useTheme( );
return (
<View
className="right-[127px] bottom-[127px]"
pointerEvents="none"
>
<View pointerEvents="none">
<View
className={
classnames(

View File

@@ -1,19 +1,18 @@
// @flow
import classnames from "classnames";
import {
Body3,
CloseButton, Heading4,
INatIconButton,
Map,
ViewWrapper
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import MapView from "react-native-maps";
import { ActivityIndicator, useTheme } from "react-native-paper";
import { useTheme } from "react-native-paper";
import useTranslation from "sharedHooks/useTranslation";
import { getShadowStyle } from "styles/global";
import colors from "styles/tailwindColors";
import CrosshairCircle from "./CrosshairCircle";
import DisplayLatLng from "./DisplayLatLng";
@@ -37,18 +36,16 @@ type Props = {
accuracy: number,
accuracyTest: string,
goBackOnSave: Function,
fetchingLocation: boolean,
hidePlaceResults: boolean,
keysToUpdate: Object,
loading: boolean,
locationName: ?string,
mapType: string,
mapView: any,
mapViewRef: any,
region: Object,
returnToUserLocation: Function,
selectPlaceResult: Function,
setMapReady: Function,
showMap: boolean,
showCrosshairs: boolean,
toggleMapLayer: Function,
updateLocationName: Function,
updateRegion: Function,
@@ -58,18 +55,16 @@ const LocationPicker = ( {
accuracy,
accuracyTest,
goBackOnSave,
fetchingLocation,
hidePlaceResults,
keysToUpdate,
loading,
locationName,
mapViewRef,
mapType,
mapView,
region,
returnToUserLocation,
selectPlaceResult,
setMapReady,
showMap,
setMapReady = ( ) => { },
showCrosshairs,
toggleMapLayer,
updateLocationName,
updateRegion
@@ -85,59 +80,60 @@ const LocationPicker = ( {
<CloseButton black size={19} />
</View>
</View>
<View className="z-20">
<LocationSearch
locationName={locationName}
updateLocationName={updateLocationName}
getShadow={getShadow}
selectPlaceResult={selectPlaceResult}
hidePlaceResults={hidePlaceResults}
/>
</View>
<View className="z-10">
<DisplayLatLng
region={region}
accuracy={accuracy}
getShadow={getShadow}
/>
</View>
<View
className="top-1/2 left-1/2 absolute z-10"
pointerEvents="none"
>
{showMap && (
<CrosshairCircle
accuracyTest={accuracyTest}
<View className="flex-grow">
<View className="z-20">
<LocationSearch
locationName={locationName}
updateLocationName={updateLocationName}
getShadow={getShadow}
selectPlaceResult={selectPlaceResult}
hidePlaceResults={hidePlaceResults}
/>
</View>
<View className="z-10">
<DisplayLatLng
region={region}
accuracy={accuracy}
getShadow={getShadow}
/>
)}
</View>
<View className="top-1/2 left-1/2 absolute z-10">
{loading && <LoadingIndicator getShadow={getShadow} theme={theme} />}
</View>
<View className="flex-shrink">
{showMap
? (
<MapView
className="h-full"
showsCompass={false}
region={region}
ref={mapView}
mapType={mapType}
// TODO: figure out the right zoom level here
// don't think it's necessary to let a user zoom out far beyond cities
minZoomLevel={5}
onRegionChangeComplete={async newRegion => {
updateRegion( newRegion );
}}
onMapReady={setMapReady}
/>
)
: (
<View className="h-full bg-lightGray items-center justify-center">
<Body3>{t( "Try-searching-for-a-location-name" )}</Body3>
</View>
</View>
<View
className={classnames(
"absolute",
"z-10",
"flex-1",
"items-center",
"justify-center",
"w-full",
"h-full"
)}
pointerEvents="none"
>
{showCrosshairs && (
<CrosshairCircle
accuracyTest={accuracyTest}
getShadow={getShadow}
/>
)}
</View>
<View className="top-1/2 left-1/2 absolute z-10">
{loading && <LoadingIndicator getShadow={getShadow} theme={theme} />}
</View>
<Map
className="h-full"
showsCompass={false}
region={region}
mapViewRef={mapViewRef}
mapType={mapType}
minZoomLevel={5}
onRegionChangeComplete={async newRegion => {
updateRegion( newRegion );
}}
onMapReady={setMapReady}
showCurrentLocationButton
obsLatitude={region.latitude}
obsLongitude={region.longitude}
/>
<View
style={getShadow( theme.colors.primary )}
className="absolute bottom-3 bg-white left-3 rounded-full"
@@ -152,37 +148,6 @@ const LocationPicker = ( {
accessibilityHint={t( "Toggles-map-layer" )}
/>
</View>
<View
style={getShadow( theme.colors.primary )}
className="absolute bottom-3 bg-white right-3 rounded-full"
>
{
fetchingLocation
? (
<INatIconButton
disabled
height={46}
width={46}
size={24}
accessibilityLabel={t( "Loading-wheel" )}
accessibilityHint={t( "Indicates-location-is-loading" )}
>
<ActivityIndicator color={colors.darkGrayDisabled} />
</INatIconButton>
)
: (
<INatIconButton
icon="location-crosshairs"
onPress={returnToUserLocation}
height={46}
width={46}
size={24}
accessibilityLabel={t( "User-location" )}
accessibilityHint={t( "Returns-map-to-users-current-location" )}
/>
)
}
</View>
</View>
<Footer
keysToUpdate={keysToUpdate}

View File

@@ -5,12 +5,13 @@ import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React, {
useCallback,
useContext, useEffect,
useReducer, useRef
useContext,
useEffect,
useReducer,
useRef
} from "react";
import { Dimensions, Platform } from "react-native";
import { Dimensions } from "react-native";
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
import fetchUserLocation from "sharedHelpers/fetchUserLocation";
import LocationPicker from "./LocationPicker";
@@ -36,7 +37,6 @@ const initialState = {
longitudeDelta: DELTA
},
loading: true,
fetchingLocation: false,
hidePlaceResults: true
};
@@ -47,21 +47,6 @@ const reducer = ( state, action ) => {
...state,
positional_accuracy: estimatedAccuracy( state.region.longitudeDelta )
};
case "SET_CURRENT_LOCATION":
return {
...state,
fetchingLocation: false,
region: {
...state.region,
latitude: action.userLocation?.latitude,
longitude: action.userLocation?.longitude
}
};
case "SET_FETCHING_LOCATION":
return {
...state,
fetchingLocation: true
};
case "INITIALIZE_MAP":
return {
...state,
@@ -130,7 +115,7 @@ type Props = {
};
const LocationPickerContainer = ( { route }: Props ): Node => {
const mapView = useRef( );
const mapViewRef = useRef( );
const { currentObservation } = useContext( ObsEditContext );
const navigation = useNavigation( );
const { goBackOnSave } = route.params;
@@ -140,7 +125,6 @@ const LocationPickerContainer = ( { route }: Props ): Node => {
const {
accuracy,
accuracyTest,
fetchingLocation,
hidePlaceResults,
loading,
locationName,
@@ -148,7 +132,7 @@ const LocationPickerContainer = ( { route }: Props ): Node => {
region
} = state;
const showMap = region.latitude !== 0.0;
const showCrosshairs = region.latitude !== 0.0;
const keysToUpdate = {
latitude: region.latitude,
@@ -172,9 +156,11 @@ const LocationPickerContainer = ( { route }: Props ): Node => {
// don't update region if map hasn't actually moved
// otherwise, it's jittery on Android
if ( newRegion.latitude.toFixed( 6 ) === region.latitude?.toFixed( 6 )
if (
newRegion.latitude.toFixed( 6 ) === region.latitude?.toFixed( 6 )
&& newRegion.longitude.toFixed( 6 ) === region.longitude?.toFixed( 6 )
&& newRegion.latitudeDelta.toFixed( 6 ) === region.latitudeDelta?.toFixed( 6 ) ) {
&& newRegion.latitudeDelta.toFixed( 6 ) === region.latitudeDelta?.toFixed( 6 )
) {
return;
}
@@ -195,29 +181,6 @@ const LocationPickerContainer = ( { route }: Props ): Node => {
}
};
const returnToUserLocation = async ( ) => {
dispatch( { type: "SET_FETCHING_LOCATION" } );
const userLocation = await fetchUserLocation( );
dispatch( { type: "SET_CURRENT_LOCATION", userLocation } );
mapView.current?.getCamera( )
.then( cam => {
if ( Platform.OS === "android" ) {
cam.zoom = 20;
} else {
cam.altitude = 100;
}
mapView.current?.animateCamera( cam );
} )
.catch( getCameraError => {
if ( getCameraError.message.match( /AirMapView.map is not valid/ ) ) {
// This doesn't seem to have any ill effect
return;
}
throw getCameraError;
} );
dispatch( { type: "ESTIMATE_ACCURACY" } );
};
const updateLocationName = useCallback( name => {
dispatch( { type: "UPDATE_LOCATION_NAME", locationName: name } );
}, [] );
@@ -237,8 +200,8 @@ const LocationPickerContainer = ( { route }: Props ): Node => {
);
useEffect( ( ) => {
if ( !showMap ) dispatch( { type: "SET_LOADING", loading: false } );
}, [showMap] );
if ( !showCrosshairs ) dispatch( { type: "SET_LOADING", loading: false } );
}, [showCrosshairs] );
const setMapReady = ( ) => dispatch( { type: "SET_LOADING", loading: false } );
@@ -260,18 +223,16 @@ const LocationPickerContainer = ( { route }: Props ): Node => {
accuracy={accuracy}
accuracyTest={accuracyTest}
goBackOnSave={goBackOnSave}
fetchingLocation={fetchingLocation}
hidePlaceResults={hidePlaceResults}
keysToUpdate={keysToUpdate}
loading={loading}
locationName={locationName}
mapType={mapType}
mapView={mapView}
mapViewRef={mapViewRef}
region={region}
returnToUserLocation={returnToUserLocation}
selectPlaceResult={selectPlaceResult}
setMapReady={setMapReady}
showMap={showMap}
showCrosshairs={showCrosshairs}
toggleMapLayer={toggleMapLayer}
updateLocationName={updateLocationName}
updateRegion={updateRegion}

View File

@@ -40,7 +40,6 @@ type Props = {
copyCoordinates: Function,
shareMap: Function,
cycleMapTypes: Function,
zoomToCurrentUserLocation: Function,
closeModal: Function
}
@@ -61,9 +60,6 @@ const FloatingActionButton = ( {
buttonClassName
)}
icon={icon}
height={46}
width={46}
size={24}
onPress={onPress}
accessibilityLabel={accessibilityLabel}
/>
@@ -83,8 +79,7 @@ const DetailsMap = ( {
obscured,
positionalAccuracy,
shareMap,
showNotificationModal,
zoomToCurrentUserLocation
showNotificationModal
}: Props ): Node => {
const theme = useTheme( );
return (
@@ -92,7 +87,7 @@ const DetailsMap = ( {
<View className="flex-1 h-full">
<Map
showLocationIndicator
showsMyLocationButton={false}
showCurrentLocationButton
obsLatitude={latitude}
obsLongitude={longitude}
mapHeight="100%"
@@ -119,13 +114,6 @@ const DetailsMap = ( {
/>
</>
)}
<FloatingActionButton
icon="currentlocation"
onPress={( ) => zoomToCurrentUserLocation( )}
accessibilityLabel={t( "User-location" )}
buttonClassName="bottom-0 right-0"
theme={theme}
/>
<FloatingActionButton
icon="map-layers"
onPress={( ) => cycleMapTypes( )}

View File

@@ -7,7 +7,6 @@ import { t } from "i18next";
import type { Node } from "react";
import React, { useRef, useState } from "react";
import openMap from "react-native-open-maps";
import fetchUserLocation from "sharedHelpers/fetchUserLocation";
type Props = {
observation: Object,
@@ -67,18 +66,6 @@ const DetailsMapContainer = ( {
const mapViewRef = useRef<any>( null );
const zoomToCurrentUserLocation = async () => {
const userLocation = await fetchUserLocation( );
if ( userLocation ) {
mapViewRef.current.animateCamera( {
center: {
latitude: userLocation.latitude,
longitude: userLocation.longitude
}
} );
}
};
return (
<DetailsMap
mapViewRef={mapViewRef}
@@ -92,7 +79,6 @@ const DetailsMapContainer = ( {
copyCoordinates={copyCoordinates}
shareMap={shareMap}
cycleMapTypes={cycleMapTypes}
zoomToCurrentUserLocation={zoomToCurrentUserLocation}
showNotificationModal={showNotificationModal}
closeNotificationsModal={closeShowNotificationModal}
closeModal={closeModal}

View File

@@ -1,5 +1,6 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import classnames from "classnames";
import {
Button, StickyToolbar
@@ -7,7 +8,7 @@ import {
import { View } from "components/styledComponents";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React, { useContext, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import useTranslation from "sharedHooks/useTranslation";
import ImpreciseLocationSheet from "./Sheets/ImpreciseLocationSheet";
@@ -28,6 +29,16 @@ const BottomButtons = ( ): Node => {
const [showImpreciseLocationSheet, setShowImpreciseLocationSheet] = useState( false );
const [allowUserToUpload, setAllowUserToUpload] = useState( false );
const [buttonPressed, setButtonPressed] = useState( null );
const navigation = useNavigation( );
useEffect(
( ) => {
navigation.addListener( "blur", ( ) => {
setButtonPressed( null );
} );
},
[navigation]
);
const showMissingEvidence = ( ) => {
if ( allowUserToUpload ) { return false; }

View File

@@ -6,6 +6,7 @@ import { MAX_PHOTOS_ALLOWED } from "components/Camera/StandardCamera/StandardCam
import {
Body3, Body4, Heading4, INatIcon
} from "components/SharedComponents";
import LocationPermissionGate from "components/SharedComponents/LocationPermissionGate";
import { Pressable, View } from "components/styledComponents";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
@@ -18,21 +19,29 @@ import EvidenceList from "./EvidenceList";
import AddEvidenceSheet from "./Sheets/AddEvidenceSheet";
type Props = {
passesEvidenceTest: Function,
evidenceList: Array<string>,
handleDragAndDrop: Function,
isFetchingLocation: boolean,
locationPermissionNeeded: boolean,
locationTextClassNames: any,
evidenceList: Array<string>,
onLocationPermissionBlocked: Function,
onLocationPermissionDenied: Function,
onLocationPermissionGranted: Function,
passesEvidenceTest: Function,
setShowAddEvidenceSheet: Function,
showAddEvidenceSheet: boolean
showAddEvidenceSheet: boolean,
}
const EvidenceSection = ( {
locationTextClassNames,
handleDragAndDrop,
passesEvidenceTest,
isFetchingLocation,
evidenceList,
handleDragAndDrop,
isFetchingLocation,
locationPermissionNeeded,
locationTextClassNames,
onLocationPermissionBlocked,
onLocationPermissionDenied,
onLocationPermissionGranted,
passesEvidenceTest,
setShowAddEvidenceSheet,
showAddEvidenceSheet
}: Props ): Node => {
@@ -133,6 +142,13 @@ const EvidenceSection = ( {
</View>
</Pressable>
<DatePicker currentObservation={currentObservation} />
<LocationPermissionGate
permissionNeeded={locationPermissionNeeded}
onPermissionGranted={onLocationPermissionGranted}
onPermissionDenied={onLocationPermissionDenied}
onPermissionBlocked={onLocationPermissionBlocked}
withoutNavigation
/>
</View>
);
};

View File

@@ -12,6 +12,9 @@ import React, {
useCallback, useContext, useEffect,
useRef, useState
} from "react";
import {
RESULTS as PERMISSION_RESULTS
} from "react-native-permissions";
import useCurrentObservationLocation from "sharedHooks/useCurrentObservationLocation";
import EvidenceSection from "./EvidenceSection";
@@ -29,6 +32,11 @@ const EvidenceSectionContainer = ( ): Node => {
const [showAddEvidenceSheet, setShowAddEvidenceSheet] = useState( false );
const [
shouldRetryCurrentObservationLocation,
setShouldRetryCurrentObservationLocation
] = useState( false );
// Hook version of componentWillUnmount. We use a ref to track mounted
// state (not useState, which might get frozen in a closure for other
// useEffects), and set it to false in the cleanup cleanup function. The
@@ -53,14 +61,30 @@ const EvidenceSectionContainer = ( ): Node => {
}
}, [obsPhotos, photoEvidenceUris, setPhotoEvidenceUris] );
// const {
// hasLocation,
// isFetchingLocation
// } = useCurrentObservationLocation( mountedRef );
const {
hasLocation,
isFetchingLocation
} = useCurrentObservationLocation( mountedRef );
isFetchingLocation,
permissionResult: locationPermissionResult
} = useCurrentObservationLocation( mountedRef, {
retry: shouldRetryCurrentObservationLocation
} );
const latitude = currentObservation?.latitude;
const longitude = currentObservation?.longitude;
useEffect( ( ) => {
if ( latitude ) {
setShouldRetryCurrentObservationLocation( false );
} else if ( locationPermissionResult === "granted" ) {
setShouldRetryCurrentObservationLocation( true );
}
}, [latitude, locationPermissionResult] );
const hasPhotoOrSound = useCallback( ( ) => {
if ( currentObservation?.observationPhotos?.length > 0
|| currentObservation?.observationSounds?.length > 0 ) {
@@ -142,6 +166,16 @@ const EvidenceSectionContainer = ( ): Node => {
evidenceList={obsPhotos || []}
setShowAddEvidenceSheet={setShowAddEvidenceSheet}
showAddEvidenceSheet={showAddEvidenceSheet}
onLocationPermissionGranted={( ) => {
setShouldRetryCurrentObservationLocation( true );
}}
onLocationPermissionDenied={( ) => {
setShouldRetryCurrentObservationLocation( false );
}}
onLocationPermissionBlocked={( ) => {
setShouldRetryCurrentObservationLocation( false );
}}
locationPermissionNeeded={locationPermissionResult === PERMISSION_RESULTS.DENIED}
/>
);
};

View File

@@ -1,6 +1,7 @@
// @flow
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";
@@ -24,7 +25,11 @@ const ProjectsContainer = ( ): Node => {
const { t } = useTranslation( );
const [apiParams, setApiParams] = useState( { } );
const [currentTabId, setCurrentTabId] = useState( JOINED_TAB_ID );
const { latLng } = useUserLocation( { skipPlaceGuess: true } );
const [permissionsGranted, setPermissionsGranted] = useState( false );
const { latLng } = useUserLocation( {
skipPlaceGuess: true,
permissionsGranted
} );
const {
data: projects,
@@ -90,15 +95,24 @@ const ProjectsContainer = ( ): Node => {
}
return (
<Projects
searchInput={searchInput}
setSearchInput={setSearchInput}
tabs={tabs}
currentTabId={currentTabId}
projects={projects}
isLoading={isLoading}
memberId={memberId}
/>
<>
<Projects
searchInput={searchInput}
setSearchInput={setSearchInput}
tabs={tabs}
currentTabId={currentTabId}
projects={projects}
isLoading={isLoading}
memberId={memberId}
/>
<LocationPermissionGate
permissionNeeded={currentTabId === NEARBY_TAB_ID}
withoutNavigation
onPermissionGranted={( ) => setPermissionsGranted( true )}
onPermissionDenied={( ) => setPermissionsGranted( false )}
onPermissionBlocked={( ) => setPermissionsGranted( false )}
/>
</>
);
};

View File

@@ -10,7 +10,7 @@ type Props = {
}
const BackButton = ( {
color,
color = colors.black,
onPress
}: Props ): Node => {
const navigation = useNavigation();

View File

@@ -0,0 +1,46 @@
// @flow
import PermissionGateContainer, {
LOCATION_PERMISSIONS
} from "components/SharedComponents/PermissionGateContainer";
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
type Props = {
children?: Node,
permissionNeeded?: boolean,
onPermissionGranted?: Function,
onPermissionDenied?: Function,
onPermissionBlocked?: Function,
withoutNavigation?: boolean
};
const LocationPermissionGate = ( {
children,
permissionNeeded,
onPermissionGranted,
onPermissionDenied,
onPermissionBlocked,
withoutNavigation
}: Props ): Node => (
<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" )}
blockedPrompt={t( "Youve-previously-denied-location-permissions" )}
buttonText={t( "USE-LOCATION" )}
icon="map-marker-outline"
image={require( "images/landon-parenteau-EEuDMqRYbx0-unsplash.jpg" )}
permissionNeeded={permissionNeeded}
onPermissionGranted={onPermissionGranted}
onPermissionBlocked={onPermissionBlocked}
onPermissionDenied={onPermissionDenied}
withoutNavigation={withoutNavigation}
>
{children}
</PermissionGateContainer>
);
export default LocationPermissionGate;

View File

@@ -1,24 +1,38 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import { INatIconButton } from "components/SharedComponents";
import LocationPermissionGate from "components/SharedComponents/LocationPermissionGate";
import { View } from "components/styledComponents";
import LocationIndicator from "images/svg/location_indicator.svg";
import type { Node } from "react";
import React, { useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import MapView, {
Circle, Marker, Polygon, UrlTile
Circle,
Marker,
Polygon,
UrlTile
} from "react-native-maps";
import { useTheme } from "react-native-paper";
import createUTFPosition from "sharedHelpers/createUTFPosition";
import getDataForPixel from "sharedHelpers/fetchUTFGridData";
import { useDeviceOrientation, useUserLocation } from "sharedHooks";
import { useDeviceOrientation } from "sharedHooks";
import useTranslation from "sharedHooks/useTranslation";
import { getShadowStyle } from "styles/global";
import colors from "styles/tailwindColors";
const calculateZoom = ( width, delta ) => Math.round(
Math.log2( 360 * ( width / 256 / delta ) ) + 1
);
const tilesUrl = "https://tiles.inaturalist.org/v1/points";
const baseUrl = "https://api.inaturalist.org/v2";
const POINT_TILES_ENDPOINT = "https://tiles.inaturalist.org/v1/points";
const API_ENDPOINT = "https://api.inaturalist.org/v2";
const OBSCURATION_CELL_SIZE = 0.2;
@@ -47,43 +61,64 @@ function obscurationCellForLatLng( lat, lng ) {
}
type Props = {
children?: any,
className?: string,
mapHeight?: number|string, // allows for height to be defined as px or percentage
mapViewRef?: Object,
mapType?: string,
minZoomLevel?: number,
obscured?: boolean,
obsLatitude: number,
obsLongitude: number,
mapHeight?: number|string, // allows for height to be defined as px or percentage
updateCoords?: Function,
region?: Object,
showLocationIndicator?: boolean,
hideMap?: boolean,
tileMapParams?: Object,
children?: any,
mapType?: string,
positionalAccuracy?: number,
mapViewRef?: any,
obscured?: boolean,
showExplore?: boolean,
onMapReady?: Function,
onRegionChange?: Function,
onRegionChangeComplete?: Function,
openMapScreen?: Function,
showsMyLocationButton?: boolean
positionalAccuracy?: number,
region?: Object,
showCurrentLocationButton?: boolean,
showLocationIndicator?: boolean,
showsCompass?: boolean,
startAtUserLocation?: boolean,
style?: Object,
tileMapParams?: Object,
withObsTiles?: boolean
}
const getShadow = shadowColor => getShadowStyle( {
shadowColor,
offsetWidth: 0,
offsetHeight: 2,
shadowOpacity: 0.25,
shadowRadius: 2,
elevation: 5
} );
// TODO: fallback to another map library
// for people who don't use GMaps (i.e. users in China)
const Map = ( {
children,
hideMap,
className = "flex-1",
mapHeight,
mapViewRef: mapViewRefProp,
mapType,
mapViewRef,
minZoomLevel,
obscured,
obsLatitude,
obsLongitude,
onMapReady = ( ) => { },
onRegionChange,
onRegionChangeComplete,
openMapScreen,
positionalAccuracy,
region,
showExplore,
showCurrentLocationButton,
showLocationIndicator,
showsMyLocationButton = false,
showsCompass,
startAtUserLocation = false,
style,
tileMapParams,
updateCoords
withObsTiles
}: Props ): Node => {
const { screenWidth } = useDeviceOrientation( );
const [currentZoom, setCurrentZoom] = useState(
@@ -92,18 +127,72 @@ const Map = ( {
: 5
);
const navigation = useNavigation( );
const { latLng: viewerLatLng } = useUserLocation( { skipPlaceGuess: true } );
const theme = useTheme( );
const [permissionRequested, setPermissionRequested] = useState( false );
const [showsUserLocation, setShowsUserLocation] = useState( false );
const [userLocation, setUserLocation] = useState( null );
const { t } = useTranslation( );
const mapViewRef = useRef( mapViewRefProp );
const initialLatitude = obsLatitude || ( viewerLatLng?.latitude );
const initialLongitude = obsLongitude || ( viewerLatLng?.longitude );
const initialLatitude = obsLatitude;
const initialLongitude = obsLongitude;
let initialRegion = {
latitude: initialLatitude,
longitude: initialLongitude,
latitudeDelta: 0.4,
longitudeDelta: 0.4
latitude: initialLatitude || 0,
longitude: initialLongitude || 0,
latitudeDelta: initialLatitude
? 0.2
: 100,
longitudeDelta: initialLatitude
? 0.2
: 100
};
// Kind of obtuse, but the more obvious approach of making a function that
// pans the map results in a function that gets recreated every time the
// userLocation changes
const [panToUserLocationRequested, setPanToUserLocationRequested] = useState(
startAtUserLocation
);
useEffect( ( ) => {
if ( userLocation && panToUserLocationRequested && mapViewRef?.current ) {
mapViewRef.current.animateToRegion( {
...region,
latitude: userLocation.latitude,
longitude: userLocation.longitude
} );
setPanToUserLocationRequested( false );
}
}, [userLocation, panToUserLocationRequested, region] );
// 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
const onPermissionGranted = useCallback( ( ) => {
setPermissionRequested( false );
setShowsUserLocation( true );
setPanToUserLocationRequested( true );
}, [setPermissionRequested, setShowsUserLocation, setPanToUserLocationRequested] );
const onPermissionBlocked = useCallback( ( ) => {
setPermissionRequested( false );
setShowsUserLocation( false );
}, [setPermissionRequested, setShowsUserLocation] );
const onPermissionDenied = useCallback( ( ) => {
setPermissionRequested( false );
setShowsUserLocation( false );
}, [setPermissionRequested, setShowsUserLocation] );
const params = useMemo( ( ) => ( {
...tileMapParams,
color: "%2374ac00",
verifiable: "true"
} ), [tileMapParams] );
const obscurationCell = obscurationCellForLatLng( obsLatitude, obsLongitude );
if ( obscured ) {
initialRegion = {
@@ -114,20 +203,14 @@ const Map = ( {
};
}
const params = {
...tileMapParams,
color: "%2374ac00",
verifiable: "true"
};
const queryString = Object.keys( params ).map( key => `${key}=${params[key]}` ).join( "&" );
const url = currentZoom > 13
? `${baseUrl}/points/{z}/{x}/{y}.png`
: `${baseUrl}/grid/{z}/{x}/{y}.png`;
? `${API_ENDPOINT}/points/{z}/{x}/{y}.png`
: `${API_ENDPOINT}/grid/{z}/{x}/{y}.png`;
const urlTemplate = `${url}?${queryString}`;
const exploreNavigateToDetails = async latLng => {
const onMapPressForObsLyr = useCallback( async latLng => {
const UTFPosition = createUTFPosition( currentZoom, latLng.latitude, latLng.longitude );
const {
mTilePositionX,
@@ -142,7 +225,8 @@ const Map = ( {
const gridQuery = Object.keys( tilesParams )
.map( key => `${key}=${tilesParams[key]}` ).join( "&" );
const gridUrl = `${tilesUrl}/${currentZoom}/${mTilePositionX}/${mTilePositionY}.grid.json`;
const gridUrl = `${POINT_TILES_ENDPOINT}/${currentZoom}/${mTilePositionX}/${mTilePositionY}`
+ ".grid.json";
const gridUrlTemplate = `${gridUrl}?${gridQuery}`;
const options = {
@@ -161,15 +245,7 @@ const Map = ( {
if ( uuid ) {
navigation.navigate( "ObsDetails", { uuid } );
}
};
const onMapPress = coordinate => {
if ( showExplore ) {
exploreNavigateToDetails( coordinate );
} else if ( openMapScreen ) {
openMapScreen();
}
};
}, [params, currentZoom, navigation] );
const locationIndicator = () => (
// $FlowIgnore
@@ -188,82 +264,118 @@ const Map = ( {
: null
]}
testID="MapView"
className="flex-1"
className="flex-1 h-full"
>
{!hideMap && (
<MapView
ref={mapViewRef}
testID="Map.MapView"
className="flex-1"
region={( region?.latitude )
? region
: initialRegion}
onRegionChange={updateCoords}
showsUserLocation
showsMyLocationButton={showsMyLocationButton}
loadingEnabled
onRegionChangeComplete={async r => {
setCurrentZoom( calculateZoom( screenWidth, r.longitudeDelta ) );
}}
onPress={e => onMapPress( e.nativeEvent.coordinate )}
mapType={mapType || "standard"}
>
{( urlTemplate && showExplore ) && (
<UrlTile
testID="Map.UrlTile"
tileSize={512}
urlTemplate={urlTemplate}
/>
)}
{( showLocationIndicator && ( !obscured ) ) && (
<>
<Circle
center={{
latitude: obsLatitude,
longitude: obsLongitude
}}
radius={positionalAccuracy}
strokeWidth={2}
strokeColor="#74AC00"
fillColor="rgba( 116, 172, 0, 0.2 )"
/>
<Marker
coordinate={{
latitude: obsLatitude,
longitude: obsLongitude
}}
>
{locationIndicator()}
</Marker>
</>
)}
{( showLocationIndicator && obscured ) && (
<Polygon
coordinates={[
{
latitude: obscurationCell.minLat,
longitude: obscurationCell.minLng
},
{
latitude: obscurationCell.minLat,
longitude: obscurationCell.maxLng
},
{
latitude: obscurationCell.maxLat,
longitude: obscurationCell.maxLng
},
{
latitude: obscurationCell.maxLat,
longitude: obscurationCell.minLng
}
]}
<MapView
ref={mapViewRef}
testID="Map.MapView"
className={className}
region={( region?.latitude )
? region
: initialRegion}
onRegionChange={onRegionChange}
onUserLocationChange={locationChangeEvent => {
const coordinate = locationChangeEvent?.nativeEvent?.coordinate;
if (
coordinate?.latitude
&& coordinate.latitude.toFixed( 4 ) !== userLocation?.latitude.toFixed( 4 )
) {
setUserLocation( coordinate );
}
}}
showsUserLocation={showsUserLocation}
loadingEnabled
onRegionChangeComplete={async newRegion => {
if ( onRegionChangeComplete ) onRegionChangeComplete( newRegion );
setCurrentZoom( calculateZoom( screenWidth, newRegion.longitudeDelta ) );
}}
onPress={e => {
if ( withObsTiles ) onMapPressForObsLyr( e.nativeEvent.coordinate );
else if ( openMapScreen ) {
openMapScreen( );
}
}}
showsCompass={showsCompass}
mapType={mapType || "standard"}
minZoomLevel={minZoomLevel}
onMapReady={onMapReady}
style={style}
>
{withObsTiles && urlTemplate && (
<UrlTile
testID="Map.UrlTile"
tileSize={512}
urlTemplate={urlTemplate}
/>
)}
{( showLocationIndicator && ( !obscured ) ) && (
<>
<Circle
center={{
latitude: obsLatitude,
longitude: obsLongitude
}}
radius={positionalAccuracy}
strokeWidth={2}
strokeColor={colors.inatGreen}
strokeColor="#74AC00"
fillColor="rgba( 116, 172, 0, 0.2 )"
/>
)}
</MapView>
<Marker
coordinate={{
latitude: obsLatitude,
longitude: obsLongitude
}}
>
{locationIndicator()}
</Marker>
</>
)}
{( showLocationIndicator && obscured ) && (
<Polygon
coordinates={[
{
latitude: obscurationCell.minLat,
longitude: obscurationCell.minLng
},
{
latitude: obscurationCell.minLat,
longitude: obscurationCell.maxLng
},
{
latitude: obscurationCell.maxLat,
longitude: obscurationCell.maxLng
},
{
latitude: obscurationCell.maxLat,
longitude: obscurationCell.minLng
}
]}
strokeWidth={2}
strokeColor={colors.inatGreen}
fillColor="rgba( 116, 172, 0, 0.2 )"
/>
)}
</MapView>
{ showCurrentLocationButton && (
<INatIconButton
icon="location-crosshairs"
className="absolute bottom-5 right-5 bg-white rounded-full"
style={getShadow( theme.colors.primary )}
accessibilityLabel={t( "User-location" )}
onPress={( ) => {
setPanToUserLocationRequested( true );
setShowsUserLocation( true );
setPermissionRequested( true );
}}
/>
)}
<LocationPermissionGate
permissionNeeded={permissionRequested}
onPermissionGranted={onPermissionGranted}
onPermissionBlocked={onPermissionBlocked}
onPermissionDenied={onPermissionDenied}
withoutNavigation
/>
{children}
</View>
);

View File

@@ -9,6 +9,8 @@ type Props = {
closeModal: Function,
modal: any,
backdropOpacity?: number,
fullScreen?: boolean,
onModalHide?: Function,
style?: Object,
animationIn?: string,
animationOut?: string,
@@ -20,12 +22,25 @@ const modalStyle = {
justifyContent: "flex-end"
};
const fullScreenModalStyle = {
...modalStyle,
margin: 0
};
// accessibility might not work on Android because of backdrop
// https://github.com/react-native-modal/react-native-modal/issues/525
const Modal = ( {
showModal, closeModal, modal, backdropOpacity, style,
animationIn, animationOut, disableSwipeDirection = false
animationIn,
animationOut,
backdropOpacity,
closeModal,
disableSwipeDirection,
fullScreen = false,
modal,
onModalHide,
showModal,
style
}: Props ): React.Node => {
const swipeDirection = disableSwipeDirection
? null
@@ -38,8 +53,15 @@ const Modal = ( {
swipeDirection={swipeDirection}
useNativeDriverForBackdrop
useNativeDriver
style={{ ...style, ...modalStyle }}
style={{
...style,
...( fullScreen
? fullScreenModalStyle
: modalStyle
)
}}
backdropOpacity={backdropOpacity}
onModalHide={onModalHide}
animationIn={animationIn || "slideInUp"}
animationOut={animationOut || "slideOutDown"}
>

View File

@@ -1,129 +1,115 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import classnames from "classnames";
import {
PermissionsAndroid,
Platform,
Pressable,
Text,
Body2,
Button,
Heading2,
INatIcon,
INatIconButton,
ViewWrapper
} from "components/SharedComponents";
import {
ImageBackground,
View
} from "components/styledComponents";
import { t } from "i18next";
import React from "react";
import {
Linking
} from "react-native";
import { request, RESULTS } from "react-native-permissions";
import useTranslation from "sharedHooks/useTranslation";
import { viewStyles } from "styles/permissionGate";
import DeviceInfo from "react-native-device-info";
import { RESULTS } from "react-native-permissions";
import colors from "styles/tailwindColors";
import ViewWrapper from "./ViewWrapper";
type Props = {
children: Node,
permission: string,
isIOS?: boolean,
const BACKGROUND_IMAGE_STYLE = {
opacity: 0.33,
backgroundColor: "black"
};
// Prompts the user for an Android permission and renders children if granted.
// Otherwise renders a view saying that permission is required, with a button
// to grant it if the user hasn't asked not to be bothered again. In the
// future we might want to extend this to always show a custom view before
// asking the user for a permission.
const PermissionGate = ( { children, permission, isIOS }: Props ): Node => {
const navigation = useNavigation();
const { t } = useTranslation();
const [result, setResult] = useState(
( isIOS && Platform.OS === "ios" ) || ( !isIOS && Platform.OS === "android" )
? null
: "granted"
);
useEffect( () => {
// kueda 20220422: for reasons I don't understand, the app crashes if this
// effect refers to anything defined outside of this function, hence no
// constants for the result states and no abstraction of this method for
// requesting permissions
const requestAndroidPermissions = async () => {
const r = await PermissionsAndroid.request( permission );
if ( r === PermissionsAndroid.RESULTS.GRANTED ) {
setResult( "granted" );
} else if ( r === PermissionsAndroid.RESULTS.DENIED ) {
setResult( "denied" );
} else {
setResult( "never_ask_again" );
}
};
const isTablet = DeviceInfo.isTablet();
const requestiOSPermissions = async () => {
const r = await request( permission );
if ( r === RESULTS.GRANTED ) {
setResult( "granted" );
} else if ( r === RESULTS.DENIED ) {
setResult( "denied" );
} else {
setResult( "never_ask_again" );
}
};
if ( result === null ) {
if ( Platform.OS === "android" ) {
requestAndroidPermissions();
} else {
requestiOSPermissions();
}
}
// If this component has already been rendered but was just returned to in
// the navigation, check again
navigation.addListener( "focus", async () => {
if ( result === null && Platform.OS === "android" ) {
await requestAndroidPermissions();
}
} );
}, [permission, navigation, result] );
const manualGrantButton = (
<Pressable
accessibilityRole="button"
style={viewStyles.permissionButton}
onPress={async () => {
try {
const r = await PermissionsAndroid.request( permission );
if ( r === PermissionsAndroid.RESULTS.GRANTED ) {
setResult( "granted" );
} else if ( r === PermissionsAndroid.RESULTS.DENIED ) {
setResult( "denied" );
} else {
setResult( "never_ask_again" );
}
} catch ( e ) {
console.warn(
// eslint-disable-next-line max-len
`[DEBUG ${Platform.OS}] PermissionGate: Failed to request permission (${permission}): ${e}`
);
}
}}
>
<Text>{t( "Grant-Permission" )}</Text>
</Pressable>
);
const noPermission = (
<ViewWrapper>
<Text>{t( "You-denied-iNaturalist-permission-to-do-that" )}</Text>
{result === "denied" && manualGrantButton}
{result === "never_ask_again" && (
<Text>{t( "Go-to-the-Settings-app-to-grant-permissions" )}</Text>
const PermissionGate = ( {
requestPermission,
grantStatus,
icon,
title = t( "Grant-Permission" ),
titleDenied = t( "Please-Grant-Permission" ),
body,
blockedPrompt = t( "Youve-denied-permission-prompt" ),
buttonText = t( "GRANT-PERMISSION" ),
image = require( "images/bart-zimny-W5XTTLpk1-I-unsplash.jpg" ),
onClose
} ) => (
<ViewWrapper wrapperClassName="bg-black">
<ImageBackground
source={image}
imageStyle={BACKGROUND_IMAGE_STYLE}
accessibilityIgnoresInvertColors
className={classnames(
"w-full",
"h-full",
"items-center"
)}
</ViewWrapper>
);
let content = <Text>Requesting permission...</Text>;
if ( result === "granted" ) {
content = children;
} else if ( result !== null ) {
content = noPermission;
}
return <View style={viewStyles.PermissionGate}>{content}</View>;
};
>
<INatIconButton
icon="close"
color={colors.white}
onPress={() => onClose( )}
className="absolute top-2 right-2 z-10"
accessibilityLabel={t( "Close-permission-request-screen" )}
/>
<View
className={classnames(
isTablet
? "w-[500px]"
: "w-full",
"h-full",
isTablet
? "justify-center"
: "justify-end",
"p-5",
"items-center"
)}
>
{ icon && (
<INatIcon
name={icon}
color={colors.white}
size={40}
/>
) }
<Heading2 className="text-center text-white mt-8 mb-5">
{ grantStatus === null
? title
: titleDenied}
</Heading2>
{ body && (
<Body2 className="text-center text-white">{ body }</Body2>
) }
{ grantStatus === RESULTS.BLOCKED && (
<Body2 className="text-center text-white mt-5">
{ blockedPrompt }
</Body2>
) }
{ grantStatus === RESULTS.BLOCKED
? (
<Button
level="focus"
onPress={( ) => Linking.openSettings( )}
text={t( "OPEN-SETTINGS" )}
className="w-full mt-10"
/>
)
: (
<Button
level="focus"
onPress={requestPermission}
text={buttonText}
className="w-full mt-10"
/>
)}
</View>
</ImageBackground>
</ViewWrapper>
);
export default PermissionGate;

View File

@@ -0,0 +1,213 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import Modal from "components/SharedComponents/Modal";
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
} from "react-native-permissions";
import PermissionGate from "./PermissionGate";
const usesAndroid10Permissions = Platform.OS === "android" && Platform.Version <= 29;
const usesAndroid13Permissions = Platform.OS === "android" && Platform.Version >= 33;
let androidReadPermissions = [
PERMISSIONS.ANDROID.ACCESS_MEDIA_LOCATION
];
if ( usesAndroid10Permissions ) {
androidReadPermissions = [...androidReadPermissions, PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE];
} else if ( usesAndroid13Permissions ) {
androidReadPermissions = [...androidReadPermissions, PERMISSIONS.ANDROID.READ_MEDIA_IMAGES];
} else {
androidReadPermissions = [...androidReadPermissions, 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"
? [PERMISSIONS.IOS.CAMERA]
: androidCameraPermissions;
export const AUDIO_PERMISSIONS: Array<string> = Platform.OS === "ios"
? [PERMISSIONS.IOS.MICROPHONE]
: [...androidReadPermissions, PERMISSIONS.ANDROID.RECORD_AUDIO];
export const READ_MEDIA_PERMISSIONS: Array<string> = Platform.OS === "ios"
? [PERMISSIONS.IOS.PHOTO_LIBRARY]
: androidReadPermissions;
export const LOCATION_PERMISSIONS: Array<string> = Platform.OS === "ios"
? [PERMISSIONS.IOS.LOCATION_WHEN_IN_USE]
: [PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION];
type Props = {
children?: Node,
permissions: Array<string>,
icon?: string,
title?: string,
titleDenied: string,
body?: string,
blockedPrompt?: string,
buttonText?: string,
image?: Object,
permissionNeeded?: boolean,
onPermissionGranted?: Function,
onPermissionDenied?: Function,
onPermissionBlocked?: Function,
withoutNavigation?: boolean
};
export function permissionResultFromMultiple( multiResults: Object ): string {
if ( typeof ( multiResults ) !== "object" ) {
throw new Error(
"permissionResultFromMultiple received something other than an object. "
+ "Make sure you're using it with checkMultiple and not check"
);
}
if ( _.find( multiResults, ( permResult, _perm ) => permResult === RESULTS.BLOCKED ) ) {
return RESULTS.BLOCKED;
}
if ( _.find( multiResults, ( permResult, _perm ) => permResult === RESULTS.DENIED ) ) {
return RESULTS.DENIED;
}
if ( _.find( multiResults, ( permResult, _perm ) => permResult === RESULTS.UNAVAILABLE ) ) {
return RESULTS.UNAVAILABLE;
}
return RESULTS.GRANTED;
}
// Prompts the user for an Android permission and renders children if granted.
// Otherwise renders a view saying that permission is required, with a button
// to grant it if the user hasn't asked not to be bothered again. In the
// future we might want to extend this to always show a custom view before
// asking the user for a permission.
const PermissionGateContainer = ( {
blockedPrompt,
body,
buttonText,
children,
icon,
image,
onPermissionBlocked,
onPermissionDenied,
onPermissionGranted,
permissionNeeded = true,
permissions,
title,
titleDenied,
withoutNavigation
}: Props ): Node => {
const [result, setResult] = useState( null );
const [modalShown, setModalShown] = useState( false );
const navigation = useNavigation();
const requestPermission = useCallback( async ( ) => {
const requestResult = await requestMultiple( permissions );
setResult( permissionResultFromMultiple( requestResult ) );
}, [permissions] );
const checkPermission = useCallback( async ( ) => {
const checkResult = await checkMultiple( permissions );
setResult( permissionResultFromMultiple( checkResult ) );
}, [permissions] );
useEffect( () => {
if ( result === null && permissionNeeded ) {
checkPermission( );
}
}, [checkPermission, result, permissionNeeded] );
useEffect( ( ) => {
if (
withoutNavigation
&& permissionNeeded
&& result !== RESULTS.GRANTED
&& result !== null
) {
setModalShown( true );
return () => {};
}
if ( !withoutNavigation ) {
const unsubscribe = navigation.addListener( "focus", async () => {
await checkPermission( );
setModalShown( true );
} );
return unsubscribe;
}
return () => {};
}, [
checkPermission,
children,
navigation,
permissionNeeded,
result,
withoutNavigation
] );
useEffect( ( ) => {
if ( result === RESULTS.GRANTED && !children ) {
setModalShown( false );
}
}, [result, children, setModalShown] );
const closeModal = useCallback( ( ) => {
setModalShown( false );
if ( !withoutNavigation ) navigation.goBack( );
}, [
navigation,
setModalShown,
withoutNavigation
] );
// If the result changes, notify the parent component
useEffect( ( ) => {
if ( onPermissionDenied && result === RESULTS.DENIED ) {
onPermissionDenied( );
} else if ( onPermissionGranted && result === RESULTS.GRANTED ) {
onPermissionGranted( );
} else if ( onPermissionBlocked && result === RESULTS.BLOCKED ) {
onPermissionBlocked( );
}
}, [
onPermissionBlocked,
onPermissionDenied,
onPermissionGranted,
result
] );
// If permission was granted, just render the children
if ( result === RESULTS.GRANTED && children ) return children;
if ( !result ) return null;
return (
<Modal
showModal={modalShown}
closeModal={closeModal}
fullScreen
modal={(
<PermissionGate
requestPermission={requestPermission}
grantStatus={result}
icon={icon}
title={title}
titleDenied={titleDenied}
body={body}
blockedPrompt={blockedPrompt}
buttonText={buttonText}
image={image}
onClose={closeModal}
/>
)}
/>
);
};
export default PermissionGateContainer;

View File

@@ -40,9 +40,7 @@ const TaxonResult = ( {
const theme = useTheme( );
const taxonImage = { uri: taxon?.default_photo?.url };
const navToTaxonDetails = ( ) => navigation.navigate( "TaxonDetails", {
id: taxon.id
} );
const navToTaxonDetails = () => navigation.navigate( "TaxonDetails", { id: taxon.id } );
return (
<View

View File

@@ -1,5 +1,6 @@
// @flow strict-local
// @flow
import classnames from "classnames";
import { SafeAreaView } from "components/styledComponents";
import * as React from "react";
import { StatusBar } from "react-native";
@@ -7,10 +8,20 @@ import { StatusBar } from "react-native";
type Props = {
children: React.Node,
testID?: string,
// If someone can explain to me why className doesn't work here, I'm all
// ears ~~~kueda 20230815
wrapperClassName?: string
};
const ViewWrapper = ( { children, testID }: Props ): React.Node => (
<SafeAreaView className="flex-1 bg-white" testID={testID}>
const ViewWrapper = ( {
children,
wrapperClassName,
testID
}: Props ): React.Node => (
<SafeAreaView
className={classnames( "flex-1", "bg-white", wrapperClassName )}
testID={testID}
>
<StatusBar barStyle="dark-content" />
{children}
</SafeAreaView>

View File

@@ -1277,7 +1277,62 @@ No-results-found = No results found
# Default accessibility label for DisplayTaxon component
Taxon-photo-and-name = Taxon photo and name
You-havent-joined-any-projects-yet = You haven't joined any projects yet!
# Title of screen asking for permission to access the camera
Observe-and-identify-organisms-in-real-time-with-your-camera = Observe and identify organisms in real-time with your camera
# Title of screen asking for permission to access the camera when access was denied
Please-allow-Camera-Access = Please allow Camera Access
Use-the-iNaturalist-camera-to-observe = Use the iNaturalist camera to observe and identify organisms on-screen in real-time, and share them with our community to get identifications and contribute to science!
Youve-previously-denied-camera-permissions = You've previously denied camera permissions, so please enable them in settings.
# Text for a button prompting the user to grant access to the camera
OBSERVE-ORGANISMS = OBSERVE ORGANISMS
# Text for a button that opens the operating system Settings app
OPEN-SETTINGS = OPEN SETTINGS
# Title of a screen asking for permission
Grant-Permission = Grant Permission
# Title of a screen asking for permission when permission has been denied
Please-Grant-Permission = Please Grant Permission
# 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.
# Text for a button that asks the user to grant permission
GRANT-PERMISSION = GRANT PERMISSION
# Title of screen asking for permission to access the microphone
Record-organism-sounds-with-the-microphone = Record organism sounds with the microphone
# Title of screen asking for permission to access the microphone when access was denied
Please-allow-Microphone-Access = Please allow Microphone Access
Use-your-devices-microphone-to-record = Use your devices microphone to record sounds made by organisms and share them with our community to get identifications and contribute to science!
Youve-previously-denied-microphone-permissions = Youve previously denied microphone permissions, so please enable them in settings.
# Text for a button prompting the user to grant access to the microphone
RECORD-SOUND = RECORD SOUND
# Title of screen asking for permission to access the gallery
Observe-and-identify-organisms-from-your-gallery = Observe and identify organisms from your gallery
# Title of screen asking for permission to access the gallery when access was denied
Please-Allow-Gallery-Access = Please Allow Gallery Access
Upload-photos-from-your-gallery-and-create-observations = Upload photos from your gallery and create observations and get identifications of organisms youve already observed!
Youve-previously-denied-gallery-permissions = Youve previously denied gallery permissions, so please enable them in settings.
# Text for a button prompting the user to grant access to the gallery
CHOOSE-PHOTOS = CHOOSE PHOTOS
# Title of screen asking for permission to access location
Get-more-accurate-suggestions-create-useful-data = Get more accurate suggestions & create useful data for science using your location
# Title of screen asking for permission to access location when access was denied
Please-allow-Location-Access = Please allow Location Access
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.
Youve-previously-denied-location-permissions = Youve previously denied location permissions, so please enable them in settings.
# Text for a button prompting the user to grant access to location
USE-LOCATION = USE LOCATION
# Accessibility label for a button that closes the permission request screen
Close-permission-request-screen = Close permission request screen
You-havent-joined-any-projects-yet = You havent joined any projects yet!
You-can-click-join-on-the-project-page = You can click “join” on the project page.

View File

@@ -187,7 +187,7 @@
"val": "Go to the Settings app to grant iNaturalist the appropriate permissions."
},
"Grant-Permission": {
"comment": "Verb phrase label for a button to grant the app a permission, e.g.\npermission to use the camera",
"comment": "Title of a screen asking for permission",
"val": "Grant Permission"
},
"Group-Photos": "Group Photos",
@@ -888,7 +888,83 @@
"comment": "Default accessibility label for DisplayTaxon component",
"val": "Taxon photo and name"
},
"You-havent-joined-any-projects-yet": "You haven't joined any projects yet!",
"Observe-and-identify-organisms-in-real-time-with-your-camera": {
"comment": "Title of screen asking for permission to access the camera",
"val": "Observe and identify organisms in real-time with your camera"
},
"Please-allow-Camera-Access": {
"comment": "Title of screen asking for permission to access the camera when access was denied",
"val": "Please allow Camera Access"
},
"Use-the-iNaturalist-camera-to-observe": "Use the iNaturalist camera to observe and identify organisms on-screen in real-time, and share them with our community to get identifications and contribute to science!",
"Youve-previously-denied-camera-permissions": "You've previously denied camera permissions, so please enable them in settings.",
"OBSERVE-ORGANISMS": {
"comment": "Text for a button prompting the user to grant access to the camera",
"val": "OBSERVE ORGANISMS"
},
"OPEN-SETTINGS": {
"comment": "Text for a button that opens the operating system Settings app",
"val": "OPEN SETTINGS"
},
"Please-Grant-Permission": {
"comment": "Title of a screen asking for permission when permission has been denied",
"val": "Please Grant Permission"
},
"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."
},
"GRANT-PERMISSION": {
"comment": "Text for a button that asks the user to grant permission",
"val": "GRANT PERMISSION"
},
"Record-organism-sounds-with-the-microphone": {
"comment": "Title of screen asking for permission to access the microphone",
"val": "Record organism sounds with the microphone"
},
"Please-allow-Microphone-Access": {
"comment": "Title of screen asking for permission to access the microphone when access was denied",
"val": "Please allow Microphone Access"
},
"Use-your-devices-microphone-to-record": "Use your devices microphone to record sounds made by organisms and share them with our community to get identifications and contribute to science!",
"Youve-previously-denied-microphone-permissions": "Youve previously denied microphone permissions, so please enable them in settings.",
"RECORD-SOUND": {
"comment": "Text for a button prompting the user to grant access to the microphone",
"val": "RECORD SOUND"
},
"Observe-and-identify-organisms-from-your-gallery": {
"comment": "Title of screen asking for permission to access the gallery",
"val": "Observe and identify organisms from your gallery"
},
"Please-Allow-Gallery-Access": {
"comment": "Title of screen asking for permission to access the gallery when access was denied",
"val": "Please Allow Gallery Access"
},
"Upload-photos-from-your-gallery-and-create-observations": "Upload photos from your gallery and create observations and get identifications of organisms youve already observed!",
"Youve-previously-denied-gallery-permissions": "Youve previously denied gallery permissions, so please enable them in settings.",
"CHOOSE-PHOTOS": {
"comment": "Text for a button prompting the user to grant access to the gallery",
"val": "CHOOSE PHOTOS"
},
"Get-more-accurate-suggestions-create-useful-data": {
"comment": "Title of screen asking for permission to access location",
"val": "Get more accurate suggestions & create useful data for science using your location"
},
"Please-allow-Location-Access": {
"comment": "Title of screen asking for permission to access location when access was denied",
"val": "Please allow Location Access"
},
"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.",
"Youve-previously-denied-location-permissions": "Youve previously denied location permissions, so please enable them in settings.",
"USE-LOCATION": {
"comment": "Text for a button prompting the user to grant access to location",
"val": "USE LOCATION"
},
"Close-permission-request-screen": {
"comment": "Accessibility label for a button that closes the permission request screen",
"val": "Close permission request screen"
},
"You-havent-joined-any-projects-yet": "You havent joined any projects yet!",
"You-can-click-join-on-the-project-page": "You can click “join” on the project page.",
"No-projects-match-that-search": "No projects match that search",
"RESET-SEARCH": "RESET SEARCH",

View File

@@ -80,6 +80,7 @@
"CONFIRM": "CONFIRM",
"Connected-Accounts": "Connected Accounts",
"Content-Display": "Content & Display",
"Coordinates-copied-to-keyboard": "Coordinates copied to keyboard",
"Copy-coordinates": "Copy Coordinates",
"Couldnt-create-comment": "Couldn't create comment",
"Couldnt-create-identification": "Couldn't create identification",
@@ -162,9 +163,9 @@
"Explore": "Explore",
"EXPLORE-OBSERVATIONS": "EXPLORE OBSERVATIONS",
"External-Applications": "External Applications",
"Featured": {
"FEATURED": {
"comment": "Header for featured projects",
"val": "Featured"
"val": "FEATURED"
},
"Fetching-location": "Fetching location...",
"Filters": "Filters",
@@ -203,7 +204,7 @@
},
"IDENTIFICATION": "IDENTIFICATION",
"IDENTIFICATIONS": "IDENTIFICATIONS",
"If-an-account-with-that-email-exists": "If an account with that email exists, weve sent password reset instructions to your email.",
"If-an-account-with-that-email-exists": "If an account with that email exists, we've sent password reset instructions to your email.",
"Import-X-photos": {
"comment": "Shows the number of photos a user selected from the camera roll for upload",
"val": "Import { $count ->\n [one] 1 photo\n *[other] { $count } photos\n}"
@@ -219,9 +220,9 @@
"val": "The username or password is incorrect"
},
"Join-the-largest-community-of-naturalists": "Join the largest community of naturalists in the world!",
"Joined": {
"JOINED": {
"comment": "Header for joined projects",
"val": "Joined"
"val": "JOINED"
},
"Joined-date": {
"comment": "Shows date user joined iNaturalist on user profile",
@@ -277,9 +278,9 @@
"Muted-Users": "Muted Users",
"Names": "Names",
"Native": "Native",
"Nearby": {
"NEARBY": {
"comment": "Header for nearby projects",
"val": "Nearby"
"val": "NEARBY"
},
"New-Observation": "New Observation",
"Next": "Next",
@@ -303,6 +304,10 @@
},
"Notifications": "Notifications",
"Obscured": "Obscured",
"Obscured-observation-location-map-description": {
"comment": "Displayed when user views an obscured location on the ObsDetail map screen",
"val": "This observations location is obscured. You are seeing a randomized point within the obscuration polygon."
},
"Observation": "Observation",
"Observation-Attribution": "Observation: © { $attribution } · { $restrictions }",
"OBSERVATIONS": "OBSERVATIONS",
@@ -425,7 +430,6 @@
"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-to-add-an-identification": "Search for a taxon to add an identification.",
"Search-for-a-user": "Search for a user",
"Search-for-description-tags-text": "Search for description/tags text",
"Select": "Select",
@@ -514,7 +518,7 @@
},
"VIEW-DATA-QUALITY-ASSESSEMENT": "VIEW DATA QUALITY ASSESSEMENT",
"View-in-browser": "View in Browser",
"Visually-search-iNaturalist-data": "Visually search iNaturalists wealth of data. Search by a taxon in a location",
"Visually-search-iNaturalist-data": "Visually search iNaturalist's wealth of data. Search by a taxon in a location",
"Welcome-to-iNaturalist": "Welcome to iNaturalist!",
"Whenever-you-get-internet-connection-you-can-upload": "Whenever you get internet connection, you can upload your observations to iNaturalist.",
"Which-traditional-projects-can-add-your-observations": "Which traditional projects can add your observations?",
@@ -551,7 +555,7 @@
"Take-a-photo-with-your-camera": "Take a photo with your camera",
"Upload-a-photo-from-your-gallery": "Upload a photo from your gallery",
"Record-a-sound": "Record a sound",
"You-can-also-explore-existing-observations": "You can also explore existing observations on iNaturalist to discover whats around you.",
"You-can-also-explore-existing-observations": "You can also explore existing observations on iNaturalist to discover what's around you.",
"You-denied-iNaturalist-permission-to-do-that": {
"comment": "Message shown when a permission is required to use a part of the app\n(e.g. permission to access the camera) but the user denied the permission.",
"val": "You denied iNaturalist permission to do that"
@@ -560,7 +564,7 @@
"You-must-be-logged-in-to-view-messages": "You must be logged in to view messages",
"You-will-lose-all-existing-observations": "{ $count ->\n [one] You will lose all existing observations. Would you like to discard 1 observation?\n *[other] You will lose all existing observations. Would you like to discard { $count } observations?\n}",
"You-can-still-share-the-file": "You can still share the file with another app. If you can email it, please send it to { $email }",
"Zoom-in": "Zoom in so that the observations accuracy is as low as possible.",
"Zoom-in": "Zoom in so that the observation's accuracy is as low as possible.",
"Your-location-uncertainty-is-over-4000km": "Your location uncertainty is over 4000km, 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.",
"Category-leading": {
"comment": "Identification category",
@@ -706,6 +710,8 @@
"Observation-photos-unavailable-without-internet": "Observation photos unavailable without internet",
"Taxon-photo-unavailable-without-internet": "Taxon photo unavailable without internet",
"User-photo-unavailable-without-internet": "User photo unavailable without internet",
"Search": "Search",
"Select-photo": "Select photo",
"Add-this-ID": {
"comment": "Accessibility labels for icons",
"val": "Add this identification"
@@ -730,6 +736,8 @@
"User-location": "User location",
"Loading-wheel": "Loading wheel",
"Map-layers": "Map layers",
"Share-map": "Share map",
"Copy-map-coordinates": "Copy map coordinates",
"Identification-label": "Identification label",
"Observation-with-no-evidence": "Observation with no evidence",
"Photo-importer": "Photo importer",
@@ -743,6 +751,7 @@
"Start-upload": "Start upload",
"Previous-observation": "Previous observation",
"Next-observation": "Next observation",
"Close-search": "Close search",
"date-format-short": {
"comment": "Date formatting using date-fns\nSee complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format",
"val": "M/d/yy"
@@ -792,16 +801,20 @@
"The-location-will-not-be-visible": "The location will not be visible to others, which means it may be difficult to identify.",
"This-is-a-wild-organism": {
"comment": " Wild status sheet descriptions",
"val": "This is a wild organism and wasnt placed in this location by humans."
"val": "This is a wild organism and wasn't placed in this location by humans."
},
"This-organism-was-placed-by-humans": "This organism was placed in this location by humans. This applies to things like garden plants, pets, and zoo animals.",
"Lat-Lon-Acc": {
"comment": "Latitude, longitude, and accuracy on a single line on a single line",
"val": "Lat: { NUMBER($latitude, maximumFractionDigits: \"6\") }, Lon: { NUMBER($longitude, maximumFractionDigits: \"6\") }, Acc: { $accuracy }"
},
"Lat-Lon": {
"comment": "Latitude, longitude on a single line on a single line",
"val": "{ NUMBER($latitude, maximumFractionDigits: \"6\") }, { NUMBER($longitude, maximumFractionDigits: \"6\") }"
},
"Every-observation-needs": {
"comment": "Missing evidence sheet",
"val": "Every observation needs a location, date, and time to be helpful to identifiers. You can edit geoprivacy if youre concerned about location privacy."
"val": "Every observation needs a location, date, and time to be helpful to identifiers. You can edit geoprivacy if you're concerned about location privacy."
},
"Stop-upload": {
"comment": "Button or accessibility label for an interactive element that stops an upload",
@@ -837,6 +850,7 @@
"SPECIES-WITHOUT-NUMBER": "{ $count ->\n [one] SPECIES\n *[other] SPECIES\n}",
"IDENTIFICATIONS-WITHOUT-NUMBER": "{ $count ->\n [one] IDENTIFICATION\n *[other] IDENTIFICATIONS\n}",
"JOURNAL-POSTS-WITHOUT-NUMBER": "{ $count ->\n [one] JOURNAL POST\n *[other] JOURNAL POSTS\n}",
"MEMBERS-WITHOUT-NUMBER": "{ $count ->\n [one] MEMBER\n *[other] MEMBERS\n}",
"Yes-license-my-photos": "Yes, license my photos, sounds, and observations so scientists can use my data (recommended)",
"I-consent-to-allow-iNaturalist-to-store": "I consent to allow iNaturalist to store and process limited kinds of personal information about me in order to manage my account (required)",
"I-consent-to-allow-my-personal-information": "I consent to allow my personal information to be transferred to the United States of America (required)",
@@ -865,7 +879,7 @@
"Log-in-to-iNaturalist": "Log in to iNaturalist",
"Try-searching-for-a-location-name": "Try searching for a location name to see the map",
"Scan-the-area-around-you-for-organisms": "Scan the area around you for organisms.",
"Loading-iNaturalists-AR-Camera": "Loading iNaturalists AR Camera",
"Loading-iNaturalists-AR-Camera": "Loading iNaturalist's AR Camera",
"No-results-found": {
"comment": "Used for explore screen when search params lead to a search with no data",
"val": "No results found"
@@ -950,8 +964,42 @@
"Close-permission-request-screen": {
"comment": "Accessibility label for a button that closes the permission request screen",
"val": "Close permission request screen"
}
},
"You-havent-joined-any-projects-yet": "You havent joined any projects yet!",
=======
"Your-email-is-confirmed": "Your email is confirmed! Please log in to continue."
"You-havent-joined-any-projects-yet": "You haven't joined any projects yet!",
>>>>>>> main
"You-can-click-join-on-the-project-page": "You can click “join” on the project page.",
"No-projects-match-that-search": "No projects match that search",
"RESET-SEARCH": "RESET SEARCH",
"PROJECT-REQUIREMENTS": "PROJECT REQUIREMENTS",
"VIEW-PROJECT-REQUIREMENTS": "VIEW PROJECT REQUIREMENTS",
"VIEW-IN-EXPLORE": "VIEW IN EXPLORE",
"Collection-Project": "Collection Project",
"Umbrella-Project": "Umbrella Project",
"Traditional-Project": "Traditional Project",
"ABOUT-COLLECTION-PROJECTS": "ABOUT COLLECTION PROJECTS",
"ABOUT-TRADITIONAL-PROJECTS": "ABOUT TRADITIONAL PROJECTS",
"ABOUT-UMBRELLA-PROJECTS": "ABOUT UMBRELLA PROJECTS",
"Every-time-a-collection-project": "Every time a collection project's page is loaded, iNaturalist will perform a quick search and display all observations that match the project's requirements. It is an easy way to display a set of observations, such as for a class project, a park, or a bioblitz without making participants take the extra step of manually adding their observations to a project.",
"Obervations-must-be-manually-added": "Observations must be manually added to a traditional project, either during the upload stage or after the observation has been shared to iNaturalist. A user must also join a traditional project in order to add their observations to it.",
"If-you-want-to-collate-compare-promote": "If you want to collate, compare, or promote a set of existing projects, then an Umbrella project is what you should use. For example the 2018 City Nature Challenge, which collated over 60 projects, made for a great landing page where anyone could compare and contrast each city's observations. Both Collection and Traditional projects can be used in an Umbrella project, and up to 500 projects can be collated by an Umbrella project.",
"JOIN-PROJECT": "JOIN PROJECT",
"JOIN": "JOIN",
"LEAVE-PROJECT": "LEAVE PROJECT",
"LEAVE": "LEAVE",
"Your-email-is-confirmed": "Your email is confirmed! Please log in to continue.",
"SEARCH-FOR-A-TAXON": "SEARCH FOR A TAXON",
"Select-the-identification-you-want-to-add": "Select the identification you want to add to this observation. You can add a filter to further refine your results or search for a taxon.",
"TOP-ID-SUGGESTION": "TOP ID SUGGESTION",
"NEARBY-SUGGESTIONS": "NEARBY SUGGESTIONS",
"INCLUDE-TAXA-NOT-EXPECTED-NEARBY": "INCLUDE TAXA NOT EXPECTED NEARBY",
"ONLY-SHOW-TAXA-EXPECTED-NEARBY": "ONLY-SHOW-TAXA-EXPECTED-NEARBY",
"iNaturalist-Identification-suggestions-are-trained-on": "iNaturalist's Identification suggestions are trained on observations and identifications made by the iNaturalist community, including { $user1 }, { $user2 }, { $user3 }, and many others.",
"SPECIES-NEARBY": "SPECIES NEARBY",
"Below-are-all-the-species-observed-within-50km": "Below are all the species observed within 50 km of your location within the taxon:",
"Species-Nearby-requires-internet-to-work": "Species Nearby requires internet to work. Please check your internet connection.",
"Your-identification-will-be-posted-with-the-following-comment": "Your identification will be posted with the following comment:",
"iNaturalist-isnt-able-to-provide-a-top-ID-suggestion-for-this-photo": "iNaturalist isn't able to provide a top ID suggestion for this photo.",
"iNaturalist-has-no-ID-suggestions-for-this-photo": "iNaturalist has no ID suggestions for this photo."
}

View File

@@ -132,6 +132,8 @@ Connected-Accounts = Connected Accounts
Content-Display = Content & Display
Coordinates-copied-to-keyboard = Coordinates copied to keyboard
Copy-coordinates = Copy Coordinates
Copy-coordinates = Copy Coordinates
@@ -300,7 +302,7 @@ EXPLORE-OBSERVATIONS = EXPLORE OBSERVATIONS
External-Applications = External Applications
# Header for featured projects
Featured = Featured
FEATURED = FEATURED
Fetching-location = Fetching location...
@@ -355,7 +357,7 @@ IDENTIFICATION = IDENTIFICATION
IDENTIFICATIONS = IDENTIFICATIONS
If-an-account-with-that-email-exists = If an account with that email exists, weve sent password reset instructions to your email.
If-an-account-with-that-email-exists = If an account with that email exists, we've sent password reset instructions to your email.
# Shows the number of photos a user selected from the camera roll for upload
Import-X-photos = Import {$count ->
@@ -381,7 +383,7 @@ Invalid-login = The username or password is incorrect
Join-the-largest-community-of-naturalists = Join the largest community of naturalists in the world!
# Header for joined projects
Joined = Joined
JOINED = JOINED
# Shows date user joined iNaturalist on user profile
Joined-date = Joined: {$date}
@@ -469,7 +471,7 @@ Names = Names
Native = Native
# Header for nearby projects
Nearby = Nearby
NEARBY = NEARBY
New-Observation = New Observation
@@ -501,6 +503,9 @@ Notifications = Notifications
Obscured = Obscured
# Displayed when user views an obscured location on the ObsDetail map screen
Obscured-observation-location-map-description = This observations location is obscured. You are seeing a randomized point within the obscuration polygon.
Observation = Observation
Observation-Attribution = Observation: © {$attribution} · {$restrictions}
@@ -699,8 +704,6 @@ Search-for-a-project = Search for a project
Search-for-a-taxon = Search for a taxon
Search-for-a-taxon-to-add-an-identification = Search for a taxon to add an identification.
Search-for-a-user = Search for a user
Search-for-description-tags-text = Search for description/tags text
@@ -841,7 +844,7 @@ VIEW-DATA-QUALITY-ASSESSEMENT = VIEW DATA QUALITY ASSESSEMENT
View-in-browser = View in Browser
Visually-search-iNaturalist-data = Visually search iNaturalists wealth of data. Search by a taxon in a location
Visually-search-iNaturalist-data = Visually search iNaturalist's wealth of data. Search by a taxon in a location
Welcome-to-iNaturalist = Welcome to iNaturalist!
@@ -911,7 +914,7 @@ Take-a-photo-with-your-camera = Take a photo with your camera
Upload-a-photo-from-your-gallery = Upload a photo from your gallery
Record-a-sound = Record a sound
You-can-also-explore-existing-observations = You can also explore existing observations on iNaturalist to discover whats around you.
You-can-also-explore-existing-observations = You can also explore existing observations on iNaturalist to discover what's around you.
# Message shown when a permission is required to use a part of the app
# (e.g. permission to access the camera) but the user denied the permission.
@@ -929,7 +932,7 @@ You-will-lose-all-existing-observations = {$count ->
You-can-still-share-the-file =
You can still share the file with another app. If you can email it, please send it to { $email }
Zoom-in = Zoom in so that the observations accuracy is as low as possible.
Zoom-in = Zoom in so that the observation's accuracy is as low as possible.
Your-location-uncertainty-is-over-4000km = Your location uncertainty is over 4000km, 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.
@@ -1080,6 +1083,8 @@ Location-map-unavailable-without-internet = Location map unavailable without int
Observation-photos-unavailable-without-internet = Observation photos unavailable without internet
Taxon-photo-unavailable-without-internet = Taxon photo unavailable without internet
User-photo-unavailable-without-internet = User photo unavailable without internet
Search = Search
Select-photo = Select photo
# Accessibility labels for icons
Add-this-ID = Add this identification
@@ -1103,6 +1108,8 @@ Edit = Edit
User-location = User location
Loading-wheel = Loading wheel
Map-layers = Map layers
Share-map = Share map
Copy-map-coordinates = Copy map coordinates
Filters = Filters
Identification-label = Identification label
Sound-recorder = Sound recorder
@@ -1118,6 +1125,7 @@ Add-favorite = Add favorite
Start-upload = Start upload
Previous-observation = Previous observation
Next-observation = Next observation
Close-search = Close search
# Date formatting using date-fns
# See complete list of formatting styles: https://date-fns.org/v2.29.3/docs/format
@@ -1166,14 +1174,18 @@ The-exact-location-will-be-hidden = The exact location will be hidden publicly,
The-location-will-not-be-visible = The location will not be visible to others, which means it may be difficult to identify.
# Wild status sheet descriptions
This-is-a-wild-organism = This is a wild organism and wasnt placed in this location by humans.
This-is-a-wild-organism = This is a wild organism and wasn't placed in this location by humans.
This-organism-was-placed-by-humans = This organism was placed in this location by humans. This applies to things like garden plants, pets, and zoo animals.
# Latitude, longitude, and accuracy on a single line on a single line
Lat-Lon-Acc = Lat: { NUMBER($latitude, maximumFractionDigits: 6) }, Lon: { NUMBER($longitude, maximumFractionDigits: 6) }, Acc: { $accuracy }
# Latitude, longitude on a single line on a single line
Lat-Lon = { NUMBER($latitude, maximumFractionDigits: 6) }, { NUMBER($longitude, maximumFractionDigits: 6) }
# Missing evidence sheet
Every-observation-needs = Every observation needs a location, date, and time to be helpful to identifiers. You can edit geoprivacy if youre concerned about location privacy.
Every-observation-needs = Every observation needs a location, date, and time to be helpful to identifiers. You can edit geoprivacy if you're concerned about location privacy.
# Button or accessibility label for an interactive element that stops an upload
Stop-upload = Stop upload
@@ -1221,6 +1233,11 @@ JOURNAL-POSTS-WITHOUT-NUMBER = {$count ->
*[other] JOURNAL POSTS
}
MEMBERS-WITHOUT-NUMBER = {$count ->
[one] MEMBER
*[other] MEMBERS
}
Yes-license-my-photos = Yes, license my photos, sounds, and observations so scientists can use my data (recommended)
I-consent-to-allow-iNaturalist-to-store = I consent to allow iNaturalist to store and process limited kinds of personal information about me in order to manage my account (required)
I-consent-to-allow-my-personal-information = I consent to allow my personal information to be transferred to the United States of America (required)
@@ -1238,7 +1255,7 @@ Please-click-the-link = Please click the link in the email within 60 minutes to
# Title for dialog telling the user that an Internet connection is required
Internet-Connection-Required = Internet Connection Required
Please-try-again-when-you-are-connected-to-the-internet=Please try again when you are connected to the Internet.
Please-try-again-when-you-are-connected-to-the-internet = Please try again when you are connected to the Internet.
IDENTIFY = IDENTIFY
PROJECTS = PROJECTS
@@ -1252,7 +1269,7 @@ Log-in-to-iNaturalist = Log in to iNaturalist
Try-searching-for-a-location-name = Try searching for a location name to see the map
Scan-the-area-around-you-for-organisms = Scan the area around you for organisms.
Loading-iNaturalists-AR-Camera = Loading iNaturalists AR Camera
Loading-iNaturalists-AR-Camera = Loading iNaturalist's AR Camera
# Used for explore screen when search params lead to a search with no data
No-results-found = No results found
@@ -1316,6 +1333,52 @@ USE-LOCATION = USE LOCATION
# Accessibility label for a button that closes the permission request screen
Close-permission-request-screen = Close permission request screen
You-havent-joined-any-projects-yet = You havent joined any projects yet!
=======
Your-email-is-confirmed = Your email is confirmed! Please log in to continue.
You-havent-joined-any-projects-yet = You haven't joined any projects yet!
>>>>>>> main
You-can-click-join-on-the-project-page = You can click “join” on the project page.
No-projects-match-that-search = No projects match that search
RESET-SEARCH = RESET SEARCH
PROJECT-REQUIREMENTS = PROJECT REQUIREMENTS
VIEW-PROJECT-REQUIREMENTS = VIEW PROJECT REQUIREMENTS
VIEW-IN-EXPLORE = VIEW IN EXPLORE
Collection-Project = Collection Project
Umbrella-Project = Umbrella Project
Traditional-Project = Traditional Project
ABOUT-COLLECTION-PROJECTS = ABOUT COLLECTION PROJECTS
ABOUT-TRADITIONAL-PROJECTS = ABOUT TRADITIONAL PROJECTS
ABOUT-UMBRELLA-PROJECTS = ABOUT UMBRELLA PROJECTS
Every-time-a-collection-project = Every time a collection project's page is loaded, iNaturalist will perform a quick search and display all observations that match the project's requirements. It is an easy way to display a set of observations, such as for a class project, a park, or a bioblitz without making participants take the extra step of manually adding their observations to a project.
Obervations-must-be-manually-added = Observations must be manually added to a traditional project, either during the upload stage or after the observation has been shared to iNaturalist. A user must also join a traditional project in order to add their observations to it.
If-you-want-to-collate-compare-promote = If you want to collate, compare, or promote a set of existing projects, then an Umbrella project is what you should use. For example the 2018 City Nature Challenge, which collated over 60 projects, made for a great landing page where anyone could compare and contrast each city's observations. Both Collection and Traditional projects can be used in an Umbrella project, and up to 500 projects can be collated by an Umbrella project.
JOIN-PROJECT = JOIN PROJECT
JOIN = JOIN
LEAVE-PROJECT = LEAVE PROJECT
LEAVE = LEAVE
Your-email-is-confirmed = Your email is confirmed! Please log in to continue.
SEARCH-FOR-A-TAXON = SEARCH FOR A TAXON
Select-the-identification-you-want-to-add = Select the identification you want to add to this observation. You can add a filter to further refine your results or search for a taxon.
TOP-ID-SUGGESTION = TOP ID SUGGESTION
NEARBY-SUGGESTIONS = NEARBY SUGGESTIONS
INCLUDE-TAXA-NOT-EXPECTED-NEARBY = INCLUDE TAXA NOT EXPECTED NEARBY
ONLY-SHOW-TAXA-EXPECTED-NEARBY = ONLY-SHOW-TAXA-EXPECTED-NEARBY
iNaturalist-Identification-suggestions-are-trained-on = iNaturalist's Identification suggestions are trained on observations and identifications made by the iNaturalist community, including {$user1}, {$user2}, {$user3}, and many others.
SPECIES-NEARBY = SPECIES NEARBY
Below-are-all-the-species-observed-within-50km = Below are all the species observed within 50 km of your location within the taxon:
Species-Nearby-requires-internet-to-work = Species Nearby requires internet to work. Please check your internet connection.
Your-identification-will-be-posted-with-the-following-comment = Your identification will be posted with the following comment:
iNaturalist-isnt-able-to-provide-a-top-ID-suggestion-for-this-photo = iNaturalist isn't able to provide a top ID suggestion for this photo.
iNaturalist-has-no-ID-suggestions-for-this-photo = iNaturalist has no ID suggestions for this photo.

View File

@@ -1277,7 +1277,62 @@ No-results-found = No results found
# Default accessibility label for DisplayTaxon component
Taxon-photo-and-name = Taxon photo and name
You-havent-joined-any-projects-yet = You haven't joined any projects yet!
# Title of screen asking for permission to access the camera
Observe-and-identify-organisms-in-real-time-with-your-camera = Observe and identify organisms in real-time with your camera
# Title of screen asking for permission to access the camera when access was denied
Please-allow-Camera-Access = Please allow Camera Access
Use-the-iNaturalist-camera-to-observe = Use the iNaturalist camera to observe and identify organisms on-screen in real-time, and share them with our community to get identifications and contribute to science!
Youve-previously-denied-camera-permissions = You've previously denied camera permissions, so please enable them in settings.
# Text for a button prompting the user to grant access to the camera
OBSERVE-ORGANISMS = OBSERVE ORGANISMS
# Text for a button that opens the operating system Settings app
OPEN-SETTINGS = OPEN SETTINGS
# Title of a screen asking for permission
Grant-Permission = Grant Permission
# Title of a screen asking for permission when permission has been denied
Please-Grant-Permission = Please Grant Permission
# 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.
# Text for a button that asks the user to grant permission
GRANT-PERMISSION = GRANT PERMISSION
# Title of screen asking for permission to access the microphone
Record-organism-sounds-with-the-microphone = Record organism sounds with the microphone
# Title of screen asking for permission to access the microphone when access was denied
Please-allow-Microphone-Access = Please allow Microphone Access
Use-your-devices-microphone-to-record = Use your devices microphone to record sounds made by organisms and share them with our community to get identifications and contribute to science!
Youve-previously-denied-microphone-permissions = Youve previously denied microphone permissions, so please enable them in settings.
# Text for a button prompting the user to grant access to the microphone
RECORD-SOUND = RECORD SOUND
# Title of screen asking for permission to access the gallery
Observe-and-identify-organisms-from-your-gallery = Observe and identify organisms from your gallery
# Title of screen asking for permission to access the gallery when access was denied
Please-Allow-Gallery-Access = Please Allow Gallery Access
Upload-photos-from-your-gallery-and-create-observations = Upload photos from your gallery and create observations and get identifications of organisms youve already observed!
Youve-previously-denied-gallery-permissions = Youve previously denied gallery permissions, so please enable them in settings.
# Text for a button prompting the user to grant access to the gallery
CHOOSE-PHOTOS = CHOOSE PHOTOS
# Title of screen asking for permission to access location
Get-more-accurate-suggestions-create-useful-data = Get more accurate suggestions & create useful data for science using your location
# Title of screen asking for permission to access location when access was denied
Please-allow-Location-Access = Please allow Location Access
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.
Youve-previously-denied-location-permissions = Youve previously denied location permissions, so please enable them in settings.
# Text for a button prompting the user to grant access to location
USE-LOCATION = USE LOCATION
# Accessibility label for a button that closes the permission request screen
Close-permission-request-screen = Close permission request screen
You-havent-joined-any-projects-yet = You havent joined any projects yet!
You-can-click-join-on-the-project-page = You can click “join” on the project page.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -5,7 +5,11 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack";
import CameraContainer from "components/Camera/CameraContainer";
import GroupPhotosContainer from "components/PhotoImporter/GroupPhotosContainer";
import PhotoGallery from "components/PhotoImporter/PhotoGallery";
import { Mortal, PermissionGate } from "components/SharedComponents";
import PermissionGateContainer, {
AUDIO_PERMISSIONS,
CAMERA_PERMISSIONS,
READ_MEDIA_PERMISSIONS
} from "components/SharedComponents/PermissionGateContainer";
import SoundRecorder from "components/SoundRecorder/SoundRecorder";
import { t } from "i18next";
import {
@@ -16,108 +20,54 @@ import {
} from "navigation/navigationOptions";
import type { Node } from "react";
import React from "react";
import { PermissionsAndroid, Platform } from "react-native";
import { PERMISSIONS } from "react-native-permissions";
import SharedStackScreens from "./SharedStackScreens";
const usesAndroid10Permissions = Platform.OS === "android" && Platform.Version <= 29;
const usesAndroid13Permissions = Platform.OS === "android" && Platform.Version >= 33;
const androidReadPermissions = usesAndroid13Permissions
? PermissionsAndroid.PERMISSIONS.READ_MEDIA_IMAGES
: PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE;
const Stack = createNativeStackNavigator( );
const CameraContainerWithPermission = ( ) => {
if ( usesAndroid10Permissions ) {
// WRITE_EXTERNAL_STORAGE is deprecated after Android 10
// https://developer.android.com/training/data-storage/shared/media#access-other-apps-files
return (
<Mortal>
<PermissionGate
permission={PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE}
>
<PermissionGate permission={PermissionsAndroid.PERMISSIONS.CAMERA}>
<CameraContainer />
</PermissionGate>
</PermissionGate>
</Mortal>
);
}
return (
<Mortal>
<PermissionGate permission={PermissionsAndroid.PERMISSIONS.CAMERA}>
<CameraContainer />
</PermissionGate>
</Mortal>
);
};
const CameraContainerWithPermission = ( ) => (
<PermissionGateContainer
permissions={CAMERA_PERMISSIONS}
title={t( "Observe-and-identify-organisms-in-real-time-with-your-camera" )}
titleDenied={t( "Please allow Camera Access" )}
body={t( "Use-the-iNaturalist-camera-to-observe" )}
blockedPrompt={t( "Youve-previously-denied-camera-permissions" )}
buttonText={t( "OBSERVE-ORGANISMS" )}
icon="camera"
>
<CameraContainer />
</PermissionGateContainer>
);
const SoundRecorderWithPermission = ( ) => {
if ( usesAndroid10Permissions ) {
return (
<PermissionGate permission={PermissionsAndroid.PERMISSIONS.RECORD_AUDIO}>
<PermissionGate
permission={PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE}
>
<PermissionGate
permission={androidReadPermissions}
>
<SoundRecorder />
</PermissionGate>
</PermissionGate>
</PermissionGate>
);
}
return (
<PermissionGate permission={PermissionsAndroid.PERMISSIONS.RECORD_AUDIO}>
<PermissionGate
permission={androidReadPermissions}
>
<SoundRecorder />
</PermissionGate>
</PermissionGate>
);
};
const SoundRecorderWithPermission = ( ) => (
<PermissionGateContainer
permissions={AUDIO_PERMISSIONS}
title={t( "Record-organism-sounds-with-the-microphone" )}
titleDenied={t( "Please-allow-Microphone-Access" )}
body={t( "Use-your-devices-microphone-to-record" )}
blockedPrompt={t( "Youve-previously-denied-microphone-permissions" )}
buttonText={t( "RECORD-SOUND" )}
icon="microphone"
image={require( "images/viviana-rishe-j2330n6bg3I-unsplash.jpg" )}
>
<SoundRecorder />
</PermissionGateContainer>
);
const PhotoGalleryWithPermission = ( ) => {
if ( usesAndroid10Permissions ) {
return (
<PermissionGate
permission={androidReadPermissions}
>
<PermissionGate
permission={PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE}
>
<PermissionGate
permission={PermissionsAndroid.PERMISSIONS.ACCESS_MEDIA_LOCATION}
>
<PhotoGallery />
</PermissionGate>
</PermissionGate>
</PermissionGate>
);
}
return (
<PermissionGate permission={androidReadPermissions}>
<PermissionGate
permission={PermissionsAndroid.PERMISSIONS.ACCESS_MEDIA_LOCATION}
>
<PermissionGate permission={PERMISSIONS.IOS.PHOTO_LIBRARY} isIOS>
<PermissionGate
permission={PERMISSIONS.IOS.LOCATION_WHEN_IN_USE}
isIOS
>
<PhotoGallery />
</PermissionGate>
</PermissionGate>
</PermissionGate>
</PermissionGate>
);
};
const PhotoGalleryWithPermission = ( ) => (
<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/azmaan-baluch-_ra6NcejHVs-unsplash.jpg" )}
>
<PhotoGallery />
</PermissionGateContainer>
);
const AddObsStackNavigator = ( ): Node => (
<Stack.Navigator

View File

@@ -5,7 +5,7 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack";
import LocationPickerContainer from "components/LocationPicker/LocationPickerContainer";
import MediaViewer from "components/MediaViewer/MediaViewer";
import ObsEdit from "components/ObsEdit/ObsEdit";
import { Heading4, Mortal, PermissionGate } from "components/SharedComponents";
import { Heading4 } from "components/SharedComponents";
import SuggestionsContainer from "components/Suggestions/SuggestionsContainer";
import TaxonSearch from "components/Suggestions/TaxonSearch";
import TaxonDetails from "components/TaxonDetails/TaxonDetails";
@@ -17,23 +17,12 @@ import {
} from "navigation/navigationOptions";
import type { Node } from "react";
import React from "react";
import { PermissionsAndroid } from "react-native";
const suggestionsTitle = ( ) => <Heading4>{t( "ADD-AN-ID" )}</Heading4>;
const taxonSearchTitle = ( ) => <Heading4>{t( "SEARCH" )}</Heading4>;
const Stack = createNativeStackNavigator( );
const ObsEditWithPermission = ( ) => (
<Mortal>
<PermissionGate
permission={PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION}
>
<ObsEdit />
</PermissionGate>
</Mortal>
);
const SharedStackScreens = ( ): Node => (
<Stack.Group
screenOptions={{
@@ -45,7 +34,7 @@ const SharedStackScreens = ( ): Node => (
>
<Stack.Screen
name="ObsEdit"
component={ObsEditWithPermission}
component={ObsEdit}
options={{
...removeBottomBorder,
...blankHeaderTitle,

View File

@@ -405,6 +405,7 @@ const ObsEditProvider = ( { children }: Props ): Node => {
"createObsWithCameraPhotos, calling savePhotosToCameraGallery with paths: ",
localFilePaths
);
// TODO catch the error that gets raised here if the user denies gallery permission
await savePhotosToCameraGallery( localFilePaths );
};

View File

@@ -1,32 +1,15 @@
// @flow
import Geolocation from "@react-native-community/geolocation";
import { PermissionsAndroid, Platform } from "react-native";
import { PERMISSIONS, request } from "react-native-permissions";
import {
LOCATION_PERMISSIONS,
permissionResultFromMultiple
} from "components/SharedComponents/PermissionGateContainer";
import { Platform } from "react-native";
import { checkMultiple, RESULTS } from "react-native-permissions";
import fetchPlaceName from "./fetchPlaceName";
const requestLocationPermissions = async ( ): Promise<?string> => {
// TODO: test this on a real device
if ( Platform.OS === "ios" ) {
try {
const permission = await request( PERMISSIONS.IOS.LOCATION_WHEN_IN_USE );
return permission;
} catch ( e ) {
console.warn( e, ": error requesting iOS permissions" );
}
}
if ( Platform.OS === "android" ) {
try {
const permission = await request( PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION );
return permission;
} catch ( e ) {
console.warn( e, ": error requesting android permissions" );
}
}
return null;
};
const options = {
enableHighAccuracy: true,
maximumAge: 0
@@ -46,10 +29,13 @@ type UserLocation = {
}
const fetchUserLocation = async ( ): Promise<?UserLocation> => {
const permissions = await requestLocationPermissions( );
// const permissions = await checkLocationPermissions( );
const permissionResult = permissionResultFromMultiple(
await checkMultiple( LOCATION_PERMISSIONS )
);
// TODO: handle case where iOS permissions are not granted
if ( Platform.OS !== "android" && permissions !== "granted" ) {
if ( Platform.OS !== "android" && permissionResult !== RESULTS.GRANTED ) {
return null;
}

View File

@@ -1,21 +1,27 @@
// @flow
import {
LOCATION_PERMISSIONS,
permissionResultFromMultiple
} from "components/SharedComponents/PermissionGateContainer";
import { ObsEditContext } from "providers/contexts";
import {
useContext,
useEffect, useState
useEffect,
useState
} from "react";
import { checkMultiple, RESULTS } from "react-native-permissions";
import fetchUserLocation from "sharedHelpers/fetchUserLocation";
const INITIAL_POSITIONAL_ACCURACY = 99999;
const TARGET_POSITIONAL_ACCURACY = 10;
const LOCATION_FETCH_INTERVAL = 1000;
export const LOCATION_FETCH_INTERVAL = 1000;
// Primarily fetches the current location for a new observation and returns
// isFetchingLocation to tell the consumer whether this process is happening.
// If currentObservation is not new, it will not fetch location and return
// information about the current observation's location
const useCurrentObservationLocation = ( mountedRef: any ): Object => {
const useCurrentObservationLocation = ( mountedRef: any, options: Object = { } ): Object => {
const {
currentObservation,
updateObservationKeys
@@ -38,6 +44,7 @@ const useCurrentObservationLocation = ( mountedRef: any ): Object => {
const [fetchingLocation, setFetchingLocation] = useState( false );
const [positionalAccuracy, setPositionalAccuracy] = useState( INITIAL_POSITIONAL_ACCURACY );
const [lastLocationFetchTime, setLastLocationFetchTime] = useState( 0 );
const [permissionResult, setPermissionResult] = useState( null );
useEffect( ( ) => {
if ( !currentObservation ) return;
@@ -49,6 +56,14 @@ const useCurrentObservationLocation = ( mountedRef: any ): Object => {
if ( !mountedRef.current ) return;
if ( !shouldFetchLocation ) return;
setPermissionResult( permissionResultFromMultiple(
await checkMultiple( LOCATION_PERMISSIONS )
) );
if ( permissionResult !== RESULTS.GRANTED ) {
setFetchingLocation( false );
setShouldFetchLocation( false );
return;
}
const location = await fetchUserLocation( );
// If we're still receiving location updates and location is blank,
@@ -94,15 +109,25 @@ const useCurrentObservationLocation = ( mountedRef: any ): Object => {
currentObservation,
fetchingLocation,
lastLocationFetchTime,
mountedRef,
numLocationFetches,
permissionResult,
positionalAccuracy,
setFetchingLocation,
setLastLocationFetchTime,
setNumLocationFetches,
setShouldFetchLocation,
shouldFetchLocation,
updateObservationKeys,
mountedRef
updateObservationKeys
] );
useEffect( ( ) => {
if ( options.retry && !shouldFetchLocation ) {
setTimeout( ( ) => {
setShouldFetchLocation( true );
}, LOCATION_FETCH_INTERVAL + 1 );
}
}, [
options.retry,
setShouldFetchLocation,
shouldFetchLocation
] );
return {
@@ -113,7 +138,9 @@ const useCurrentObservationLocation = ( mountedRef: any ): Object => {
// Internally we're tracking isFetching when one of potentially many
// location requests is in flight, but this tells the external consumer
// whether the overall location fetching process is happening
isFetchingLocation: shouldFetchLocation
isFetchingLocation: shouldFetchLocation,
permissionResult,
numLocationFetches
};
};

View File

@@ -1,40 +1,54 @@
// @flow
import Geolocation from "@react-native-community/geolocation";
import {
LOCATION_PERMISSIONS,
permissionResultFromMultiple
} from "components/SharedComponents/PermissionGateContainer";
import { useEffect, useState } from "react";
import { Platform } from "react-native";
import { PERMISSIONS, request } from "react-native-permissions";
import {
checkMultiple, RESULTS
} from "react-native-permissions";
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
// Max time to wait while fetching current location
const CURRENT_LOCATION_TIMEOUT_MS = 30000;
const useUserLocation = ( { skipPlaceGuess = false }: Object ): Object => {
const useUserLocation = ( {
skipPlaceGuess = false,
permissionsGranted: permissionsGrantedProp = false
}: Object ): Object => {
const [latLng, setLatLng] = useState( null );
const [isLoading, setIsLoading] = useState( true );
const [permissionsGranted, setPermissionsGranted] = useState( permissionsGrantedProp );
// TODO: wrap this in PermissionsGate so permissions aren't requested at odd times
const requestiOSPermissions = async ( ): Promise<?string> => {
// TODO: test this on a real device
try {
const permission = await request( PERMISSIONS.IOS.LOCATION_WHEN_IN_USE );
return permission;
} catch ( e ) {
console.warn( e, ": error requesting iOS permissions" );
useEffect( ( ) => {
if ( permissionsGrantedProp === true && permissionsGranted === false ) {
setPermissionsGranted( true );
}
return null;
};
}, [permissionsGranted, permissionsGrantedProp] );
useEffect( ( ) => {
async function checkPermissions() {
const permissionsResult = permissionResultFromMultiple(
await checkMultiple( LOCATION_PERMISSIONS )
);
if ( permissionsResult === RESULTS.GRANTED ) {
setPermissionsGranted( true );
} else {
console.warn(
"Location permissions have not been granted. You probably need to use a PermissionGate"
);
}
}
checkPermissions( );
}, [] );
useEffect( ( ) => {
let isCurrent = true;
const fetchLocation = async ( ) => {
setIsLoading( true );
if ( Platform.OS === "ios" ) {
const permissions = await requestiOSPermissions( );
// TODO: handle case where iOS permissions are not granted
if ( permissions !== "granted" ) { return; }
}
const success = async ( { coords } ) => {
if ( !isCurrent ) { return; }
@@ -53,7 +67,7 @@ const useUserLocation = ( { skipPlaceGuess = false }: Object ): Object => {
// TODO: set geolocation fetch error
const failure = error => {
console.warn( error.code, error.message );
console.warn( `useUserLocation: ${error.message} (${error.code})` );
setIsLoading( false );
};
@@ -65,12 +79,17 @@ const useUserLocation = ( { skipPlaceGuess = false }: Object ): Object => {
Geolocation.getCurrentPosition( success, failure, options );
};
fetchLocation( );
if ( permissionsGranted ) {
fetchLocation( );
} else {
setIsLoading( false );
}
return ( ) => {
isCurrent = false;
};
}, [skipPlaceGuess] );
}, [permissionsGranted, skipPlaceGuess] );
return {
latLng,

View File

@@ -6,6 +6,7 @@ import { ObsEditContext } from "providers/contexts";
import INatPaperProvider from "providers/INatPaperProvider";
import ObsEditProvider from "providers/ObsEditProvider";
import React from "react";
import { LOCATION_FETCH_INTERVAL } from "sharedHooks/useCurrentObservationLocation";
import factory from "../factory";
import { renderComponent } from "../helpers/render";
@@ -20,11 +21,28 @@ jest.mock( "@react-navigation/native", () => {
...actualNav,
useRoute: () => ( {} ),
useNavigation: () => ( {
addListener: jest.fn(),
setOptions: jest.fn()
} )
};
} );
// import { checkMultiple, RESULTS } from "react-native-permissions";
// jest.mock( "react-native-permissions", ( ) => {
// const actual = jest.requireActual( "react-native-permissions" );
// return {
// ...actual,
// checkMultiple: permissions => permissions.reduce(
// ( memo, permission ) => {
// memo[permission] = actual.RESULTS.GRANTED;
// return memo;
// },
// {}
// )
// };
// } );
// jest.mock('react-native-permissions', () => require('react-native-permissions/mock'));
const mockCurrentUser = factory( "LocalUser" );
const mockFetchUserLocation = jest.fn( () => ( { latitude: 37, longitude: 34 } ) );
@@ -117,7 +135,7 @@ describe( "location fetching", () => {
} );
test( "should fetch location when new observation hasn't saved", async ( ) => {
const observations = [factory( "RemoteObservation", {
const observations = [factory( "LocalObservation", {
observationPhotos: []
} )];
mockObsEditProviderWithObs( observations );
@@ -127,7 +145,7 @@ describe( "location fetching", () => {
await waitFor( () => {
expect( mockFetchUserLocation ).toHaveBeenCalled();
} );
}, { timeout: LOCATION_FETCH_INTERVAL * 2 } );
// Note: it would be nice to look for an update in the UI, but since we've
// mocked ObsEditProvider here, it will never update. Might be good for
// an integration test
@@ -149,6 +167,11 @@ describe( "location fetching", () => {
expect(
screen.getByText( new RegExp( `Lat: ${observation.latitude}` ) )
).toBeTruthy();
// Location may not fetch immediately, so wait for twice the default fetch
// interval before testing whether the mock was called
await waitFor( () => {}, { timeout: LOCATION_FETCH_INTERVAL * 2 } );
expect( mockFetchUserLocation ).not.toHaveBeenCalled();
} );
@@ -169,6 +192,9 @@ describe( "location fetching", () => {
expect(
screen.getByText( new RegExp( `Lat: ${observation.latitude}` ) )
).toBeTruthy();
await waitFor( () => {}, { timeout: LOCATION_FETCH_INTERVAL * 2 } );
expect( mockFetchUserLocation ).not.toHaveBeenCalled();
} );
} );

View File

@@ -70,6 +70,7 @@ jest.mock( "@react-navigation/native", ( ) => {
useIsFocused: jest.fn( ( ) => true ),
useRoute: jest.fn( ( ) => ( { } ) ),
useNavigation: ( ) => ( {
addListener: jest.fn(),
setOptions: jest.fn( )
} )
};

View File

@@ -18,6 +18,7 @@ jest.mock( "@react-navigation/native", ( ) => {
useRoute: ( ) => ( {
} ),
useNavigation: ( ) => ( {
addListener: jest.fn(),
setOptions: jest.fn( )
} )
};

View File

@@ -22,12 +22,12 @@ describe( "Map", ( ) => {
} );
it( "displays filtered observations on map", async ( ) => {
renderComponent( <Map
showExplore
tileMapParams={{
taxon_id: 47178
}}
/> );
renderComponent(
<Map
withObsTiles
tileMapParams={{ taxon_id: 47178 }}
/>
);
const tiles = await screen.findByTestId( "Map.UrlTile" );
expect( tiles ).toHaveProp( "urlTemplate", "https://api.inaturalist.org/v2/grid/{z}/{x}/{y}.png?taxon_id=47178&color=%2374ac00&verifiable=true" );
} );

View File

@@ -0,0 +1,65 @@
import { render, screen } from "@testing-library/react-native";
import PermissionGate from "components/SharedComponents/PermissionGate";
import initI18next from "i18n/initI18next";
import React from "react";
import { RESULTS } from "react-native-permissions";
describe( "PermissionGate", ( ) => {
beforeAll( async ( ) => {
await initI18next( );
} );
it( "should show the GRANT PERMISSION button when permission unknown", ( ) => {
render(
<PermissionGate
requestPermission={jest.fn( )}
grantStatus={null}
onClose={jest.fn( )}
/>
);
expect( screen.getByText( "GRANT PERMISSION" ) ).toBeTruthy( );
} );
it( "should show the GRANT PERMISSION button when permission blocked", ( ) => {
render(
<PermissionGate
requestPermission={jest.fn( )}
grantStatus={RESULTS.DENIED}
onClose={jest.fn( )}
/>
);
expect( screen.getByText( "GRANT PERMISSION" ) ).toBeTruthy( );
} );
it( "should show the OPEN SETTINGS button when permission blocked", ( ) => {
render(
<PermissionGate
requestPermission={jest.fn( )}
grantStatus={RESULTS.BLOCKED}
onClose={jest.fn( )}
/>
);
expect( screen.getByText( "OPEN SETTINGS" ) ).toBeTruthy( );
} );
it( "should show the blockedPrompt when permission blocked", ( ) => {
render(
<PermissionGate
requestPermission={jest.fn( )}
grantStatus={RESULTS.BLOCKED}
onClose={jest.fn( )}
/>
);
expect( screen.getByText( /Youve denied permission/ ) ).toBeTruthy( );
} );
it( "should be accessible", ( ) => {
expect(
<PermissionGate
requestPermission={jest.fn( )}
grantStatus={null}
onClose={jest.fn( )}
/>
).toBeAccessible( );
} );
} );