Suggestions (using server data) (#821)

* UI overhaul for Suggestions

* Updates to suggestions

* Update permissions library and Podfile

* Get tests passing

* Add tests for suggestions

* Update snapshot

* Add comment prompt & box to TaxonSearch

* Add empty state

* Nav and loading fixes

* Add more tests to Suggestions flow

* Fix tests

* Fix tests
This commit is contained in:
Amanda Bullington
2023-10-13 14:46:05 -07:00
committed by GitHub
parent f29b2b566c
commit 7d20f6aa81
57 changed files with 1590 additions and 865 deletions

View File

Binary file not shown.

View File

@@ -3,7 +3,7 @@
"data": [
{
"path": "assets/fonts/INatIcon.ttf",
"sha1": "62da083366bb9e2e214e271c04fc5823a467b396"
"sha1": "d3d24b237a7ef666ef81aff93cde57e534a676a3"
},
{
"path": "assets/fonts/Whitney-BookItalic-Pro.otf",

View File

Binary file not shown.

View File

@@ -1,11 +1,48 @@
# frozen_string_literal: true
# setup instructions from https://www.npmjs.com/package/react-native-permissions
def node_require(script)
# Resolve script with node to allow for hoisting
require Pod::Executable.execute_command('node', ['-p',
"require.resolve(
'#{script}',
{paths: [process.argv[1]]},
)", __dir__]).strip
end
node_require('react-native/scripts/react_native_pods.rb')
node_require('react-native-permissions/scripts/setup.rb')
require_relative "../node_modules/react-native/scripts/react_native_pods"
require_relative "../node_modules/@react-native-community/cli-platform-ios/native_modules"
require_relative '../node_modules/react-native-permissions/scripts/setup'
platform :ios, min_ios_version_supported
prepare_react_native_project!
# ⬇️ uncomment wanted permissions
setup_permissions([
# 'AppTrackingTransparency',
# 'BluetoothPeripheral',
# 'Calendars',
'Camera',
# 'Contacts',
# 'FaceID',
'LocationAccuracy',
'LocationAlways',
'LocationWhenInUse',
'MediaLibrary',
'Microphone',
# 'Motion',
# 'Notifications',
'PhotoLibrary',
'PhotoLibraryAddOnly',
# 'Reminders',
# 'Siri',
# 'SpeechRecognition',
# 'StoreKit',
])
# If you are using a `react-native-flipper` your iOS build will fail when `NO_FLIPPER=1` is set.
# because `react-native-flipper` depends on (FlipperKit,...) that will be excluded
#

View File

@@ -418,7 +418,7 @@ PODS:
- React-Core
- RNLocalize (2.2.6):
- React-Core
- RNPermissions (3.8.0):
- RNPermissions (3.10.0):
- React-Core
- RNReanimated (2.17.0):
- DoubleConversion
@@ -447,7 +447,7 @@ PODS:
- React-RCTText
- ReactCommon/turbomodule/core
- Yoga
- RNScreens (3.20.0):
- RNScreens (3.21.1):
- React-Core
- React-RCTImage
- RNShareMenu (6.0.0):
@@ -764,9 +764,9 @@ SPEC CHECKSUMS:
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: 6e4dc6b7ab3a385386d4e36228bd065e5a611394
RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81
RNPermissions: 46f97a9fb7a4ce9a3fbf3a9dde0e6f83eb7bd170
RNPermissions: 0332875c444efe864dd97071dc848529bd7cc692
RNReanimated: f186e85d9f28c9383d05ca39e11dd194f59093ec
RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
RNScreens: d3675ab2878704de70c9dae57fa5d024802404cc
RNShareMenu: cb9dac548c8bf147d06f0bf07296ad51ea9f5fc3
RNSVG: 53c661b76829783cdaf9b7a57258f3d3b4c28315
RNVectorIcons: fcc2f6cb32f5735b586e66d14103a74ce6ad61f8
@@ -774,6 +774,6 @@ SPEC CHECKSUMS:
VisionCameraPluginInatVision: 79bb258db75218889c74d0897ecba676492c4def
Yoga: 39310a10944fc864a7550700de349183450f8aaa
PODFILE CHECKSUM: 12011c0d1d148a45ad35fd9c47bc095139411027
PODFILE CHECKSUM: 77ed9526d4011b245ce5afa1ea331dea4c67d753
COCOAPODS: 1.13.0

View File

@@ -22,9 +22,9 @@
8B65ED3129F575C10054CCEF /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8B65ED2F29F575C10054CCEF /* MainInterface.storyboard */; };
8B65ED3529F575C10054CCEF /* iNaturalistReactNative-ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8B65ED2B29F575C10054CCEF /* iNaturalistReactNative-ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
8B65ED3B29F575FE0054CCEF /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B65ED3A29F575FE0054CCEF /* ShareViewController.swift */; };
8C27575C16AD4AB88AFBEAD7 /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 046B62AFE69547678922E815 /* INatIcon.ttf */; };
A019DB3A4661689827F5BB56 /* libPods-iNaturalistReactNative.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 486ED9661FEC89EDDBE3DA02 /* libPods-iNaturalistReactNative.a */; };
A252B2AEA64E47C9AC1D20E8 /* Whitney-Light-Pro.otf in Resources */ = {isa = PBXBuildFile; fileRef = BA9D41ECEBFA4C38B74009B3 /* Whitney-Light-Pro.otf */; };
B7727B0D0C414FFF99814FDF /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 68976D05824745ECA696C47C /* INatIcon.ttf */; };
BA2479FA3D7B40A7BEF7B3CD /* Whitney-Medium-Pro.otf in Resources */ = {isa = PBXBuildFile; fileRef = D09FA3A0162844FF80A5EF96 /* Whitney-Medium-Pro.otf */; };
/* End PBXBuildFile section */
@@ -63,7 +63,6 @@
00E356EE1AD99517003FC87E /* iNaturalistReactNativeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iNaturalistReactNativeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
00E356F21AD99517003FC87E /* iNaturalistReactNativeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = iNaturalistReactNativeTests.m; sourceTree = "<group>"; };
046B62AFE69547678922E815 /* INatIcon.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = INatIcon.ttf; path = ../assets/fonts/INatIcon.ttf; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* iNaturalistReactNative.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iNaturalistReactNative.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = iNaturalistReactNative/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = iNaturalistReactNative/AppDelegate.mm; sourceTree = "<group>"; };
@@ -77,6 +76,7 @@
374CB22E29943E63005885ED /* Whitney-BookItalic-Pro.otf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Whitney-BookItalic-Pro.otf"; path = "../assets/fonts/Whitney-BookItalic-Pro.otf"; sourceTree = "<group>"; };
486ED9661FEC89EDDBE3DA02 /* libPods-iNaturalistReactNative.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-iNaturalistReactNative.a"; sourceTree = BUILT_PRODUCTS_DIR; };
6836402D214AF8075A93F130 /* Pods-iNaturalistReactNative.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iNaturalistReactNative.release.xcconfig"; path = "Target Support Files/Pods-iNaturalistReactNative/Pods-iNaturalistReactNative.release.xcconfig"; sourceTree = "<group>"; };
68976D05824745ECA696C47C /* INatIcon.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = INatIcon.ttf; path = ../assets/fonts/INatIcon.ttf; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = iNaturalistReactNative/LaunchScreen.storyboard; sourceTree = "<group>"; };
8B65ED2B29F575C10054CCEF /* iNaturalistReactNative-ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "iNaturalistReactNative-ShareExtension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
8B65ED3029F575C10054CCEF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
@@ -232,7 +232,7 @@
D09FA3A0162844FF80A5EF96 /* Whitney-Medium-Pro.otf */,
374CB22E29943E63005885ED /* Whitney-BookItalic-Pro.otf */,
EE004FD2EC174086A7AB2908 /* inaturalisticons.ttf */,
046B62AFE69547678922E815 /* INatIcon.ttf */,
68976D05824745ECA696C47C /* INatIcon.ttf */,
);
name = Resources;
sourceTree = "<group>";
@@ -363,7 +363,7 @@
A252B2AEA64E47C9AC1D20E8 /* Whitney-Light-Pro.otf in Resources */,
BA2479FA3D7B40A7BEF7B3CD /* Whitney-Medium-Pro.otf in Resources */,
4FB3B444D46A4115B867B9CC /* inaturalisticons.ttf in Resources */,
8C27575C16AD4AB88AFBEAD7 /* INatIcon.ttf in Resources */,
B7727B0D0C414FFF99814FDF /* INatIcon.ttf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -3,7 +3,7 @@
"data": [
{
"path": "assets/fonts/INatIcon.ttf",
"sha1": "62da083366bb9e2e214e271c04fc5823a467b396"
"sha1": "d3d24b237a7ef666ef81aff93cde57e534a676a3"
},
{
"path": "assets/fonts/Whitney-BookItalic-Pro.otf",

161
package-lock.json generated
View File

@@ -82,13 +82,13 @@
"react-native-open-maps": "^0.4.3",
"react-native-orientation-locker": "github:wonday/react-native-orientation-locker",
"react-native-paper": "^5.10.5",
"react-native-permissions": "3.8.0",
"react-native-permissions": "^3.10.0",
"react-native-picker-select": "8.0.4",
"react-native-reanimated": "^2.17.0",
"react-native-reanimated-carousel": "^3.4.0",
"react-native-render-html": "^6.3.4",
"react-native-safe-area-context": "^4.7.2",
"react-native-screens": "3.20.0",
"react-native-screens": "^3.21.1",
"react-native-sensitive-info": "^6.0.0-alpha.9",
"react-native-share-menu": "^6.0.0",
"react-native-svg": "13.9.0",
@@ -23448,13 +23448,9 @@
}
},
"node_modules/react-native-permissions": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.8.0.tgz",
"integrity": "sha512-BfZ7ksgdpGchHZH8M/kxCGZbWeACANbnPmb3hNjVOMDQusc4PWlPpobX3eBqYMSKbpi7bMECeV9BVU4QuwAf9A==",
"dependencies": {
"picocolors": "^1.0.0",
"pkg-dir": "^5.0.0"
},
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.10.0.tgz",
"integrity": "sha512-6SB6JInfC0u54Wco8M1QsRoOGThnVjrhaks5IDWyYgwfi6JpXfTIi+F9fZZmRycyyPCgU8vGtht/5gF4VWEB/A==",
"peerDependencies": {
"react": ">=16.13.1",
"react-native": ">=0.63.3",
@@ -23466,82 +23462,6 @@
}
}
},
"node_modules/react-native-permissions/node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-native-permissions/node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-native-permissions/node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-native-permissions/node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-native-permissions/node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"engines": {
"node": ">=8"
}
},
"node_modules/react-native-permissions/node_modules/pkg-dir": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz",
"integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==",
"dependencies": {
"find-up": "^5.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-picker-select": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/react-native-picker-select/-/react-native-picker-select-8.0.4.tgz",
@@ -23626,9 +23546,9 @@
}
},
"node_modules/react-native-screens": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.20.0.tgz",
"integrity": "sha512-joWUKWAVHxymP3mL9gYApFHAsbd9L6ZcmpoZa6Sl3W/82bvvNVMqcfP7MeNqVCg73qZ8yL4fW+J/syusHleUgg==",
"version": "3.21.1",
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.21.1.tgz",
"integrity": "sha512-asyqTA/0Ij9Sa16zB9YCUmZku5bf8RVKS0FKKhS2jfmF98BFt5XV6ldvM+Ze9cwgssSyRz4jYOIDSK1Wr5ZaYQ==",
"dependencies": {
"react-freeze": "^1.0.0",
"warn-once": "^0.1.0"
@@ -44801,61 +44721,10 @@
}
},
"react-native-permissions": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.8.0.tgz",
"integrity": "sha512-BfZ7ksgdpGchHZH8M/kxCGZbWeACANbnPmb3hNjVOMDQusc4PWlPpobX3eBqYMSKbpi7bMECeV9BVU4QuwAf9A==",
"requires": {
"picocolors": "^1.0.0",
"pkg-dir": "^5.0.0"
},
"dependencies": {
"find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"requires": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
}
},
"locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"requires": {
"p-locate": "^5.0.0"
}
},
"p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"requires": {
"yocto-queue": "^0.1.0"
}
},
"p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"requires": {
"p-limit": "^3.0.2"
}
},
"path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
},
"pkg-dir": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz",
"integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==",
"requires": {
"find-up": "^5.0.0"
}
}
}
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.10.0.tgz",
"integrity": "sha512-6SB6JInfC0u54Wco8M1QsRoOGThnVjrhaks5IDWyYgwfi6JpXfTIi+F9fZZmRycyyPCgU8vGtht/5gF4VWEB/A==",
"requires": {}
},
"react-native-picker-select": {
"version": "8.0.4",
@@ -44918,9 +44787,9 @@
"requires": {}
},
"react-native-screens": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.20.0.tgz",
"integrity": "sha512-joWUKWAVHxymP3mL9gYApFHAsbd9L6ZcmpoZa6Sl3W/82bvvNVMqcfP7MeNqVCg73qZ8yL4fW+J/syusHleUgg==",
"version": "3.21.1",
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.21.1.tgz",
"integrity": "sha512-asyqTA/0Ij9Sa16zB9YCUmZku5bf8RVKS0FKKhS2jfmF98BFt5XV6ldvM+Ze9cwgssSyRz4jYOIDSK1Wr5ZaYQ==",
"requires": {
"react-freeze": "^1.0.0",
"warn-once": "^0.1.0"

View File

@@ -103,13 +103,13 @@
"react-native-open-maps": "^0.4.3",
"react-native-orientation-locker": "github:wonday/react-native-orientation-locker",
"react-native-paper": "^5.10.5",
"react-native-permissions": "3.8.0",
"react-native-permissions": "^3.10.0",
"react-native-picker-select": "8.0.4",
"react-native-reanimated": "^2.17.0",
"react-native-reanimated-carousel": "^3.4.0",
"react-native-render-html": "^6.3.4",
"react-native-safe-area-context": "^4.7.2",
"react-native-screens": "3.20.0",
"react-native-screens": "^3.21.1",
"react-native-sensitive-info": "^6.0.0-alpha.9",
"react-native-share-menu": "^6.0.0",
"react-native-svg": "13.9.0",

26
src/api/computerVision.js Normal file
View File

@@ -0,0 +1,26 @@
// @flow
import inatjs from "inaturalistjs";
import Taxon from "realmModels/Taxon";
import handleError from "./error";
const PARAMS = {
fields: {
taxon: Taxon.TAXON_FIELDS
}
};
const scoreImage = async (
params: Object = {},
opts: Object = {}
): Promise<any> => {
try {
const { results } = await inatjs.computervision.score_image( { ...PARAMS, ...params }, opts );
return results;
} catch ( e ) {
return handleError( e );
}
};
export default scoreImage;

View File

@@ -1,88 +0,0 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import {
Body3, INatIcon, INatIconButton,
TextInputSheet, ViewWrapper
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import {
ActivityIndicator
} from "react-native-paper";
import { useTranslation } from "sharedHooks";
import TaxonSearch from "./TaxonSearch";
type Props = {
setComment: Function,
comment: string,
taxonQuery: string,
setTaxonQuery: Function,
createId: Function,
loading: boolean
};
const AddID = ( {
setComment, comment, taxonQuery, setTaxonQuery, createId, loading
}: Props ): Node => {
const { t } = useTranslation( );
const [showAddCommentSheet, setShowAddCommentSheet] = useState( false );
const navigation = useNavigation();
useEffect( ( ) => {
const addCommentIcon = ( ) => (
<INatIconButton
icon="add-comment-outline"
onPress={( ) => setShowAddCommentSheet( true )}
accessibilityLabel={t( "Add-comment" )}
size={25}
/>
);
navigation.setOptions( {
headerRight: addCommentIcon
} );
}, [navigation, t] );
return (
<ViewWrapper>
{showAddCommentSheet && (
<TextInputSheet
handleClose={( ) => setShowAddCommentSheet( false )}
headerText={t( "ADD-OPTIONAL-COMMENT" )}
snapPoints={[416]}
confirm={textInput => setComment( textInput )}
/>
)}
<View>
{ comment && comment.length > 0 && (
<View className="bg-lightGray mx-6 p-5 rounded-lg flex-row items-center">
<INatIcon
name="comments-outline"
size={22}
/>
<Body3 className="ml-4 shrink">{ comment }</Body3>
</View>
) }
{loading && (
<View
className="absolute self-center z-10 pt-[30px]"
testID="AddID.ActivityIndicator"
>
<ActivityIndicator large />
</View>
)}
<TaxonSearch
taxonQuery={taxonQuery}
setTaxonQuery={setTaxonQuery}
onTaxonChosen={createId}
/>
</View>
</ViewWrapper>
);
};
export default AddID;

View File

@@ -1,136 +0,0 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import { createIdentification } from "api/identifications";
import { ObsEditContext, RealmContext } from "providers/contexts";
import type { Node } from "react";
import React, { useContext, useEffect, useState } from "react";
import { Alert } from "react-native";
import uuid from "react-native-uuid";
import {
useAuthenticatedMutation,
useCurrentUser,
useLocalObservation,
useTranslation
} from "sharedHooks";
import AddID from "./AddID";
const { useRealm } = RealmContext;
type Props = {
route: {
params: {
observationUUID?: string,
createRemoteIdentification?: boolean,
belongsToCurrentUser?: boolean
},
},
};
const AddIDContainer = ( { route }: Props ): Node => {
const [comment, setComment] = useState( "" );
const [loading, setLoading] = useState( false );
const [taxonQuery, setTaxonQuery] = useState( "" );
const {
updateObservationKeys
} = useContext( ObsEditContext );
const currentUser = useCurrentUser( );
const realm = useRealm( );
const observationUUID = route?.params?.observationUUID;
const createRemoteIdentification = route?.params?.createRemoteIdentification;
const belongsToCurrentUser = route?.params?.belongsToCurrentUser;
const localObservation = useLocalObservation( observationUUID );
const { t } = useTranslation( );
const navigation = useNavigation( );
const showErrorAlert = error => Alert.alert( "Error", error, [{ text: t( "OK" ) }], {
cancelable: true
} );
const createIdentificationMutation = useAuthenticatedMutation(
( idParams, optsWithAuth ) => createIdentification( idParams, optsWithAuth ),
{
onSuccess: data => {
if ( belongsToCurrentUser ) {
realm?.write( ( ) => {
const localIdentifications = localObservation?.identifications;
const newIdentification = data[0];
newIdentification.user = currentUser;
newIdentification.taxon = realm?.objectForPrimaryKey(
"Taxon",
newIdentification.taxon.id
) || newIdentification.taxon;
const realmIdentification = realm?.create( "Identification", newIdentification );
localIdentifications.push( realmIdentification );
} );
}
// navigate back to ObsDetails
navigation.goBack( );
},
onError: e => {
let error = null;
if ( e ) {
error = t( "Couldnt-create-identification-error", { error: e.message } );
} else {
error = t( "Couldnt-create-identification-unknown-error" );
}
showErrorAlert( error );
}
}
);
const formatIdentification = taxon => {
const newIdent = {
uuid: uuid.v4(),
body: comment,
taxon
};
return newIdent;
};
const createId = identification => {
setLoading( true );
const newIdentification = formatIdentification( identification );
if ( createRemoteIdentification ) {
createIdentificationMutation.mutate( {
identification: {
observation_id: observationUUID,
taxon_id: newIdentification.taxon.id,
body: newIdentification.body
}
} );
} else {
updateObservationKeys( {
taxon: newIdentification.taxon
} );
navigation.goBack( );
}
};
useEffect(
( ) => {
navigation.addListener( "blur", ( ) => {
setTaxonQuery( "" );
setComment( "" );
setLoading( false );
} );
},
[navigation]
);
return (
<AddID
setComment={setComment}
comment={comment}
taxonQuery={taxonQuery}
setTaxonQuery={setTaxonQuery}
createId={createId}
loading={loading}
/>
);
};
export default AddIDContainer;

View File

@@ -1,76 +0,0 @@
// @flow
import fetchSearchResults from "api/search";
import {
Body2,
SearchBar,
TaxonResult
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { FlatList } from "react-native";
import Taxon from "realmModels/Taxon";
import { useTranslation } from "sharedHooks";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
type Props = {
taxonQuery: string,
setTaxonQuery: Function,
onTaxonChosen: Function
};
const TaxonSearch = ( {
taxonQuery,
setTaxonQuery,
onTaxonChosen
}: Props ): Node => {
const { t } = useTranslation( );
const { data: taxonList } = useAuthenticatedQuery(
["fetchSearchResults", taxonQuery],
optsWithAuth => fetchSearchResults(
{
q: taxonQuery,
sources: "taxa",
fields: {
taxon: Taxon.TAXON_FIELDS
}
},
optsWithAuth
)
);
const renderEmptyComponent = ( ) => (
<Body2 className="self-center">
{t( "Search-for-a-taxon-to-add-an-identification" )}
</Body2>
);
return (
<>
<View className="mx-6">
<SearchBar
handleTextChange={setTaxonQuery}
value={taxonQuery}
testID="SearchTaxon"
containerClass="pb-5 mt-3"
/>
</View>
<FlatList
keyboardShouldPersistTaps="always"
data={taxonList}
renderItem={( { item } ) => (
<TaxonResult
taxon={item}
handleCheckmarkPress={( ) => onTaxonChosen( item )}
testID={`Search.taxa.${item.id}`}
/>
)}
keyExtractor={item => item.id}
ListEmptyComponent={renderEmptyComponent}
/>
</>
);
};
export default TaxonSearch;

View File

@@ -29,7 +29,7 @@ type Props = {
rotation?: {
value: number
},
setMediaViewerUris: Function,
setPhotoEvidenceUris: Function,
photoUris: Array<string>,
setSelectedPhotoIndex: Function
}
@@ -60,7 +60,7 @@ const PhotoCarousel = ( {
isLargeScreen,
isTablet,
rotation,
setMediaViewerUris,
setPhotoEvidenceUris,
photoUris,
setSelectedPhotoIndex
}: Props ): Node => {
@@ -136,7 +136,7 @@ const PhotoCarousel = ( {
deletePhoto( item );
} else {
setSelectedPhotoIndex( index );
setMediaViewerUris( [...photoUris] );
setPhotoEvidenceUris( [...photoUris] );
navigation.navigate( "MediaViewer" );
}
}}

View File

@@ -33,7 +33,7 @@ const PhotoPreview = ( {
const {
cameraPreviewUris: photoUris,
deletePhotoFromObservation,
setMediaViewerUris,
setPhotoEvidenceUris,
setSelectedPhotoIndex
} = useContext( ObsEditContext );
const wrapperDim = isLargeScreen
@@ -99,7 +99,7 @@ const PhotoPreview = ( {
deletePhoto={deletePhoto}
photoUris={photoUris}
rotation={rotation}
setMediaViewerUris={setMediaViewerUris}
setPhotoEvidenceUris={setPhotoEvidenceUris}
takingPhoto={takingPhoto}
isLargeScreen={isLargeScreen}
isTablet={isTablet}

View File

@@ -25,13 +25,13 @@ const MediaViewer = ( ): Node => {
const [warningSheet, setWarningSheet] = useState( false );
const {
deletePhotoFromObservation,
mediaViewerUris,
photoEvidenceUris,
selectedPhotoIndex,
setSelectedPhotoIndex
} = useContext( ObsEditContext );
const atFirstPhoto = selectedPhotoIndex === 0;
const atLastPhoto = selectedPhotoIndex === mediaViewerUris.length - 1;
const atLastPhoto = selectedPhotoIndex === photoEvidenceUris.length - 1;
const handleScrollLeft = index => {
if ( atFirstPhoto ) { return; }
@@ -48,15 +48,15 @@ const MediaViewer = ( ): Node => {
const { isLandscapeMode, screenWidth } = useDeviceOrientation( );
const isLargeScreen = screenWidth > BREAKPOINTS.md;
const numOfPhotos = mediaViewerUris.length;
const numOfPhotos = photoEvidenceUris.length;
const showWarningSheet = ( ) => setWarningSheet( true );
const hideWarningSheet = ( ) => setWarningSheet( false );
const deletePhoto = ( ) => {
deletePhotoFromObservation( mediaViewerUris[selectedPhotoIndex] );
deletePhotoFromObservation( photoEvidenceUris[selectedPhotoIndex] );
hideWarningSheet( );
if ( mediaViewerUris.length === 0 ) {
if ( photoEvidenceUris.length === 0 ) {
navigation.goBack( );
} else if ( selectedPhotoIndex !== 0 ) {
setSelectedPhotoIndex( selectedPhotoIndex - 1 );
@@ -115,13 +115,13 @@ const MediaViewer = ( ): Node => {
/>
)}
<MainPhotoDisplay
photoUris={mediaViewerUris}
photoUris={photoEvidenceUris}
selectedPhotoIndex={selectedPhotoIndex}
handleScrollEndDrag={handleScrollEndDrag}
horizontalScroll={horizontalScroll}
/>
<PhotoSelector
photoUris={mediaViewerUris}
photoUris={photoEvidenceUris}
scrollToIndex={scrollToIndex}
isLargeScreen={isLargeScreen}
isLandscapeMode={isLandscapeMode}

View File

@@ -12,13 +12,13 @@ import { getShadowStyle } from "styles/global";
import colors from "styles/tailwindColors";
type Props = {
navToAddID: Function,
navToSuggestions: Function,
showCommentBox: Function,
openCommentBox: Function
}
const FloatingButtons = ( {
navToAddID,
navToSuggestions,
openCommentBox,
showCommentBox
}: Props ): Node => {
@@ -47,7 +47,7 @@ const FloatingButtons = ( {
/>
<Button
text={t( "SUGGEST-ID" )}
onPress={navToAddID}
onPress={navToSuggestions}
className="w-1/2 mx-6"
testID="ObsDetail.cvSuggestionsButton"
accessibilityRole="link"

View File

@@ -20,7 +20,7 @@ import DetailsTab from "./DetailsTab/DetailsTab";
import Header from "./Header";
type Props = {
navToAddID: Function,
navToSuggestions: Function,
onCommentAdded: Function,
openCommentBox: Function,
tabs: Array<Object>,
@@ -40,7 +40,7 @@ type Props = {
}
const ObsDetails = ( {
navToAddID,
navToSuggestions,
onCommentAdded,
openCommentBox,
tabs,
@@ -97,7 +97,7 @@ const ObsDetails = ( {
</ScrollView>
{showActivityTab && (
<FloatingButtons
navToAddID={navToAddID}
navToSuggestions={navToSuggestions}
openCommentBox={openCommentBox}
showCommentBox={showCommentBox}
/>

View File

@@ -1,5 +1,5 @@
// @flow
import { useNavigation, useRoute } from "@react-navigation/native";
import { useFocusEffect, useNavigation, useRoute } from "@react-navigation/native";
import { useQueryClient } from "@tanstack/react-query";
import { createComment } from "api/comments";
import { createIdentification } from "api/identifications";
@@ -7,10 +7,11 @@ import {
fetchRemoteObservation,
markObservationUpdatesViewed
} from "api/observations";
import { RealmContext } from "providers/contexts";
import { ObsEditContext, RealmContext } from "providers/contexts";
import type { Node } from "react";
import React, {
useEffect, useReducer
useCallback,
useContext, useEffect, useReducer
} from "react";
import { Alert, LogBox } from "react-native";
import Observation from "realmModels/Observation";
@@ -98,6 +99,10 @@ const reducer = ( state, action ) => {
};
const ObsDetailsContainer = ( ): Node => {
const {
setObservations,
setLastScreen
} = useContext( ObsEditContext );
const currentUser = useCurrentUser( );
const { params } = useRoute();
const { uuid } = params;
@@ -138,6 +143,14 @@ const ObsDetailsContainer = ( ): Node => {
const belongsToCurrentUser = observation?.user?.login === currentUser?.login;
useFocusEffect(
// this ensures activity items load after a user taps suggest id
// and adds a remote id on the Suggestions screen
useCallback( ( ) => {
queryClient.invalidateQueries( "fetchRemoteObservation" );
}, [queryClient] )
);
useEffect( ( ) => {
if ( !observationShown ) {
dispatch( {
@@ -290,12 +303,10 @@ const ObsDetailsContainer = ( ): Node => {
}
}, [localObservation, markViewedMutation, uuid] );
const navToAddID = ( ) => {
navigation.navigate( "AddID", {
observationUUID: uuid,
createRemoteIdentification: true,
belongsToCurrentUser
} );
const navToSuggestions = ( ) => {
setObservations( [observation] );
setLastScreen( "ObsDetails" );
navigation.navigate( "Suggestions" );
};
const showActivityTab = currentTabId === ACTIVITY_TAB_ID;
@@ -332,7 +343,7 @@ const ObsDetailsContainer = ( ): Node => {
return (
<ObsDetails
navToAddID={navToAddID}
navToSuggestions={navToSuggestions}
onCommentAdded={onCommentAdded}
openCommentBox={openCommentBox}
tabs={tabs}

View File

@@ -1,10 +1,12 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import classnames from "classnames";
import { INatIcon } from "components/SharedComponents";
import { Image, Pressable, View } from "components/styledComponents";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React from "react";
import React, { useContext } from "react";
import { ActivityIndicator } from "react-native";
// eslint-disable-next-line import/no-extraneous-dependencies
import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist";
@@ -14,18 +16,19 @@ import colors from "styles/tailwindColors";
type Props = {
evidenceList: Array<string>,
handleAddEvidence?: Function,
handleDragAndDrop: Function,
showMediaViewer: Function,
savingPhoto: boolean
handleDragAndDrop: Function
}
const EvidenceList = ( {
evidenceList,
handleAddEvidence,
handleDragAndDrop,
showMediaViewer,
savingPhoto
handleDragAndDrop
}: Props ): Node => {
const {
savingPhoto,
setSelectedPhotoIndex
} = useContext( ObsEditContext );
const navigation = useNavigation( );
const imageClass = "h-16 w-16 justify-center mx-1.5 rounded-lg";
const renderPhoto = ( { item, getIndex, drag } ) => (
@@ -33,7 +36,10 @@ const EvidenceList = ( {
<Pressable
onLongPress={drag}
accessibilityRole="button"
onPress={( ) => showMediaViewer( getIndex( ) )}
onPress={( ) => {
setSelectedPhotoIndex( getIndex( ) );
navigation.navigate( "MediaViewer" );
}}
className={classnames( imageClass )}
testID={`EvidenceList.${item.photo?.url || item.photo?.localFilePath}`}
>

View File

@@ -20,25 +20,21 @@ import AddEvidenceSheet from "./Sheets/AddEvidenceSheet";
type Props = {
passesEvidenceTest: Function,
handleDragAndDrop: Function,
showMediaViewer: Function,
isFetchingLocation: boolean,
locationTextClassNames: any,
evidenceList: Array<string>,
setShowAddEvidenceSheet: Function,
showAddEvidenceSheet: boolean,
savingPhoto: boolean
showAddEvidenceSheet: boolean
}
const EvidenceSection = ( {
locationTextClassNames,
handleDragAndDrop,
showMediaViewer,
passesEvidenceTest,
isFetchingLocation,
evidenceList,
setShowAddEvidenceSheet,
showAddEvidenceSheet,
savingPhoto
showAddEvidenceSheet
}: Props ): Node => {
const { t } = useTranslation( );
const theme = useTheme( );
@@ -103,8 +99,6 @@ const EvidenceSection = ( {
evidenceList={evidenceList}
handleAddEvidence={( ) => setShowAddEvidenceSheet( true )}
handleDragAndDrop={handleDragAndDrop}
showMediaViewer={showMediaViewer}
savingPhoto={savingPhoto}
/>
<Pressable
accessibilityRole="button"

View File

@@ -1,6 +1,5 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import { DESIRED_LOCATION_ACCURACY } from "components/LocationPicker/LocationPicker";
import {
differenceInCalendarYears,
@@ -13,7 +12,6 @@ import React, {
useCallback, useContext, useEffect,
useRef, useState
} from "react";
import Photo from "realmModels/Photo";
import useCurrentObservationLocation from "sharedHooks/useCurrentObservationLocation";
import EvidenceSection from "./EvidenceSection";
@@ -22,15 +20,12 @@ const EvidenceSectionContainer = ( ): Node => {
const {
currentObservation,
setPassesEvidenceTest,
savingPhoto,
setMediaViewerUris,
setSelectedPhotoIndex,
updateObservationKeys
updateObservationKeys,
setPhotoEvidenceUris,
photoEvidenceUris
} = useContext( ObsEditContext );
const obsPhotos = currentObservation?.observationPhotos;
const mountedRef = useRef( true );
const navigation = useNavigation( );
const [deletePhotoMode, setDeletePhotoMode] = useState( false );
const [showAddEvidenceSheet, setShowAddEvidenceSheet] = useState( false );
@@ -50,6 +45,14 @@ const EvidenceSectionContainer = ( ): Node => {
};
}, [] );
useEffect( ( ) => {
if ( obsPhotos?.length > photoEvidenceUris?.length ) {
setPhotoEvidenceUris( obsPhotos.map(
obsPhoto => obsPhoto.photo?.url || obsPhoto.photo?.localFilePath
) );
}
}, [obsPhotos, photoEvidenceUris, setPhotoEvidenceUris] );
const {
hasLocation,
isFetchingLocation
@@ -96,16 +99,6 @@ const EvidenceSectionContainer = ( ): Node => {
return false;
}, [currentObservation] );
const showMediaViewer = index => {
setSelectedPhotoIndex( index - 1 );
setMediaViewerUris(
obsPhotos.map(
obsPhoto => Photo.displayLocalOrRemoteSquarePhoto( obsPhoto.photo )
)
);
navigation.navigate( "MediaViewer" );
};
const passesEvidenceTest = useCallback( ( ) => {
if ( isFetchingLocation ) {
return null;
@@ -116,12 +109,6 @@ const EvidenceSectionContainer = ( ): Node => {
return false;
}, [isFetchingLocation, hasValidLocation, hasValidDate, hasPhotoOrSound] );
useEffect( ( ) => {
if ( obsPhotos?.length === 0 && deletePhotoMode ) {
setDeletePhotoMode( false );
}
}, [obsPhotos, deletePhotoMode] );
useEffect( ( ) => {
// we're only showing the Missing Evidence Sheet if location/date are missing
// but not if there is a missing photo or sound
@@ -142,19 +129,19 @@ const EvidenceSectionContainer = ( ): Node => {
updateObservationKeys( {
observationPhotos: data
} );
const uris = data.map( obsPhoto => obsPhoto.photo?.url || obsPhoto.photo?.localFilePath );
setPhotoEvidenceUris( uris );
};
return (
<EvidenceSection
locationTextClassNames={locationTextClassNames}
handleDragAndDrop={handleDragAndDrop}
showMediaViewer={showMediaViewer}
passesEvidenceTest={passesEvidenceTest}
isFetchingLocation={isFetchingLocation}
evidenceList={obsPhotos || []}
setShowAddEvidenceSheet={setShowAddEvidenceSheet}
showAddEvidenceSheet={showAddEvidenceSheet}
savingPhoto={savingPhoto}
/>
);
};

View File

@@ -21,7 +21,8 @@ const IdentificationSection = ( ): Node => {
const {
currentObservation,
updateObservationKeys,
setPassesIdentificationTest
setPassesIdentificationTest,
setLastScreen
} = useContext( ObsEditContext );
const { t } = useTranslation( );
const theme = useTheme( );
@@ -49,7 +50,10 @@ const IdentificationSection = ( ): Node => {
} );
};
const navToAddID = ( ) => navigation.navigate( "AddID" );
const navToSuggestions = ( ) => {
setLastScreen( "ObsEdit" );
navigation.navigate( "Suggestions" );
};
useEffect( ( ) => {
if ( hasIdentification ) {
@@ -72,13 +76,13 @@ const IdentificationSection = ( ): Node => {
<View className="flex-row items-center justify-between mr-5 mt-5">
<DisplayTaxon
taxon={identification}
handlePress={navToAddID}
handlePress={navToSuggestions}
accessibilityLabel={t( "Navigates-to-add-identification" )}
/>
<INatIconButton
icon="edit"
size={20}
onPress={navToAddID}
onPress={navToSuggestions}
accessibilityLabel={t( "Edit" )}
accessibilityHint={t( "Navigates-to-add-identification" )}
/>
@@ -96,7 +100,7 @@ const IdentificationSection = ( ): Node => {
level={identification
? "primary"
: "focus"}
onPress={navToAddID}
onPress={navToSuggestions}
text={t( "ADD-AN-ID" )}
className={classnames( "rounded-full py-1 h-[36px]", {
"border border-darkGray border-[2px]": identification

View File

@@ -18,7 +18,7 @@ const ConfidenceInterval = ( { confidence = 0, activeColor = "bg-white" }: Props
)}
/>
);
const Dot = <View className="bg-lightGray h-[2px] w-[2px] rounded-full mx-[1px]" />;
const Dot = <View className="bg-mediumGray h-[2px] w-[2px] rounded-full mx-[1px]" />;
const dots = [];
@@ -29,7 +29,7 @@ const ConfidenceInterval = ( { confidence = 0, activeColor = "bg-white" }: Props
}
return (
<View className="flex-row justify-evenly items-center w-[36px]">
<View className="flex-row justify-between items-center w-[36px]">
{dots}
</View>
);

View File

@@ -33,76 +33,77 @@
"crop": 61728,
"currentlocation": 61729,
"door-exit": 61730,
"flag": 61731,
"flash-off": 61732,
"flash-on": 61733,
"flip": 61734,
"gallery": 61735,
"gear": 61736,
"globe-outline": 61737,
"grid-square": 61738,
"grid": 61739,
"hamburger-menu": 61740,
"heart": 61741,
"help-circle-outline": 61742,
"help-circle": 61743,
"help": 61744,
"iconic-actinopterygii": 61745,
"iconic-amphibia": 61746,
"iconic-animalia": 61747,
"iconic-arachnida": 61748,
"iconic-aves": 61749,
"iconic-chromista": 61750,
"iconic-fungi": 61751,
"iconic-insecta": 61752,
"iconic-mammalia": 61753,
"iconic-mollusca": 61754,
"iconic-plantae": 61755,
"iconic-protozoa": 61756,
"iconic-reptilia": 61757,
"iconic-unknown": 61758,
"id-agree": 61759,
"inaturalist": 61760,
"info-circle-outline": 61761,
"kebab-menu": 61762,
"label-outline": 61763,
"label": 61764,
"laptop": 61765,
"layers": 61766,
"leaf": 61767,
"list-square": 61768,
"location-crosshairs": 61769,
"magnifying-glass": 61770,
"map-layers": 61771,
"map-marker-outline": 61772,
"map": 61773,
"microphone-circle": 61774,
"microphone": 61775,
"noevidence": 61776,
"notifications-bell": 61777,
"pause-circle": 61778,
"pencil-outline": 61779,
"pencil": 61780,
"person": 61781,
"photos-outline": 61782,
"photos": 61783,
"play-circle": 61784,
"play": 61785,
"plus-bold": 61786,
"plus": 61787,
"pot-outline": 61788,
"rotate-exclamation": 61789,
"rotate-right": 61790,
"rotate": 61791,
"share": 61792,
"sliders": 61793,
"sound-bold-outline": 61794,
"sound-outline": 61795,
"sounds": 61796,
"sparkly-label": 61797,
"star-bold-outline": 61798,
"star": 61799,
"trash-outline": 61800,
"trash": 61801,
"triangle-exclamation": 61802
"edit-comment": 61731,
"flag": 61732,
"flash-off": 61733,
"flash-on": 61734,
"flip": 61735,
"gallery": 61736,
"gear": 61737,
"globe-outline": 61738,
"grid-square": 61739,
"grid": 61740,
"hamburger-menu": 61741,
"heart": 61742,
"help-circle-outline": 61743,
"help-circle": 61744,
"help": 61745,
"iconic-actinopterygii": 61746,
"iconic-amphibia": 61747,
"iconic-animalia": 61748,
"iconic-arachnida": 61749,
"iconic-aves": 61750,
"iconic-chromista": 61751,
"iconic-fungi": 61752,
"iconic-insecta": 61753,
"iconic-mammalia": 61754,
"iconic-mollusca": 61755,
"iconic-plantae": 61756,
"iconic-protozoa": 61757,
"iconic-reptilia": 61758,
"iconic-unknown": 61759,
"id-agree": 61760,
"inaturalist": 61761,
"info-circle-outline": 61762,
"kebab-menu": 61763,
"label-outline": 61764,
"label": 61765,
"laptop": 61766,
"layers": 61767,
"leaf": 61768,
"list-square": 61769,
"location-crosshairs": 61770,
"magnifying-glass": 61771,
"map-layers": 61772,
"map-marker-outline": 61773,
"map": 61774,
"microphone-circle": 61775,
"microphone": 61776,
"noevidence": 61777,
"notifications-bell": 61778,
"pause-circle": 61779,
"pencil-outline": 61780,
"pencil": 61781,
"person": 61782,
"photos-outline": 61783,
"photos": 61784,
"play-circle": 61785,
"play": 61786,
"plus-bold": 61787,
"plus": 61788,
"pot-outline": 61789,
"rotate-exclamation": 61790,
"rotate-right": 61791,
"rotate": 61792,
"share": 61793,
"sliders": 61794,
"sound-bold-outline": 61795,
"sound-outline": 61796,
"sounds": 61797,
"sparkly-label": 61798,
"star-bold-outline": 61799,
"star": 61800,
"trash-outline": 61801,
"trash": 61802,
"triangle-exclamation": 61803
}

View File

@@ -26,8 +26,9 @@ type Props = {
selected?: boolean,
source: SOURCE,
style?: Object,
testID?: string,
white?: boolean,
className?: string,
testID?: string,
width?: string
};
@@ -44,6 +45,7 @@ const ObsImagePreview = ( {
selected = false,
source,
style,
className,
testID,
white = false,
width = "w-[62px]"
@@ -59,6 +61,8 @@ const ObsImagePreview = ( {
"relative",
borderRadius,
height,
borderRadius,
className,
width
);

View File

@@ -96,7 +96,7 @@ const SearchBar = ( {
</View>
)
: (
<View className="absolute right-4 top-[20px]">
<View className="absolute right-4 top-[15px]">
<INatIcon name="magnifying-glass" size={18} />
</View>
)}

View File

@@ -18,7 +18,10 @@ type Props = {
testID: string,
clearBackground?: boolean,
confidence?: number,
white?: boolean
white?: boolean,
activeColor?: string,
confidencePosition?: string,
first?: boolean,
};
const TaxonResult = ( {
@@ -27,21 +30,29 @@ const TaxonResult = ( {
handleCheckmarkPress,
taxon,
testID,
white = false
white = false,
activeColor,
confidencePosition = "photo",
first = false
}: Props ): Node => {
const { t } = useTranslation( );
const navigation = useNavigation( );
const theme = useTheme( );
const taxonImage = { uri: taxon?.default_photo?.url };
const navToTaxonDetails = ( ) => navigation.navigate( "TaxonDetails", {
id: taxon.id
} );
return (
<View
className={
classnames(
"flex-row items-center justify-between pl-3 py-1",
"flex-row items-center justify-between px-4 py-3",
{
"border-[0.5px] border-lightGray": !clearBackground,
"mx-4": clearBackground
"border-b-[1px] border-lightGray": !clearBackground,
"mx-4": clearBackground,
"border-t-[1px]": first
}
)
}
@@ -49,7 +60,7 @@ const TaxonResult = ( {
>
<Pressable
className="flex-row items-center w-16 grow"
onPress={() => navigation.navigate( "TaxonDetails", { id: taxon.id } )}
onPress={navToTaxonDetails}
accessible
accessibilityRole="link"
accessibilityLabel={t( "Navigate-to-taxon-details" )}
@@ -60,12 +71,16 @@ const TaxonResult = ( {
source={taxonImage}
testID={`${testID}.photo`}
iconicTaxonName={taxon?.iconic_taxon_name}
className="rounded-xl"
isSmall
white={white}
/>
{confidence && (
{( confidence && confidencePosition === "photo" ) && (
<View className="absolute -bottom-5 w-[62px]">
<ConfidenceInterval confidence={confidence} />
<ConfidenceInterval
confidence={confidence}
activeColor={activeColor}
/>
</View>
)}
<View className="shrink ml-3">
@@ -74,28 +89,27 @@ const TaxonResult = ( {
layout="horizontal"
color={clearBackground && "text-white"}
/>
{( confidence && confidencePosition === "text" ) && (
<View className="absolute -bottom-3 w-[62px]">
<ConfidenceInterval
confidence={confidence}
activeColor={activeColor}
/>
</View>
)}
</View>
</Pressable>
<View className="flex-row items-center">
<INatIconButton
icon="info-circle-outline"
size={22}
onPress={() => navigation.navigate( "TabNavigator", {
screen: "ObservationsStackNavigator",
params: {
screen: "TaxonDetails",
params: {
id: taxon.id,
lastScreen: "AddID"
}
}
} )}
onPress={navToTaxonDetails}
color={clearBackground && theme.colors.onSecondary}
accessibilityLabel={t( "Information" )}
accessibilityHint={t( "Navigate-to-taxon-details" )}
/>
<INatIconButton
className="mx-2"
className="ml-2"
icon={clearBackground
? "checkmark-circle-outline"
: "checkmark-circle"}
@@ -106,6 +120,7 @@ const TaxonResult = ( {
onPress={handleCheckmarkPress}
accessibilityLabel={t( "Checkmark" )}
accessibilityHint={t( "Add-this-ID" )}
testID={`${testID}.checkmark`}
/>
</View>
</View>

View File

@@ -1,6 +1,7 @@
export { default as ActivityCount } from "./ActivityCount/ActivityCount";
export { default as CommentsCount } from "./ActivityCount/CommentsCount";
export { default as IdentificationsCount } from "./ActivityCount/IdentificationsCount";
export { default as BackButton } from "./Buttons/BackButton";
export { default as Button } from "./Buttons/Button";
export { default as CloseButton } from "./Buttons/CloseButton";
export { default as EvidenceButton } from "./Buttons/EvidenceButton";

View File

@@ -0,0 +1,53 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import {
INatIconButton,
TextInputSheet
} from "components/SharedComponents";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useLocalObservation, useTranslation } from "sharedHooks";
const AddCommentPrompt = ( ): Node => {
const {
setComment,
currentObservation
} = useContext( ObsEditContext );
const [showAddCommentSheet, setShowAddCommentSheet] = useState( false );
const uuid = currentObservation?.uuid;
const localObservation = useLocalObservation( uuid );
const wasSynced = localObservation?.wasSynced( );
const { t } = useTranslation( );
const navigation = useNavigation( );
useEffect( ( ) => {
const addCommentIcon = ( ) => (
<INatIconButton
icon="edit-comment"
onPress={( ) => setShowAddCommentSheet( true )}
accessibilityLabel={t( "Add-comment" )}
size={25}
/>
);
if ( wasSynced ) {
navigation.setOptions( {
headerRight: addCommentIcon
} );
}
}, [navigation, t, wasSynced] );
return showAddCommentSheet && (
<TextInputSheet
handleClose={( ) => setShowAddCommentSheet( false )}
headerText={t( "ADD-OPTIONAL-COMMENT" )}
snapPoints={[416]}
confirm={textInput => setComment( textInput )}
/>
);
};
export default AddCommentPrompt;

View File

@@ -0,0 +1,58 @@
// @flow
import { fetchObservers } from "api/observations";
import {
Body3
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import {
useAuthenticatedQuery,
useTranslation
} from "sharedHooks";
type Props = {
taxonIds: Array<number>
};
const Attribution = ( {
taxonIds
}: Props ): Node => {
const { t } = useTranslation( );
const { data } = useAuthenticatedQuery(
["fetchObservers", taxonIds],
( ) => fetchObservers( {
taxon_ids: taxonIds,
per_page: 3,
fields: {
user: {
login: true,
name: true
}
}
} ),
{
enabled: taxonIds.length > 0
}
);
const observers = data?.results?.map( observation => observation.user.login );
if ( !observers || observers?.length === 0 ) {
return <View testID="Attribution.empty" />;
}
return (
<Body3 className="mt-6 mb-4 mx-4">
{t( "iNaturalist-Identification-suggestions-are-trained-on", {
user1: observers[0],
user2: observers[1],
user3: observers[2]
} )}
</Body3>
);
};
export default Attribution;

View File

@@ -0,0 +1,38 @@
// @flow
import {
Body3, INatIcon
} from "components/SharedComponents";
import {
View
} from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { useTranslation } from "sharedHooks";
type Props = {
comment: string
};
const CommentBox = ( {
comment
}: Props ): Node => {
const { t } = useTranslation( );
return comment && comment.length > 0 && (
<>
<Body3 className="my-5 mx-8">
{t( "Your-identification-will-be-posted-with-the-following-comment" )}
</Body3>
<View className="bg-lightGray mx-6 p-5 rounded-lg flex-row items-center">
<INatIcon
name="add-comment-outline"
size={22}
/>
<Body3 className="ml-4 shrink">{ comment }</Body3>
</View>
</>
);
};
export default CommentBox;

View File

@@ -0,0 +1,62 @@
// @flow
import classnames from "classnames";
import {
Image, Pressable, View
} from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { FlatList } from "react-native";
import { useTranslation } from "sharedHooks";
type Props = {
photoUris: Array<string>,
selectedPhotoUri: string,
setSelectedPhotoUri: Function
};
const PhotoSelectionList = ( {
photoUris, selectedPhotoUri, setSelectedPhotoUri
}: Props ): Node => {
const { t } = useTranslation( );
const renderPhoto = ( { item } ) => (
<Pressable
accessibilityRole="button"
onPress={( ) => {
setSelectedPhotoUri( item );
}}
className={classnames(
"w-[83px] h-[83px] justify-center mx-1.5 rounded-lg"
)}
accessibilityLabel={t( "Select-photo" )}
testID={`PhotoSelectionList.${item}`}
>
<View
className={classnames(
"rounded-lg overflow-hidden",
{
"border border-inatGreen border-[3px]": selectedPhotoUri === item
}
)}
testID={`PhotoSelectionList.border.${item}`}
>
<Image
source={{ uri: item }}
accessibilityIgnoresInvertColors
className="w-full h-full"
/>
</View>
</Pressable>
);
return (
<FlatList
data={photoUris}
renderItem={renderPhoto}
horizontal
/>
);
};
export default PhotoSelectionList;

View File

@@ -0,0 +1,80 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import {
Body3, Button, ScrollViewWrapper
} from "components/SharedComponents";
import {
View
} from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import {
ActivityIndicator
} from "react-native-paper";
import { useTranslation } from "sharedHooks";
import AddCommentPrompt from "./AddCommentPrompt";
import Attribution from "./Attribution";
import CommentBox from "./CommentBox";
import PhotoSelectionList from "./PhotoSelectionList";
import SuggestionsList from "./SuggestionsList";
type Props = {
comment: string,
photoUris: Array<string>,
selectedPhotoUri: string,
setSelectedPhotoUri: Function,
nearbySuggestions: Array<Object>,
onTaxonChosen: Function,
loading: boolean,
loadingSuggestions: boolean
};
const Suggestions = ( {
photoUris, selectedPhotoUri, setSelectedPhotoUri, nearbySuggestions, onTaxonChosen,
comment, loading, loadingSuggestions
}: Props ): Node => {
const { t } = useTranslation( );
const navigation = useNavigation( );
return (
<ScrollViewWrapper testID="suggestions">
<AddCommentPrompt />
{loading && (
<View
className="absolute self-center z-10 pt-[30px]"
testID="Suggestions.ActivityIndicator"
>
<ActivityIndicator large />
</View>
)}
<View className="mx-5">
<PhotoSelectionList
photoUris={photoUris}
selectedPhotoUri={selectedPhotoUri}
setSelectedPhotoUri={setSelectedPhotoUri}
/>
<Body3 className="my-4 mx-3">{t( "Select-the-identification-you-want-to-add" )}</Body3>
<Button
text={t( "SEARCH-FOR-A-TAXON" )}
onPress={( ) => navigation.navigate( "TaxonSearch" )}
accessibilityLabel={t( "Search" )}
/>
</View>
<CommentBox comment={comment} />
<SuggestionsList
nearbySuggestions={nearbySuggestions}
onTaxonChosen={onTaxonChosen}
loading={loadingSuggestions}
/>
{nearbySuggestions?.length > 0 && (
<Attribution
taxonIds={nearbySuggestions.map( suggestion => suggestion.taxon.id )}
/>
)}
</ScrollViewWrapper>
);
};
export default Suggestions;

View File

@@ -0,0 +1,74 @@
// @flow
import scoreImage from "api/computerVision";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React, { useContext, useEffect, useState } from "react";
import flattenUploadParams from "sharedHelpers/flattenUploadParams";
import {
useAuthenticatedQuery,
useLocalObservation
} from "sharedHooks";
import Suggestions from "./Suggestions";
const SuggestionsContainer = ( ): Node => {
const {
photoEvidenceUris,
currentObservation,
createId,
loading,
comment,
setPhotoEvidenceUris
} = useContext( ObsEditContext );
const uuid = currentObservation?.uuid;
const obsPhotos = currentObservation?.observationPhotos;
const localObservation = useLocalObservation( uuid );
const [selectedPhotoUri, setSelectedPhotoUri] = useState( photoEvidenceUris[0] );
useEffect( ( ) => {
if ( obsPhotos?.length > photoEvidenceUris?.length ) {
setPhotoEvidenceUris( obsPhotos.map(
obsPhoto => obsPhoto.photo?.url || obsPhoto.photo?.localFilePath
) );
}
}, [obsPhotos, photoEvidenceUris, setPhotoEvidenceUris] );
const params = {
image: selectedPhotoUri,
latitude: localObservation?.latitude || currentObservation?.latitude,
longitude: localObservation?.longitude || currentObservation?.longitude
};
const { data: nearbySuggestions, isLoading: loadingSuggestions } = useAuthenticatedQuery(
["scoreImage", selectedPhotoUri],
async optsWithAuth => scoreImage(
await flattenUploadParams(
params.image,
params.latitude,
params.longitude
),
optsWithAuth
),
{
enabled: !!selectedPhotoUri
}
);
return (
<Suggestions
photoUris={photoEvidenceUris}
selectedPhotoUri={selectedPhotoUri}
setSelectedPhotoUri={setSelectedPhotoUri}
onTaxonChosen={
identification => createId( identification )
}
comment={comment}
loading={loading}
nearbySuggestions={nearbySuggestions}
loadingSuggestions={loadingSuggestions && photoEvidenceUris.length > 0}
/>
);
};
export default SuggestionsContainer;

View File

@@ -0,0 +1,104 @@
// @flow
import { Body1, Heading4, TaxonResult } from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { ActivityIndicator } from "react-native-paper";
import { useTranslation } from "sharedHooks";
type Props = {
nearbySuggestions: Array<Object>,
onTaxonChosen: Function,
loading: boolean
};
const SuggestionsList = ( {
nearbySuggestions,
onTaxonChosen,
loading
}: Props ): Node => {
const { t } = useTranslation( );
if ( loading ) {
return (
<View className="justify-center items-center mt-5" testID="SuggestionsList.loading">
<ActivityIndicator large />
</View>
);
}
if ( !nearbySuggestions || nearbySuggestions.length === 0 ) {
return (
<>
<Heading4 className="mt-6 mb-4 ml-4">{t( "TOP-ID-SUGGESTION" )}</Heading4>
<Body1 className="mx-10 text-center">
{t( "iNaturalist-isnt-able-to-provide-a-top-ID-suggestion-for-this-photo" )}
</Body1>
<Heading4 className="mt-6 mb-4 ml-4">{t( "NEARBY-SUGGESTIONS" )}</Heading4>
<Body1 className="mx-10 text-center">
{t( "iNaturalist-has-no-ID-suggestions-for-this-photo" )}
</Body1>
</>
);
}
const topSuggestion = nearbySuggestions[0];
const convertScoreToConfidence = score => {
if ( !score ) {
return null;
}
if ( score < 20 ) {
return 1;
}
if ( score < 40 ) {
return 2;
}
if ( score < 60 ) {
return 3;
}
if ( score < 80 ) {
return 4;
}
return 5;
};
return (
<>
<Heading4 className="mt-6 mb-4 ml-4">{t( "TOP-ID-SUGGESTION" )}</Heading4>
<View className="bg-inatGreen/[.13]">
<TaxonResult
key={topSuggestion.taxon.id}
taxon={topSuggestion.taxon}
handleCheckmarkPress={( ) => onTaxonChosen( topSuggestion.taxon )}
testID={`SuggestionsList.taxa.${topSuggestion.taxon.id}`}
confidence={convertScoreToConfidence( topSuggestion.combined_score )}
activeColor="bg-inatGreen"
confidencePosition="text"
first
/>
</View>
<Heading4 className="mt-6 mb-4 ml-4">{t( "NEARBY-SUGGESTIONS" )}</Heading4>
{nearbySuggestions.map( ( suggestion, index ) => {
if ( index === 0 ) {
return null;
}
return (
<TaxonResult
key={suggestion.taxon.id}
taxon={suggestion.taxon}
handleCheckmarkPress={( ) => onTaxonChosen( suggestion.taxon )}
testID={`SuggestionsList.taxa.${suggestion.taxon.id}`}
confidence={convertScoreToConfidence( suggestion.combined_score )}
activeColor="bg-inatGreen"
confidencePosition="text"
first={index === 0}
/>
);
} )}
</>
);
};
export default SuggestionsList;

View File

@@ -0,0 +1,83 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import fetchSearchResults from "api/search";
import {
SearchBar,
TaxonResult,
ViewWrapper
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React, { useContext, useEffect, useState } from "react";
import { FlatList } from "react-native";
import Taxon from "realmModels/Taxon";
import { useAuthenticatedQuery } from "sharedHooks";
import AddCommentPrompt from "./AddCommentPrompt";
import CommentBox from "./CommentBox";
const TaxonSearch = ( ): Node => {
const {
createId,
comment
} = useContext( ObsEditContext );
const [taxonQuery, setTaxonQuery] = useState( "" );
const navigation = useNavigation( );
const { data: taxonList } = useAuthenticatedQuery(
["fetchSearchResults", taxonQuery],
optsWithAuth => fetchSearchResults(
{
q: taxonQuery,
sources: "taxa",
fields: {
taxon: Taxon.TAXON_FIELDS
}
},
optsWithAuth
)
);
useEffect(
( ) => {
navigation.addListener( "blur", ( ) => {
setTaxonQuery( "" );
} );
},
[navigation]
);
const renderFooter = ( ) => (
<View className="pb-10" />
);
return (
<ViewWrapper className="flex-1">
<AddCommentPrompt />
<CommentBox comment={comment} />
<SearchBar
handleTextChange={setTaxonQuery}
value={taxonQuery}
testID="SearchTaxon"
containerClass="my-5 mx-4"
/>
<FlatList
keyboardShouldPersistTaps="always"
data={taxonList}
renderItem={( { item, index } ) => (
<TaxonResult
taxon={item}
handleCheckmarkPress={( ) => createId( item )}
testID={`Search.taxa.${item.id}`}
first={index === 0}
/>
)}
keyExtractor={item => item.id}
ListFooterComponent={renderFooter}
/>
</ViewWrapper>
);
};
export default TaxonSearch;

View File

@@ -4,6 +4,7 @@ import { useNavigation, useRoute } from "@react-navigation/native";
import { fetchTaxon } from "api/taxa";
import PlaceholderText from "components/PlaceholderText";
import {
BackButton,
DisplayTaxonName,
Heading4,
HideView,
@@ -11,7 +12,6 @@ import {
ScrollViewWrapper,
Tabs
} from "components/SharedComponents";
import BackButton from "components/SharedComponents/Buttons/BackButton";
import { ImageBackground, View } from "components/styledComponents";
import type { Node } from "react";
import React, { useState } from "react";
@@ -29,7 +29,6 @@ const TaxonDetails = ( ): Node => {
const navigation = useNavigation( );
const { params } = useRoute( );
const { id } = params;
const lastScreen = params?.lastScreen;
const { t } = useTranslation( );
const [currentTabId, setCurrentTabId] = useState( ABOUT_TAB_ID );
@@ -70,16 +69,7 @@ const TaxonDetails = ( ): Node => {
<View className="absolute left-5 top-5">
<BackButton
color={theme.colors.onPrimary}
onPress={( ) => {
if ( lastScreen ) {
navigation.navigate( "CameraNavigator", {
screen: "AddID",
params: { }
} );
} else {
navigation.goBack( );
}
}}
onPress={( ) => navigation.goBack( )}
/>
</View>
<View className="absolute bottom-5 left-5">
@@ -93,7 +83,12 @@ const TaxonDetails = ( ): Node => {
<View className="absolute bottom-5 right-5">
<INatIconButton
icon="compass-rose-outline"
onPress={( ) => navigation.navigate( "Explore" )}
onPress={( ) => navigation.navigate( "TabNavigator", {
screen: "ObservationsStackNavigator",
params: {
screen: "Explore"
}
} )}
accessibilityLabel={t( "Explore" )}
accessibilityHint={t( "Navigates-to-explore" )}
size={30}

View File

@@ -357,7 +357,7 @@ IDENTIFICATION = IDENTIFICATION
IDENTIFICATIONS = IDENTIFICATIONS
If-an-account-with-that-email-exists = If an account with that email exists, weve sent password reset instructions to your email.
If-an-account-with-that-email-exists = If an account with that email exists, we've sent password reset instructions to your email.
# Shows the number of photos a user selected from the camera roll for upload
Import-X-photos = Import {$count ->
@@ -704,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
@@ -846,7 +844,7 @@ VIEW-DATA-QUALITY-ASSESSEMENT = VIEW DATA QUALITY ASSESSEMENT
View-in-browser = View in Browser
Visually-search-iNaturalist-data = Visually search iNaturalists wealth of data. Search by a taxon in a location
Visually-search-iNaturalist-data = Visually search iNaturalist's wealth of data. Search by a taxon in a location
Welcome-to-iNaturalist = Welcome to iNaturalist!
@@ -916,7 +914,7 @@ Take-a-photo-with-your-camera = Take a photo with your camera
Upload-a-photo-from-your-gallery = Upload a photo from your gallery
Record-a-sound = Record a sound
You-can-also-explore-existing-observations = You can also explore existing observations on iNaturalist to discover whats around you.
You-can-also-explore-existing-observations = You can also explore existing observations on iNaturalist to discover what's around you.
# Message shown when a permission is required to use a part of the app
# (e.g. permission to access the camera) but the user denied the permission.
@@ -934,7 +932,7 @@ You-will-lose-all-existing-observations = {$count ->
You-can-still-share-the-file =
You can still share the file with another app. If you can email it, please send it to { $email }
Zoom-in = Zoom in so that the observations accuracy is as low as possible.
Zoom-in = Zoom in so that the observation's accuracy is as low as possible.
Your-location-uncertainty-is-over-4000km = Your location uncertainty is over 4000km, which is too high to be helpful to identifiers. Edit the location and zoom in until the accuracy circle turns green and is centered on where you observed the organism.
@@ -1085,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
@@ -1174,7 +1174,7 @@ The-exact-location-will-be-hidden = The exact location will be hidden publicly,
The-location-will-not-be-visible = The location will not be visible to others, which means it may be difficult to identify.
# Wild status sheet descriptions
This-is-a-wild-organism = This is a wild organism and wasnt placed in this location by humans.
This-is-a-wild-organism = This is a wild organism and wasn't placed in this location by humans.
This-organism-was-placed-by-humans = This organism was placed in this location by humans. This applies to things like garden plants, pets, and zoo animals.
# Latitude, longitude, and accuracy on a single line on a single line
@@ -1185,7 +1185,7 @@ Lat-Lon = { NUMBER($latitude, maximumFractionDigits: 6) }, { NUMBER($longitude,
# Missing evidence sheet
Every-observation-needs = Every observation needs a location, date, and time to be helpful to identifiers. You can edit geoprivacy if youre concerned about location privacy.
Every-observation-needs = Every observation needs a location, date, and time to be helpful to identifiers. You can edit geoprivacy if you're concerned about location privacy.
# Button or accessibility label for an interactive element that stops an upload
Stop-upload = Stop upload
@@ -1255,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
@@ -1269,7 +1269,7 @@ Log-in-to-iNaturalist = Log in to iNaturalist
Try-searching-for-a-location-name = Try searching for a location name to see the map
Scan-the-area-around-you-for-organisms = Scan the area around you for organisms.
Loading-iNaturalists-AR-Camera = Loading iNaturalists AR Camera
Loading-iNaturalists-AR-Camera = Loading iNaturalist's AR Camera
# Used for explore screen when search params lead to a search with no data
No-results-found = No results found
@@ -1277,7 +1277,7 @@ 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 havent joined any projects yet!
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.
@@ -1299,9 +1299,9 @@ 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 projects page is loaded, iNaturalist will perform a quick search and display all observations that match the projects 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.
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 citys 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.
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
@@ -1309,3 +1309,17 @@ JOIN = JOIN
LEAVE-PROJECT = LEAVE PROJECT
LEAVE = LEAVE
Your-email-is-confirmed = Your email is confirmed! Please log in to continue.
SEARCH-FOR-A-TAXON = SEARCH FOR A TAXON
Select-the-identification-you-want-to-add = Select the identification you want to add to this observation. You can add a filter to further refine your results or search for a taxon.
TOP-ID-SUGGESTION = TOP ID SUGGESTION
NEARBY-SUGGESTIONS = NEARBY SUGGESTIONS
INCLUDE-TAXA-NOT-EXPECTED-NEARBY = INCLUDE TAXA NOT EXPECTED NEARBY
ONLY-SHOW-TAXA-EXPECTED-NEARBY = ONLY-SHOW-TAXA-EXPECTED-NEARBY
iNaturalist-Identification-suggestions-are-trained-on = iNaturalist's Identification suggestions are trained on observations and identifications made by the iNaturalist community, including {$user1}, {$user2}, {$user3}, and many others.
SPECIES-NEARBY = SPECIES NEARBY
Below-are-all-the-species-observed-within-50km = Below are all the species observed within 50 km of your location within the taxon:
Species-Nearby-requires-internet-to-work = Species Nearby requires internet to work. Please check your internet connection.
Your-identification-will-be-posted-with-the-following-comment = Your identification will be posted with the following comment:
iNaturalist-isnt-able-to-provide-a-top-ID-suggestion-for-this-photo = iNaturalist isn't able to provide a top ID suggestion for this photo.
iNaturalist-has-no-ID-suggestions-for-this-photo = iNaturalist has no ID suggestions for this photo.

View File

@@ -204,7 +204,7 @@
},
"IDENTIFICATION": "IDENTIFICATION",
"IDENTIFICATIONS": "IDENTIFICATIONS",
"If-an-account-with-that-email-exists": "If an account with that email exists, weve sent password reset instructions to your email.",
"If-an-account-with-that-email-exists": "If an account with that email exists, we've sent password reset instructions to your email.",
"Import-X-photos": {
"comment": "Shows the number of photos a user selected from the camera roll for upload",
"val": "Import { $count ->\n [one] 1 photo\n *[other] { $count } photos\n}"
@@ -430,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",
@@ -519,7 +518,7 @@
},
"VIEW-DATA-QUALITY-ASSESSEMENT": "VIEW DATA QUALITY ASSESSEMENT",
"View-in-browser": "View in Browser",
"Visually-search-iNaturalist-data": "Visually search iNaturalists wealth of data. Search by a taxon in a location",
"Visually-search-iNaturalist-data": "Visually search iNaturalist's wealth of data. Search by a taxon in a location",
"Welcome-to-iNaturalist": "Welcome to iNaturalist!",
"Whenever-you-get-internet-connection-you-can-upload": "Whenever you get internet connection, you can upload your observations to iNaturalist.",
"Which-traditional-projects-can-add-your-observations": "Which traditional projects can add your observations?",
@@ -556,7 +555,7 @@
"Take-a-photo-with-your-camera": "Take a photo with your camera",
"Upload-a-photo-from-your-gallery": "Upload a photo from your gallery",
"Record-a-sound": "Record a sound",
"You-can-also-explore-existing-observations": "You can also explore existing observations on iNaturalist to discover whats around you.",
"You-can-also-explore-existing-observations": "You can also explore existing observations on iNaturalist to discover what's around you.",
"You-denied-iNaturalist-permission-to-do-that": {
"comment": "Message shown when a permission is required to use a part of the app\n(e.g. permission to access the camera) but the user denied the permission.",
"val": "You denied iNaturalist permission to do that"
@@ -565,7 +564,7 @@
"You-must-be-logged-in-to-view-messages": "You must be logged in to view messages",
"You-will-lose-all-existing-observations": "{ $count ->\n [one] You will lose all existing observations. Would you like to discard 1 observation?\n *[other] You will lose all existing observations. Would you like to discard { $count } observations?\n}",
"You-can-still-share-the-file": "You can still share the file with another app. If you can email it, please send it to { $email }",
"Zoom-in": "Zoom in so that the observations accuracy is as low as possible.",
"Zoom-in": "Zoom in so that the observation's accuracy is as low as possible.",
"Your-location-uncertainty-is-over-4000km": "Your location uncertainty is over 4000km, which is too high to be helpful to identifiers. Edit the location and zoom in until the accuracy circle turns green and is centered on where you observed the organism.",
"Category-leading": {
"comment": "Identification category",
@@ -711,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"
@@ -800,7 +801,7 @@
"The-location-will-not-be-visible": "The location will not be visible to others, which means it may be difficult to identify.",
"This-is-a-wild-organism": {
"comment": " Wild status sheet descriptions",
"val": "This is a wild organism and wasnt placed in this location by humans."
"val": "This is a wild organism and wasn't placed in this location by humans."
},
"This-organism-was-placed-by-humans": "This organism was placed in this location by humans. This applies to things like garden plants, pets, and zoo animals.",
"Lat-Lon-Acc": {
@@ -813,7 +814,7 @@
},
"Every-observation-needs": {
"comment": "Missing evidence sheet",
"val": "Every observation needs a location, date, and time to be helpful to identifiers. You can edit geoprivacy if youre concerned about location privacy."
"val": "Every observation needs a location, date, and time to be helpful to identifiers. You can edit geoprivacy if you're concerned about location privacy."
},
"Stop-upload": {
"comment": "Button or accessibility label for an interactive element that stops an upload",
@@ -878,7 +879,7 @@
"Log-in-to-iNaturalist": "Log in to iNaturalist",
"Try-searching-for-a-location-name": "Try searching for a location name to see the map",
"Scan-the-area-around-you-for-organisms": "Scan the area around you for organisms.",
"Loading-iNaturalists-AR-Camera": "Loading iNaturalists AR Camera",
"Loading-iNaturalists-AR-Camera": "Loading iNaturalist's AR Camera",
"No-results-found": {
"comment": "Used for explore screen when search params lead to a search with no data",
"val": "No results found"
@@ -887,7 +888,7 @@
"comment": "Default accessibility label for DisplayTaxon component",
"val": "Taxon photo and name"
},
"You-havent-joined-any-projects-yet": "You havent joined any projects yet!",
"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",
@@ -900,12 +901,25 @@
"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 projects page is loaded, iNaturalist will perform a quick search and display all observations that match the projects 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.",
"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 citys 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.",
"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."
"Your-email-is-confirmed": "Your email is confirmed! Please log in to continue.",
"SEARCH-FOR-A-TAXON": "SEARCH FOR A TAXON",
"Select-the-identification-you-want-to-add": "Select the identification you want to add to this observation. You can add a filter to further refine your results or search for a taxon.",
"TOP-ID-SUGGESTION": "TOP ID SUGGESTION",
"NEARBY-SUGGESTIONS": "NEARBY SUGGESTIONS",
"INCLUDE-TAXA-NOT-EXPECTED-NEARBY": "INCLUDE TAXA NOT EXPECTED NEARBY",
"ONLY-SHOW-TAXA-EXPECTED-NEARBY": "ONLY-SHOW-TAXA-EXPECTED-NEARBY",
"iNaturalist-Identification-suggestions-are-trained-on": "iNaturalist's Identification suggestions are trained on observations and identifications made by the iNaturalist community, including { $user1 }, { $user2 }, { $user3 }, and many others.",
"SPECIES-NEARBY": "SPECIES NEARBY",
"Below-are-all-the-species-observed-within-50km": "Below are all the species observed within 50 km of your location within the taxon:",
"Species-Nearby-requires-internet-to-work": "Species Nearby requires internet to work. Please check your internet connection.",
"Your-identification-will-be-posted-with-the-following-comment": "Your identification will be posted with the following comment:",
"iNaturalist-isnt-able-to-provide-a-top-ID-suggestion-for-this-photo": "iNaturalist isn't able to provide a top ID suggestion for this photo.",
"iNaturalist-has-no-ID-suggestions-for-this-photo": "iNaturalist has no ID suggestions for this photo."
}

View File

@@ -357,7 +357,7 @@ IDENTIFICATION = IDENTIFICATION
IDENTIFICATIONS = IDENTIFICATIONS
If-an-account-with-that-email-exists = If an account with that email exists, weve sent password reset instructions to your email.
If-an-account-with-that-email-exists = If an account with that email exists, we've sent password reset instructions to your email.
# Shows the number of photos a user selected from the camera roll for upload
Import-X-photos = Import {$count ->
@@ -704,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
@@ -846,7 +844,7 @@ VIEW-DATA-QUALITY-ASSESSEMENT = VIEW DATA QUALITY ASSESSEMENT
View-in-browser = View in Browser
Visually-search-iNaturalist-data = Visually search iNaturalists wealth of data. Search by a taxon in a location
Visually-search-iNaturalist-data = Visually search iNaturalist's wealth of data. Search by a taxon in a location
Welcome-to-iNaturalist = Welcome to iNaturalist!
@@ -916,7 +914,7 @@ Take-a-photo-with-your-camera = Take a photo with your camera
Upload-a-photo-from-your-gallery = Upload a photo from your gallery
Record-a-sound = Record a sound
You-can-also-explore-existing-observations = You can also explore existing observations on iNaturalist to discover whats around you.
You-can-also-explore-existing-observations = You can also explore existing observations on iNaturalist to discover what's around you.
# Message shown when a permission is required to use a part of the app
# (e.g. permission to access the camera) but the user denied the permission.
@@ -934,7 +932,7 @@ You-will-lose-all-existing-observations = {$count ->
You-can-still-share-the-file =
You can still share the file with another app. If you can email it, please send it to { $email }
Zoom-in = Zoom in so that the observations accuracy is as low as possible.
Zoom-in = Zoom in so that the observation's accuracy is as low as possible.
Your-location-uncertainty-is-over-4000km = Your location uncertainty is over 4000km, which is too high to be helpful to identifiers. Edit the location and zoom in until the accuracy circle turns green and is centered on where you observed the organism.
@@ -1085,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
@@ -1174,7 +1174,7 @@ The-exact-location-will-be-hidden = The exact location will be hidden publicly,
The-location-will-not-be-visible = The location will not be visible to others, which means it may be difficult to identify.
# Wild status sheet descriptions
This-is-a-wild-organism = This is a wild organism and wasnt placed in this location by humans.
This-is-a-wild-organism = This is a wild organism and wasn't placed in this location by humans.
This-organism-was-placed-by-humans = This organism was placed in this location by humans. This applies to things like garden plants, pets, and zoo animals.
# Latitude, longitude, and accuracy on a single line on a single line
@@ -1185,7 +1185,7 @@ Lat-Lon = { NUMBER($latitude, maximumFractionDigits: 6) }, { NUMBER($longitude,
# Missing evidence sheet
Every-observation-needs = Every observation needs a location, date, and time to be helpful to identifiers. You can edit geoprivacy if youre concerned about location privacy.
Every-observation-needs = Every observation needs a location, date, and time to be helpful to identifiers. You can edit geoprivacy if you're concerned about location privacy.
# Button or accessibility label for an interactive element that stops an upload
Stop-upload = Stop upload
@@ -1255,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
@@ -1269,7 +1269,7 @@ Log-in-to-iNaturalist = Log in to iNaturalist
Try-searching-for-a-location-name = Try searching for a location name to see the map
Scan-the-area-around-you-for-organisms = Scan the area around you for organisms.
Loading-iNaturalists-AR-Camera = Loading iNaturalists AR Camera
Loading-iNaturalists-AR-Camera = Loading iNaturalist's AR Camera
# Used for explore screen when search params lead to a search with no data
No-results-found = No results found
@@ -1277,7 +1277,7 @@ 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 havent joined any projects yet!
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.
@@ -1299,9 +1299,9 @@ 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 projects page is loaded, iNaturalist will perform a quick search and display all observations that match the projects 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.
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 citys 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.
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
@@ -1309,3 +1309,17 @@ JOIN = JOIN
LEAVE-PROJECT = LEAVE PROJECT
LEAVE = LEAVE
Your-email-is-confirmed = Your email is confirmed! Please log in to continue.
SEARCH-FOR-A-TAXON = SEARCH FOR A TAXON
Select-the-identification-you-want-to-add = Select the identification you want to add to this observation. You can add a filter to further refine your results or search for a taxon.
TOP-ID-SUGGESTION = TOP ID SUGGESTION
NEARBY-SUGGESTIONS = NEARBY SUGGESTIONS
INCLUDE-TAXA-NOT-EXPECTED-NEARBY = INCLUDE TAXA NOT EXPECTED NEARBY
ONLY-SHOW-TAXA-EXPECTED-NEARBY = ONLY-SHOW-TAXA-EXPECTED-NEARBY
iNaturalist-Identification-suggestions-are-trained-on = iNaturalist's Identification suggestions are trained on observations and identifications made by the iNaturalist community, including {$user1}, {$user2}, {$user3}, and many others.
SPECIES-NEARBY = SPECIES NEARBY
Below-are-all-the-species-observed-within-50km = Below are all the species observed within 50 km of your location within the taxon:
Species-Nearby-requires-internet-to-work = Species Nearby requires internet to work. Please check your internet connection.
Your-identification-will-be-posted-with-the-following-comment = Your identification will be posted with the following comment:
iNaturalist-isnt-able-to-provide-a-top-ID-suggestion-for-this-photo = iNaturalist isn't able to provide a top ID suggestion for this photo.
iNaturalist-has-no-ID-suggestions-for-this-photo = iNaturalist has no ID suggestions for this photo.

View File

@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<svg width="24" height="24" viewBox="0 0 25 25">
<path d="M12.5003 2C6.56731 2 2.04199 6.06439 2.04199 10.7544C2.04199 13.4124 3.4668 15.8391 5.80285 17.4728L6.34089 17.8491L6.20952 18.4924C5.90552 19.9811 5.09007 21.4712 3.85494 22.9611C5.73527 22.7587 7.47993 21.7172 9.00129 19.6458L9.38053 19.1295L10.0081 19.2582C10.8057 19.4217 11.6402 19.5087 12.5003 19.5087C18.4333 19.5087 22.9587 15.4443 22.9587 10.7544C22.9587 6.06439 18.4333 2 12.5003 2ZM0.0419922 10.7544C0.0419922 4.66997 5.77681 0 12.5003 0C19.2238 0 24.9587 4.66997 24.9587 10.7544C24.9587 16.8388 19.2238 21.5087 12.5003 21.5087C11.725 21.5087 10.9654 21.4476 10.2278 21.3304C8.3709 23.6305 6.08704 24.8912 3.49298 24.9917C2.88211 25.0154 2.34569 24.9948 1.94297 24.8613C1.73617 24.7928 1.42873 24.651 1.21604 24.3366C0.974446 23.9794 0.983259 23.5967 1.05807 23.3258C1.12439 23.0857 1.24887 22.8961 1.33463 22.7792C1.42786 22.6521 1.53299 22.5349 1.62279 22.4392C1.71875 22.337 1.79359 22.2613 1.85902 22.195C1.94512 22.1078 2.01492 22.0371 2.0951 21.9445C3.1235 20.7564 3.76747 19.6721 4.09001 18.6906C1.63049 16.7484 0.0419922 13.9318 0.0419922 10.7544Z"/>
<circle cx="8.1" cy="11" r="1.1"/>
<circle cx="12.5004" cy="11" r="1.1"/>
<circle cx="16.8998" cy="11" r="1.1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -8,7 +8,6 @@ import MyObservationsContainer from "components/MyObservations/MyObservationsCon
import DataQualityAssessment from "components/ObsDetails/DataQualityAssessment";
import ObsDetailsContainer from "components/ObsDetails/ObsDetailsContainer";
import PlaceholderComponent from "components/PlaceholderComponent";
import TaxonDetails from "components/TaxonDetails/TaxonDetails";
import UserProfile from "components/UserProfile/UserProfile";
import { t } from "i18next";
import {
@@ -49,11 +48,6 @@ const ObservationsStackNavigator = ( ): Node => (
unmountOnBlur: true
}}
/>
<Stack.Screen
name="TaxonDetails"
component={TaxonDetails}
options={hideHeader}
/>
<Stack.Screen
name="UserProfile"
component={UserProfile}

View File

@@ -2,11 +2,13 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import AddIDContainer from "components/AddID/AddIDContainer";
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 SuggestionsContainer from "components/Suggestions/SuggestionsContainer";
import TaxonSearch from "components/Suggestions/TaxonSearch";
import TaxonDetails from "components/TaxonDetails/TaxonDetails";
import { t } from "i18next";
import {
blankHeaderTitle,
@@ -17,7 +19,8 @@ import type { Node } from "react";
import React from "react";
import { PermissionsAndroid } from "react-native";
const addIDTitle = ( ) => <Heading4>{t( "ADD-AN-ID" )}</Heading4>;
const suggestionsTitle = ( ) => <Heading4>{t( "ADD-AN-ID" )}</Heading4>;
const taxonSearchTitle = ( ) => <Heading4>{t( "SEARCH" )}</Heading4>;
const Stack = createNativeStackNavigator( );
@@ -32,7 +35,14 @@ const ObsEditWithPermission = ( ) => (
);
const SharedStackScreens = ( ): Node => (
<Stack.Group>
<Stack.Group
screenOptions={{
cardStyle: {
backgroundColor: "rgba(0,0,0,0)",
opacity: 1
}
}}
>
<Stack.Screen
name="ObsEdit"
component={ObsEditWithPermission}
@@ -44,11 +54,21 @@ const SharedStackScreens = ( ): Node => (
}}
/>
<Stack.Screen
name="AddID"
component={AddIDContainer}
name="Suggestions"
component={SuggestionsContainer}
options={{
...removeBottomBorder,
headerTitle: addIDTitle,
headerTitle: suggestionsTitle,
headerTitleAlign: "center",
headerBackTitleVisible: false
}}
/>
<Stack.Screen
name="TaxonSearch"
component={TaxonSearch}
options={{
...removeBottomBorder,
headerTitle: taxonSearchTitle,
headerTitleAlign: "center"
}}
/>
@@ -70,6 +90,11 @@ const SharedStackScreens = ( ): Node => (
}
}}
/>
<Stack.Screen
name="TaxonDetails"
component={TaxonDetails}
options={hideHeader}
/>
</Stack.Group>
);

View File

@@ -2,6 +2,7 @@
import { CameraRoll } from "@react-native-camera-roll/camera-roll";
import { useNavigation } from "@react-navigation/native";
import { activateKeepAwake, deactivateKeepAwake } from "@sayem314/react-native-keep-awake";
import { createIdentification } from "api/identifications";
import {
createObservation,
createOrUpdateEvidence,
@@ -17,7 +18,9 @@ import React, {
useReducer,
useState
} from "react";
import { Alert } from "react-native";
import { EventRegister } from "react-native-event-listeners";
import rnUUID from "react-native-uuid";
import Observation from "realmModels/Observation";
import ObservationPhoto from "realmModels/ObservationPhoto";
import Photo from "realmModels/Photo";
@@ -28,7 +31,10 @@ import fetchPlaceName from "sharedHelpers/fetchPlaceName";
import { formatExifDateAsString, parseExif, writeExifToFile } from "sharedHelpers/parseExif";
import {
useApiToken,
useCurrentUser
useAuthenticatedMutation,
useCurrentUser,
useLocalObservation,
useTranslation
} from "sharedHooks";
import { log } from "../../react-native-logs.config";
@@ -118,6 +124,7 @@ const ObsEditProvider = ( { children }: Props ): Node => {
const realm = useRealm( );
const apiToken = useApiToken( );
const currentUser = useCurrentUser( );
const { t } = useTranslation( );
// state related to creating/editing an observation
const [currentObservationIndex, setCurrentObservationIndex] = useState( 0 );
const [observations, setObservations] = useState( [] );
@@ -131,10 +138,12 @@ const ObsEditProvider = ( { children }: Props ): Node => {
const [unsavedChanges, setUnsavedChanges] = useState( false );
const [passesEvidenceTest, setPassesEvidenceTest] = useState( false );
const [passesIdentificationTest, setPassesIdentificationTest] = useState( false );
const [mediaViewerUris, setMediaViewerUris] = useState( [] );
const [photoEvidenceUris, setPhotoEvidenceUris] = useState( [] );
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState( 0 );
const [groupedPhotos, setGroupedPhotos] = useState( [] );
const [savingPhoto, setSavingPhoto] = useState( false );
const [lastScreen, setLastScreen] = useState( "" );
const [comment, setComment] = useState( "" );
// state related to uploads in useReducer
const [state, dispatch] = useReducer( reducer, initialState );
@@ -168,6 +177,8 @@ const ObsEditProvider = ( { children }: Props ): Node => {
setUnsavedChanges( false );
setPassesEvidenceTest( false );
setGroupedPhotos( [] );
setPhotoEvidenceUris( [] );
setComment( "" );
}, [] );
const stopUpload = ( ) => {
@@ -216,6 +227,9 @@ const ObsEditProvider = ( { children }: Props ): Node => {
const currentObservation = observations[currentObservationIndex];
const numOfObsPhotos = currentObservation?.observationPhotos?.length || 0;
const localObservation = useLocalObservation( currentObservation?.uuid );
const wasSynced = localObservation?.wasSynced( );
const addSound = async ( ) => {
const newObservation = await Observation.createObsWithSounds( );
setObservations( [newObservation] );
@@ -303,6 +317,46 @@ const ObsEditProvider = ( { children }: Props ): Node => {
setSavingPhoto( false );
}, [createObsPhotos, appendObsPhotos, numOfObsPhotos] );
const createIdentificationMutation = useAuthenticatedMutation(
( idParams, optsWithAuth ) => createIdentification( idParams, optsWithAuth ),
{
onSuccess: data => {
const belongsToCurrentUser = currentObservation?.user?.login === currentUser?.login;
if ( belongsToCurrentUser && wasSynced ) {
realm?.write( ( ) => {
const localIdentifications = currentObservation?.identifications;
const newIdentification = data[0];
newIdentification.user = currentUser;
newIdentification.taxon = realm?.objectForPrimaryKey(
"Taxon",
newIdentification.taxon.id
) || newIdentification.taxon;
const realmIdentification = realm?.create( "Identification", newIdentification );
localIdentifications.push( realmIdentification );
} );
}
setLoading( false );
navigation.navigate( "ObservationsStackNavigator", {
screen: "ObsDetails",
params: { uuid: currentObservation.uuid }
} );
},
onError: e => {
const showErrorAlert = err => Alert.alert( "Error", err, [{ text: t( "OK" ) }], {
cancelable: true
} );
let identificationError = null;
if ( e ) {
identificationError = t( "Couldnt-create-identification-error", { error: e.message } );
} else {
identificationError = t( "Couldnt-create-identification-unknown-error" );
}
setLoading( false );
return showErrorAlert( identificationError );
}
}
);
const uploadValue = useMemo( ( ) => {
// Save URIs to camera gallery (if a photo was taken using the app,
// we want it accessible in the camera's folder, as if the user has taken those photos
@@ -386,11 +440,49 @@ const ObsEditProvider = ( { children }: Props ): Node => {
setObservations( [...updatedObservations] );
};
const formatIdentification = taxon => {
const newIdent = {
uuid: rnUUID.v4(),
body: comment,
taxon
};
return newIdent;
};
const createId = identification => {
setLoading( true );
const newIdentification = formatIdentification( identification );
const createRemoteIdentification = localObservation?.wasSynced( );
if ( createRemoteIdentification ) {
createIdentificationMutation.mutate( {
identification: {
observation_id: currentObservation.uuid,
taxon_id: newIdentification.taxon.id,
body: newIdentification.body
}
} );
} else {
updateObservationKeys( {
taxon: newIdentification.taxon
} );
setLoading( false );
const navParams = {};
if ( lastScreen === "ObsDetails" ) {
navParams.uuid = currentObservation.uuid;
}
navigation.navigate( "ObservationsStackNavigator", {
screen: lastScreen,
params: navParams
} );
}
};
const deleteLocalObservation = uuid => {
const localObservation = realm.objectForPrimaryKey( "Observation", uuid );
if ( !localObservation ) { return; }
const localObsToDelete = realm.objectForPrimaryKey( "Observation", uuid );
if ( !localObsToDelete ) { return; }
realm?.write( ( ) => {
realm?.delete( localObservation );
realm?.delete( localObsToDelete );
} );
};
@@ -651,8 +743,8 @@ const ObsEditProvider = ( { children }: Props ): Node => {
}
// photos to show in media viewer
const newMediaViewerUris = removePhotoFromList( mediaViewerUris, photoUriToDelete );
setMediaViewerUris( [...newMediaViewerUris] );
const newphotoEvidenceUris = removePhotoFromList( photoEvidenceUris, photoUriToDelete );
setPhotoEvidenceUris( [...newphotoEvidenceUris] );
// photos displayed in PhotoPreview
const newCameraPreviewUris = removePhotoFromList( cameraPreviewUris, photoUriToDelete );
@@ -765,8 +857,8 @@ const ObsEditProvider = ( { children }: Props ): Node => {
passesEvidenceTest,
passesIdentificationTest,
setPassesIdentificationTest,
mediaViewerUris,
setMediaViewerUris,
photoEvidenceUris,
setPhotoEvidenceUris,
selectedPhotoIndex,
setSelectedPhotoIndex,
groupedPhotos,
@@ -783,7 +875,11 @@ const ObsEditProvider = ( { children }: Props ): Node => {
setOriginalCameraUrisMap,
savingPhoto,
singleUpload,
totalUploadCount
totalUploadCount,
createId,
setLastScreen,
comment,
setComment
};
}, [
currentObservation,
@@ -810,7 +906,7 @@ const ObsEditProvider = ( { children }: Props ): Node => {
uploadProgress,
passesEvidenceTest,
passesIdentificationTest,
mediaViewerUris,
photoEvidenceUris,
selectedPhotoIndex,
groupedPhotos,
currentUploadIndex,
@@ -825,6 +921,10 @@ const ObsEditProvider = ( { children }: Props ): Node => {
savingPhoto,
singleUpload,
totalUploadCount,
createIdentificationMutation,
lastScreen,
localObservation,
comment,
createObsPhotos,
numOfObsPhotos
] );

View File

@@ -0,0 +1,52 @@
import ImageResizer from "@bam.tech/react-native-image-resizer";
import { FileUpload } from "inaturalistjs";
const resizeImage = async (
path: string,
width: number,
height?: number,
outputPath?: string
): Promise<string> => {
try {
const { uri } = await ImageResizer.createResizedImage(
path,
width,
height || width, // height
"JPEG", // compressFormat
100, // quality
0, // rotation
// $FlowFixMe
outputPath, // outputPath
true // keep metadata
);
return uri;
} catch ( e ) {
return "";
}
};
const flattenUploadParams = async (
uri: string,
latitude: number,
longitude: number
): Object => {
const userImage = await resizeImage( uri, 299 );
const params = {
image: new FileUpload( {
uri: userImage,
name: "photo.jpeg",
type: "image/jpeg"
} )
};
if ( latitude ) {
params.latitude = latitude;
}
if ( longitude ) {
params.longitude = longitude;
}
return params;
};
export default flattenUploadParams;

View File

@@ -44,7 +44,9 @@ const mockObsEditProviderWithObs = obs => ObsEditProvider.mockImplementation( (
currentObservation: obs[0],
updateObservationKeys: jest.fn( ),
setPassesIdentificationTest: jest.fn( ),
writeExifToCameraRollPhotos: jest.fn( )
writeExifToCameraRollPhotos: jest.fn( ),
photoEvidenceUris: [faker.image.imageUrl( )],
setPhotoEvidenceUris: jest.fn( )
}}
>
{children}

View File

@@ -3,6 +3,9 @@ import { fireEvent, screen } from "@testing-library/react-native";
import ObsDetailsContainer from "components/ObsDetails/ObsDetailsContainer";
import initI18next from "i18n/initI18next";
import { t } from "i18next";
import { ObsEditContext } from "providers/contexts";
import INatPaperProvider from "providers/INatPaperProvider";
import ObsEditProvider from "providers/ObsEditProvider";
import React from "react";
import { View } from "react-native";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
@@ -11,6 +14,11 @@ import useIsConnected from "sharedHooks/useIsConnected";
import factory from "../../../factory";
import { renderComponent } from "../../../helpers/render";
// Mock ObservationProvider so it provides a specific array of observations
// without any current observation or ability to update or fetch
// observations
jest.mock( "providers/ObsEditProvider" );
const mockNavigate = jest.fn();
const mockObservation = factory( "LocalObservation", {
_created_at: faker.date.past( ),
@@ -150,13 +158,33 @@ jest.mock( "sharedHooks/useUserLocation", () => ( {
default: () => ( { latLng: mockLatLng } )
} ) );
const mockObsEditProviderWithObs = obs => ObsEditProvider.mockImplementation( ( { children } ) => (
// eslint-disable-next-line react/jsx-no-constructed-context-values
<INatPaperProvider>
<ObsEditContext.Provider value={{
setPhotoEvidenceUris: jest.fn( ),
observations: obs
}}
>
{children}
</ObsEditContext.Provider>
</INatPaperProvider>
) );
const renderObsDetails = ( ) => renderComponent(
<ObsEditProvider>
<ObsDetailsContainer />
</ObsEditProvider>
);
describe( "ObsDetails", () => {
beforeAll( async () => {
await initI18next();
} );
it( "should not have accessibility errors", async () => {
renderComponent( <ObsDetailsContainer /> );
mockObsEditProviderWithObs( [mockObservation] );
renderObsDetails( );
const obsDetails = await screen.findByTestId(
`ObsDetails.${mockObservation.uuid}`
);
@@ -165,7 +193,8 @@ describe( "ObsDetails", () => {
it( "renders obs details from remote call", async () => {
useIsConnected.mockImplementation( () => true );
renderComponent( <ObsDetailsContainer /> );
mockObsEditProviderWithObs( [mockObservation] );
renderObsDetails( );
expect(
await screen.findByTestId( `ObsDetails.${mockObservation.uuid}` )
@@ -174,7 +203,8 @@ describe( "ObsDetails", () => {
} );
it( "renders data tab on button press", async () => {
renderComponent( <ObsDetailsContainer /> );
mockObsEditProviderWithObs( [mockObservation] );
renderObsDetails( );
const button = await screen.findByTestId( "ObsDetails.DetailsTab" );
expect( screen.queryByTestId( "mock-data-tab" ) ).not.toBeTruthy();
@@ -191,7 +221,8 @@ describe( "ObsDetails", () => {
it( "should render fallback image icon instead of photos", async () => {
useIsConnected.mockImplementation( () => true );
renderComponent( <ObsDetailsContainer /> );
mockObsEditProviderWithObs( [mockObservation] );
renderObsDetails( );
const labelText = t( "Observation-has-no-photos-and-no-sounds" );
const fallbackImage = await screen.findByLabelText( labelText );
@@ -207,7 +238,8 @@ describe( "ObsDetails", () => {
describe( "activity tab", () => {
it( "navigates to taxon details on button press", async () => {
renderComponent( <ObsDetailsContainer /> );
mockObsEditProviderWithObs( [mockObservation] );
renderObsDetails( );
fireEvent.press(
await screen.findByTestId(
`ObsDetails.taxon.${mockObservation.taxon.id}`
@@ -220,7 +252,8 @@ describe( "ObsDetails", () => {
it( "shows network error image instead of observation photos if user is offline", async () => {
useIsConnected.mockImplementation( () => false );
renderComponent( <ObsDetailsContainer /> );
mockObsEditProviderWithObs( [mockObservation] );
renderObsDetails( );
const labelText = t( "Observation-photos-unavailable-without-internet" );
const noInternet = await screen.findByLabelText( labelText );
expect( noInternet ).toBeTruthy();

View File

@@ -1,6 +1,9 @@
import { faker } from "@faker-js/faker";
import { screen } from "@testing-library/react-native";
import EvidenceList from "components/ObsEdit/EvidenceList";
import { ObsEditContext } from "providers/contexts";
import INatPaperProvider from "providers/INatPaperProvider";
import ObsEditProvider from "providers/ObsEditProvider";
import React from "react";
import factory from "../../../factory";
@@ -21,45 +24,52 @@ const observationPhotos = [
} )
];
jest.mock( "providers/ObsEditProvider" );
const mockObsEditProvider = ( savingPhoto = false ) => ObsEditProvider
.mockImplementation( ( { children } ) => (
// eslint-disable-next-line react/jsx-no-constructed-context-values
<INatPaperProvider>
<ObsEditContext.Provider value={{
savingPhoto
}}
>
{children}
</ObsEditContext.Provider>
</INatPaperProvider>
) );
const renderEvidenceList = evidenceList => renderComponent(
<ObsEditProvider>
<EvidenceList evidenceList={evidenceList} />
</ObsEditProvider>
);
describe( "EvidenceList", ( ) => {
it( "should display add evidence button", ( ) => {
renderComponent(
<EvidenceList
evidenceList={observationPhotos}
/>
);
mockObsEditProvider( );
renderEvidenceList( observationPhotos );
expect( screen.getByTestId( "EvidenceList.add" ) ).toBeVisible( );
} );
it( "should display loading wheel if photo is saving", ( ) => {
renderComponent(
<EvidenceList
evidenceList={observationPhotos}
savingPhoto
/>
);
mockObsEditProvider( true );
renderEvidenceList( observationPhotos );
expect( screen.getByTestId( "EvidenceList.saving" ) ).toBeVisible( );
} );
it( "should render all observation photos", ( ) => {
renderComponent(
<EvidenceList
evidenceList={observationPhotos}
/>
);
mockObsEditProvider( );
renderEvidenceList( observationPhotos );
expect( screen.getByTestId( `EvidenceList.${observationPhotos[0].photo.url}` ) ).toBeVisible( );
expect( screen.getByTestId( `EvidenceList.${observationPhotos[1].photo.url}` ) ).toBeVisible( );
} );
it( "should display an empty list when observation has no observation photos", ( ) => {
renderComponent(
<EvidenceList
evidenceList={[]}
/>
);
mockObsEditProvider( );
renderEvidenceList( [] );
expect( screen.getByTestId( "EvidenceList.add" ) ).toBeVisible( );
expect( screen.queryByTestId( "ObsEdit.photo" ) ).toBeFalsy( );
} );

View File

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

View File

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

View File

@@ -23,6 +23,12 @@ exports[`ObsGridItem for an observation with a photo should render 1`] = `
{
"height": 62,
},
{
"borderBottomLeftRadius": 15,
"borderBottomRightRadius": 15,
"borderTopLeftRadius": 15,
"borderTopRightRadius": 15,
},
{
"width": "100%",
},
@@ -274,6 +280,12 @@ exports[`ObsGridItem for an observation without a photo should render 1`] = `
{
"height": 62,
},
{
"borderBottomLeftRadius": 15,
"borderBottomRightRadius": 15,
"borderTopLeftRadius": 15,
"borderTopRightRadius": 15,
},
{
"width": "100%",
},
@@ -310,6 +322,12 @@ exports[`ObsGridItem for an observation without a photo should render 1`] = `
{
"height": 62,
},
{
"borderBottomLeftRadius": 15,
"borderBottomRightRadius": 15,
"borderTopLeftRadius": 15,
"borderTopRightRadius": 15,
},
{
"width": "100%",
},
@@ -349,7 +367,7 @@ exports[`ObsGridItem for an observation without a photo should render 1`] = `
]
}
>
</Text>
</View>
<BVLinearGradient
@@ -436,7 +454,7 @@ exports[`ObsGridItem for an observation without a photo should render 1`] = `
]
}
>
</Text>
</View>
<View

View File

@@ -15,17 +15,15 @@ exports[`TaxonResult should render correctly 1`] = `
"justifyContent": "space-between",
},
{
"paddingLeft": 12,
"paddingLeft": 16,
"paddingRight": 16,
},
{
"paddingBottom": 4,
"paddingTop": 4,
"paddingBottom": 12,
"paddingTop": 12,
},
{
"borderBottomWidth": 0.5,
"borderLeftWidth": 0.5,
"borderRightWidth": 0.5,
"borderTopWidth": 0.5,
"borderBottomWidth": 1,
},
{
"borderBottomColor": "#E8E8E8",
@@ -110,9 +108,23 @@ exports[`TaxonResult should render correctly 1`] = `
{
"height": 62,
},
{
"borderBottomLeftRadius": 8,
"borderBottomRightRadius": 8,
"borderTopLeftRadius": 8,
"borderTopRightRadius": 8,
},
{
"width": 62,
},
[
{
"borderBottomLeftRadius": 12,
"borderBottomRightRadius": 12,
"borderTopLeftRadius": 12,
"borderTopRightRadius": 12,
},
],
],
]
}
@@ -146,6 +158,12 @@ exports[`TaxonResult should render correctly 1`] = `
{
"height": 62,
},
{
"borderBottomLeftRadius": 8,
"borderBottomRightRadius": 8,
"borderTopLeftRadius": 8,
"borderTopRightRadius": 8,
},
{
"width": 62,
},
@@ -191,7 +209,7 @@ exports[`TaxonResult should render correctly 1`] = `
]
}
>
</Text>
</View>
<View
@@ -388,7 +406,7 @@ exports[`TaxonResult should render correctly 1`] = `
]
}
>
</Text>
</View>
</View>
@@ -438,11 +456,11 @@ exports[`TaxonResult should render correctly 1`] = `
[
{
"marginLeft": 8,
"marginRight": 8,
},
],
]
}
testID="undefined.checkmark"
>
<View
style={

View File

@@ -0,0 +1,48 @@
import { faker } from "@faker-js/faker";
import { screen, waitFor } from "@testing-library/react-native";
import Attribution from "components/Suggestions/Attribution";
import initI18next from "i18n/initI18next";
import inatjs from "inaturalistjs";
import React from "react";
import factory, { makeResponse } from "../../../factory";
import { renderComponent } from "../../../helpers/render";
// Mock api call to observations
jest.mock( "inaturalistjs" );
const mockUsers = [
factory( "RemoteObservation", {
user: {
login: faker.name.fullName( )
}
} ),
factory( "RemoteObservation", {
user: {
login: faker.name.fullName( )
}
} )
];
const renderAttribution = ( ) => renderComponent(
<Attribution
taxonIds={[23456, 35235, 64672]}
/>
);
describe( "Attribution", ( ) => {
beforeAll( async ( ) => {
await initI18next( );
} );
it( "should show attributions", async ( ) => {
expect( inatjs.observations.observers ).not.toHaveBeenCalled( );
renderAttribution( );
expect( screen.getByTestId( "Attribution.empty" ) ).toBeVisible( );
await waitFor( ( ) => {
inatjs.observations.observers.mockResolvedValue( makeResponse( mockUsers ) );
expect( inatjs.observations.observers ).toHaveBeenCalled( );
} );
} );
} );

View File

@@ -0,0 +1,110 @@
import { faker } from "@faker-js/faker";
import {
screen, waitFor
} from "@testing-library/react-native";
import SuggestionsContainer from "components/Suggestions/SuggestionsContainer";
import initI18next from "i18n/initI18next";
import i18next from "i18next";
import inatjs from "inaturalistjs";
import { ObsEditContext } from "providers/contexts";
import React from "react";
import factory, { makeResponse } from "../../../factory";
import { renderComponent } from "../../../helpers/render";
const mockTaxon = factory( "RemoteTaxon", {
name: faker.name.firstName( ),
preferred_common_name: faker.name.fullName( ),
default_photo: {
square_url: faker.image.imageUrl( )
}
} );
const mockSuggestionsList = [{
combined_score: 90.34,
taxon: {
...mockTaxon,
id: faker.datatype.number( )
}
}, {
combined_score: 30.32,
taxon: {
...mockTaxon,
id: faker.datatype.number( )
}
}];
// Mock api call to observations
jest.mock( "inaturalistjs" );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
...actualNav,
useNavigation: ( ) => ( {
addListener: jest.fn( )
} )
};
} );
jest.mock( "sharedHooks/useLocalObservation" );
const mockUris = [
faker.image.imageUrl( ),
`${faker.image.imageUrl( )}/400`
];
const mockCreateId = jest.fn( );
const renderSuggestions = ( loading = false, comment = "" ) => renderComponent(
<ObsEditContext.Provider value={{
updateObservationKeys: jest.fn( ),
photoEvidenceUris: mockUris,
createId: mockCreateId,
loading,
comment,
currentObservation: {
uuid: faker.datatype.uuid( )
}
}}
>
<SuggestionsContainer />
</ObsEditContext.Provider>
);
describe( "Suggestions", ( ) => {
beforeAll( async ( ) => {
await initI18next( );
} );
test( "should not have accessibility errors", async ( ) => {
renderSuggestions( );
const suggestions = await screen.findByTestId( "suggestions" );
expect( suggestions ).toBeAccessible( );
} );
it( "should show loading wheel while id being created", async ( ) => {
renderSuggestions( true );
await waitFor( ( ) => {
expect( screen.queryByTestId( "Suggestions.ActivityIndicator" ) ).toBeVisible( );
} );
} );
it( "shows comment section if observation has comment", ( ) => {
renderSuggestions( false, "Comment added to observation" );
const commentSection = screen.getByText(
i18next.t( "Your-identification-will-be-posted-with-the-following-comment" )
);
expect( commentSection ).toBeVisible( );
} );
it( "should fetch nearby suggestions for current photo", async ( ) => {
renderSuggestions( );
await waitFor( ( ) => {
inatjs.observations.observers.mockResolvedValue( makeResponse( [] ) );
inatjs.computervision.score_image.mockResolvedValue( makeResponse( mockSuggestionsList ) );
expect( inatjs.computervision.score_image ).toHaveBeenCalledTimes( 1 );
} );
} );
} );

View File

@@ -0,0 +1,93 @@
import { faker } from "@faker-js/faker";
import { fireEvent, screen } from "@testing-library/react-native";
import SuggestionsList from "components/Suggestions/SuggestionsList";
import initI18next from "i18n/initI18next";
import i18next from "i18next";
import React from "react";
import factory from "../../../factory";
import { renderComponent } from "../../../helpers/render";
const mockTaxon = factory( "RemoteTaxon", {
name: faker.name.firstName( ),
preferred_common_name: faker.name.fullName( ),
default_photo: {
square_url: faker.image.imageUrl( )
}
} );
const mockSuggestionsList = [{
combined_score: 90.34,
taxon: {
...mockTaxon,
id: faker.datatype.number( )
}
}, {
combined_score: 30.32,
taxon: {
...mockTaxon,
id: faker.datatype.number( )
}
}];
const mockTaxonSelection = jest.fn( );
const renderSuggestionsList = ( ) => renderComponent(
<SuggestionsList
nearbySuggestions={mockSuggestionsList}
onTaxonChosen={mockTaxonSelection}
/>
);
describe( "SuggestionsList", ( ) => {
beforeAll( async ( ) => {
await initI18next( );
} );
it( "should render a top suggestion", ( ) => {
renderSuggestionsList( );
const topSuggestion = screen.getByTestId(
`SuggestionsList.taxa.${mockSuggestionsList[0].taxon.id}`
);
expect( topSuggestion ).toBeVisible( );
} );
it( "should render other suggestions", ( ) => {
renderSuggestionsList( );
const secondSuggestion = screen.getByTestId(
`SuggestionsList.taxa.${mockSuggestionsList[1].taxon.id}`
);
expect( secondSuggestion ).toBeVisible( );
} );
it( "should display empty text if no suggestions are found", ( ) => {
renderComponent(
<SuggestionsList
nearbySuggestions={[]}
/>
);
const emptyText = i18next
.t( "iNaturalist-isnt-able-to-provide-a-top-ID-suggestion-for-this-photo" );
expect( screen.getByText( emptyText ) ).toBeVisible( );
} );
it( "should display a loading wheel if suggestions are loading", ( ) => {
renderComponent(
<SuggestionsList
nearbySuggestions={[]}
loading
/>
);
const loading = screen.getByTestId( "SuggestionsList.loading" );
expect( loading ).toBeVisible( );
} );
it( "should create an id when checkmark is pressed", async ( ) => {
renderSuggestionsList( );
const testID = `SuggestionsList.taxa.${mockSuggestionsList[0].taxon.id}`;
const checkmark = screen.getByTestId( `${testID}.checkmark` );
expect( checkmark ).toBeVisible( );
fireEvent.press( checkmark );
expect( mockTaxonSelection ).toHaveBeenCalled( );
} );
} );

View File

@@ -1,9 +1,9 @@
import { faker } from "@faker-js/faker";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { fireEvent, screen } from "@testing-library/react-native";
import AddIDContainer from "components/AddID/AddIDContainer";
import TaxonSearch from "components/Suggestions/TaxonSearch";
import initI18next from "i18n/initI18next";
import { t } from "i18next";
import i18next from "i18next";
import inatjs from "inaturalistjs";
import { ObsEditContext } from "providers/contexts";
import INatPaperProvider from "providers/INatPaperProvider";
@@ -15,14 +15,6 @@ import { renderComponent } from "../../../helpers/render";
jest.mock( "inaturalistjs" );
jest.mock( "providers/ObsEditProvider" );
const mockMutate = jest.fn();
jest.mock( "sharedHooks/useAuthenticatedMutation", ( ) => ( {
__esModule: true,
default: ( ) => ( {
mutate: mockMutate
} )
} ) );
jest.mock(
"components/SharedComponents/ViewWrapper",
() => function MockViewWrapper( props ) {
@@ -38,23 +30,10 @@ jest.mock(
const mockTaxon = factory( "RemoteTaxon", {
name: faker.name.firstName( ),
// rank: "genus",
// rank_level: 27,
preferred_common_name: faker.name.fullName( ),
default_photo: {
square_url: faker.image.imageUrl( )
}
// ancestors: [{
// id: faker.datatype.number( ),
// preferred_common_name: faker.name.fullName( ),
// name: faker.name.fullName( ),
// rank: "class"
// }],
// wikipedia_summary: faker.lorem.paragraph( ),
// taxonPhotos: [{
// photo: factory( "RemotePhoto" )
// }],
// wikipedia_url: faker.internet.url( )
} );
const mockTaxaList = [
@@ -107,68 +86,60 @@ jest.mock( "react-native-paper", () => {
return MockedModule;
} );
const mockRoute = {
params: {
observationUUID: faker.datatype.uuid( ),
createRemoteIdentification: true
}
};
const mockCreateId = jest.fn( );
const renderAddId = ( ) => renderComponent(
const renderTaxonSearch = ( loading = false, comment = "" ) => renderComponent(
<ObsEditContext.Provider value={{
updateObservationKeys: jest.fn( )
updateObservationKeys: jest.fn( ),
createId: mockCreateId,
loading,
comment
}}
>
<AddIDContainer route={mockRoute} />
<TaxonSearch />
</ObsEditContext.Provider>
);
describe( "AddID", ( ) => {
describe( "TaxonSearch", ( ) => {
beforeAll( async ( ) => {
await initI18next( );
} );
test( "should not have accessibility errors", () => {
const addID = (
const suggestions = (
<BottomSheetModalProvider>
<INatPaperProvider>
<ObsEditContext.Provider value={{
updateObservationKeys: jest.fn( )
}}
>
<AddIDContainer route={mockRoute} />
<TaxonSearch />
</ObsEditContext.Provider>
</INatPaperProvider>
</BottomSheetModalProvider>
);
expect( addID ).toBeAccessible( );
expect( suggestions ).toBeAccessible( );
} );
it( "should render inside mocked container", ( ) => {
renderAddId( );
renderTaxonSearch( );
expect( screen.getByTestId( "mock-view-no-footer" ) ).toBeTruthy();
} );
it( "show taxon search results", async ( ) => {
inatjs.search.mockResolvedValue( makeResponse( mockTaxaList ) );
renderAddId( );
renderTaxonSearch( );
const input = screen.getByTestId( "SearchTaxon" );
const taxon = mockTaxaList[0];
fireEvent.changeText( input, "Some taxon" );
expect( await screen.findByTestId( `Search.taxa.${taxon.id}` ) ).toBeTruthy();
} );
it( "shows loading indicator when taxon is selected", async () => {
renderAddId( );
const input = screen.getByTestId( "SearchTaxon" );
const taxon = mockTaxaList[0];
fireEvent.changeText( input, "Some taxon" );
expect( await screen.findByTestId( `Search.taxa.${taxon.id}` ) ).toBeTruthy();
const labelText = t( "Checkmark" );
const chooseButton = ( await screen.findAllByLabelText( labelText ) )[0];
fireEvent.press( chooseButton );
await screen.findByTestId( "AddID.ActivityIndicator" );
it( "shows comment section if observation has comment", ( ) => {
renderTaxonSearch( false, "Comment added to observation in TaxonSearch" );
const commentSection = screen.getByText(
i18next.t( "Your-identification-will-be-posted-with-the-following-comment" )
);
expect( commentSection ).toBeVisible( );
} );
} );