mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2025-12-23 22:18:36 -05:00
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:
45
Gemfile.lock
45
Gemfile.lock
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
24
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
12
package.json
12
package.json
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -44,9 +44,9 @@ const ObservationsView = ( {
|
||||
className="h-full"
|
||||
showsCompass={false}
|
||||
region={region}
|
||||
hideMap={region.latitude === 0.0}
|
||||
observations={observations}
|
||||
tileMapParams={tileMapParams}
|
||||
showCurrentLocationButton
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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( )}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 )}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
}
|
||||
|
||||
const BackButton = ( {
|
||||
color,
|
||||
color = colors.black,
|
||||
onPress
|
||||
}: Props ): Node => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
46
src/components/SharedComponents/LocationPermissionGate.js
Normal file
46
src/components/SharedComponents/LocationPermissionGate.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
213
src/components/SharedComponents/PermissionGateContainer.js
Normal file
213
src/components/SharedComponents/PermissionGateContainer.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = You’ve 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 device’s 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 = You’ve 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 you’ve already observed!
|
||||
Youve-previously-denied-gallery-permissions = You’ve 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. You’re always in control of the location privacy of every observation you create.
|
||||
Youve-previously-denied-location-permissions = You’ve 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 haven’t joined any projects yet!
|
||||
|
||||
You-can-click-join-on-the-project-page = You can click “join” on the project page.
|
||||
|
||||
|
||||
@@ -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": "You’ve 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 device’s 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": "You’ve 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 you’ve already observed!",
|
||||
"Youve-previously-denied-gallery-permissions": "You’ve 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. You’re always in control of the location privacy of every observation you create.",
|
||||
"Youve-previously-denied-location-permissions": "You’ve 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 haven’t 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",
|
||||
|
||||
@@ -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, we’ve 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 observation’s 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 iNaturalist’s 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 what’s 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 observation’s 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 wasn’t 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 you’re 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 iNaturalist’s AR Camera",
|
||||
"Loading-iNaturalists-AR-Camera": "Loading iNaturalist's AR Camera",
|
||||
"No-results-found": {
|
||||
"comment": "Used for explore screen when search params lead to a search with no data",
|
||||
"val": "No results found"
|
||||
@@ -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 haven’t 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."
|
||||
}
|
||||
|
||||
@@ -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, we’ve 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 observation’s 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 iNaturalist’s 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 what’s 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 observation’s 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 wasn’t 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 you’re 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 iNaturalist’s 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 haven’t 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.
|
||||
@@ -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 = You’ve 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 device’s 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 = You’ve 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 you’ve already observed!
|
||||
Youve-previously-denied-gallery-permissions = You’ve 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. You’re always in control of the location privacy of every observation you create.
|
||||
Youve-previously-denied-location-permissions = You’ve 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 haven’t joined any projects yet!
|
||||
|
||||
You-can-click-join-on-the-project-page = You can click “join” on the project page.
|
||||
|
||||
|
||||
BIN
src/images/azmaan-baluch-_ra6NcejHVs-unsplash.jpg
Normal file
BIN
src/images/azmaan-baluch-_ra6NcejHVs-unsplash.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 885 KiB |
BIN
src/images/bart-zimny-W5XTTLpk1-I-unsplash.jpg
Normal file
BIN
src/images/bart-zimny-W5XTTLpk1-I-unsplash.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 990 KiB |
BIN
src/images/landon-parenteau-EEuDMqRYbx0-unsplash.jpg
Normal file
BIN
src/images/landon-parenteau-EEuDMqRYbx0-unsplash.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
BIN
src/images/viviana-rishe-j2330n6bg3I-unsplash.jpg
Normal file
BIN
src/images/viviana-rishe-j2330n6bg3I-unsplash.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 );
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -70,6 +70,7 @@ jest.mock( "@react-navigation/native", ( ) => {
|
||||
useIsFocused: jest.fn( ( ) => true ),
|
||||
useRoute: jest.fn( ( ) => ( { } ) ),
|
||||
useNavigation: ( ) => ( {
|
||||
addListener: jest.fn(),
|
||||
setOptions: jest.fn( )
|
||||
} )
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ jest.mock( "@react-navigation/native", ( ) => {
|
||||
useRoute: ( ) => ( {
|
||||
} ),
|
||||
useNavigation: ( ) => ( {
|
||||
addListener: jest.fn(),
|
||||
setOptions: jest.fn( )
|
||||
} )
|
||||
};
|
||||
|
||||
@@ -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" );
|
||||
} );
|
||||
|
||||
@@ -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( /You’ve denied permission/ ) ).toBeTruthy( );
|
||||
} );
|
||||
|
||||
it( "should be accessible", ( ) => {
|
||||
expect(
|
||||
<PermissionGate
|
||||
requestPermission={jest.fn( )}
|
||||
grantStatus={null}
|
||||
onClose={jest.fn( )}
|
||||
/>
|
||||
).toBeAccessible( );
|
||||
} );
|
||||
} );
|
||||
Reference in New Issue
Block a user