MOB-722 merge main

This commit is contained in:
sepeterson
2025-12-12 11:19:43 -06:00
24 changed files with 935 additions and 133 deletions

View File

@@ -20,7 +20,8 @@ module.exports = {
"react-native",
"simple-import-sort",
"@tanstack/query",
"@typescript-eslint"
"@typescript-eslint",
"@stylistic"
],
rules: {
"arrow-parens": [2, "as-needed"],

208
.github/workflows/e2e_android.new.yml vendored Normal file
View File

@@ -0,0 +1,208 @@
name: e2e-Android
permissions:
contents: read
on:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
# 4-core Ubunutu GitHub Larger Runner
# https://docs.github.com/en/enterprise-cloud@latest/billing/reference/actions-runner-pricing#x64-powered-larger-runners
runs-on: ubuntu-24.04-m
steps:
- name: Check out Git repository
uses: actions/checkout@v4
# Generate the secret files needed for a release build
- name: Create .env file
env:
OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }}
OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}
E2E_TEST_USERNAME: ${{ secrets.E2E_TEST_USERNAME }}
E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}
JWT_ANONYMOUS_API_SECRET: ${{ secrets.JWT_ANONYMOUS_API_SECRET }}
GMAPS_API_KEY: ${{ secrets.GMAPS_API_KEY }}
run: printf 'API_URL=https://stagingapi.inaturalist.org/v2\nOAUTH_API_URL=https://staging.inaturalist.org\nJWT_ANONYMOUS_API_SECRET=%s\nOAUTH_CLIENT_ID=%s\nOAUTH_CLIENT_SECRET=%s\nE2E_TEST_USERNAME=%s\nE2E_TEST_PASSWORD=%s\nGMAPS_API_KEY=%s\nANDROID_MODEL_FILE_NAME=INatVision_Small_2_fact256_8bit.tflite\nANDROID_TAXONOMY_FILE_NAME=taxonomy.csv\nANDROID_GEOMODEL_FILE_NAME=INatGeomodel_Small_2_8bit.tflite\nIOS_MODEL_FILE_NAME=INatVision_Small_2_fact256_8bit.mlmodel\nIOS_TAXONOMY_FILE_NAME=taxonomy.json\nIOS_GEOMODEL_FILE_NAME=INatGeomodel_Small_2_8bit.mlmodel\nCV_MODEL_VERSION=small_2\n' "$JWT_ANONYMOUS_API_SECRET" "$OAUTH_CLIENT_ID" "$OAUTH_CLIENT_SECRET" "$E2E_TEST_USERNAME" "$E2E_TEST_PASSWORD" "$GMAPS_API_KEY" > .env
- name: Add secrets to google-services.json
env:
FIREBASE_PROJECT_NUMBER: ${{ secrets.FIREBASE_PROJECT_NUMBER }}
FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
FIREBASE_STORAGE_BUCKET: ${{ secrets.FIREBASE_STORAGE_BUCKET }}
FIREBASE_STAGING_GOOGLE_APP_ID: ${{ secrets.FIREBASE_STAGING_GOOGLE_APP_ID }}
FIREBASE_STAGING_PACKAGE_NAME: ${{ secrets.FIREBASE_STAGING_PACKAGE_NAME }}
FIREBASE_STAGING_API_KEY: ${{ secrets.FIREBASE_STAGING_API_KEY }}
run: |
cp android/app/google-services.example.json android/app/google-services.json
sed -i "s/your_project_number/${FIREBASE_PROJECT_NUMBER//\//\\/}/g" "android/app/google-services.json"
sed -i "s/your_project_id/${FIREBASE_PROJECT_ID//\//\\/}/g" "android/app/google-services.json"
sed -i "s/your_storage_bucket/${FIREBASE_STORAGE_BUCKET//\//\\/}/g" "android/app/google-services.json"
sed -i "s/your_mobilesdk_app_id/${FIREBASE_STAGING_GOOGLE_APP_ID//\//\\/}/g" "android/app/google-services.json"
sed -i "s/your_package_name/${FIREBASE_STAGING_PACKAGE_NAME//\//\\/}/g" "android/app/google-services.json"
sed -i "s/your_current_key/${FIREBASE_STAGING_API_KEY//\//\\/}/g" "android/app/google-services.json"
- name: Create keystore.properties file
env:
ANDROID_KEY_STORE_PASSWORD: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_ALIAS: ${{ secrets.ANDROID_ALIAS }}
run: printf 'storePassword=%s\nkeyPassword=%s\nkeyAlias=%s\nstoreFile=release.keystore' "$ANDROID_KEY_STORE_PASSWORD" "$ANDROID_KEY_PASSWORD" "$ANDROID_ALIAS" > android/keystore.properties
- name: Generate release keystore
env:
ANDROID_ALIAS: ${{ secrets.ANDROID_ALIAS }}
ANDROID_KEY_STORE_PASSWORD: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
keytool -genkeypair -v -noprompt -storetype PKCS12 -keystore release.keystore -alias "$ANDROID_ALIAS" -keyalg RSA -keysize 2048 -validity 10000 -storepass "$ANDROID_KEY_STORE_PASSWORD" -keypass "$ANDROID_KEY_PASSWORD" -dname "CN=mqttserver.ibm.com, OU=ID, O=IBM, L=Hursley, S=Hants, C=GB"
- name: Move keystore
run: mv release.keystore android/app/release.keystore
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'npm'
- uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
gradle-version: '8.14.1'
- name: Install JS dependencies
run: |
npm install
- name: Download the small example cv and geomodel
run: |
npm run add-example-model -- -f=main
- name: Android Build
run: |
cd android
# for the CI build, we can just target the x86 build for the emulator
# also, we can exclude linting tasks
./gradlew build -PreactNativeArchitectures=x86 -x lint -x lintVitalRelease
- name: Upload APK
uses: actions/upload-artifact@v5
with:
name: release-apk
# note: clarifying because this is different from iOS:
# {myBuild}.apk is a single file so `release-apk` points to a single file
# in contrast, iOS's .ipa is actually a _directory_
path: android/app/build/outputs/apk/release/*-release.apk
test:
# 4-core Ubunutu GitHub Larger Runner
# https://docs.github.com/en/enterprise-cloud@latest/billing/reference/actions-runner-pricing#x64-powered-larger-runners
# `cores` setting for emulator actions should be updated to reflect changes to this
runs-on: ubuntu-24.04-m
needs: build
steps:
# note: this step requires checkout for the test flows, but does _not_ require npm install w/ Maestro
- uses: actions/checkout@v2
- name: Download APK
uses: actions/download-artifact@v6
with:
name: release-apk
# resulting file structure: $GITHUB_HOME/e2e-build/{myBuild}.apk
path: e2e-build
- name: Install Maestro
run: |
export MAESTRO_VERSION="2.0.8"; curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
# These KVM settings are in support of the android-emulator-runner action that takes advantage of them
# Further reading:
# - https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/
# - https://github.com/reactivecircus/android-emulator-runner/tree/v2/?tab=readme-ov-file#github-action---android-emulator-runner
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# AVD Cache: we can reuse a previously-created AVD to save time and make the test-run start step more dependable
# both android-emulator-runner steps should have the same image config for cores, api-level, target
- name: Attempt to restore AVD cache
uses: actions/cache/restore@v4
id: avd-cache-restore
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-29
- name: Create AVD and generate snapshot for caching
if: steps.avd-cache-restore.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
cores: 4
api-level: 29
target: google_apis
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "Generated AVD snapshot for caching."
- name: Save AVD cache
if: steps.avd-cache-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-29
- name: run tests
uses: reactivecircus/android-emulator-runner@v2
env:
MAESTRO_DRIVER_STARTUP_TIMEOUT: 60000
MAESTRO_CLI_NO_ANALYTICS: 1
# custom ENV var to drive recordings within test flows
# see README for details
MAESTRO_RECORD: true
with:
cores: 4
api-level: 29
target: google_apis
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: |
adb install -r e2e-build/*.apk
# single exemplary "smoketest" flow for now
maestro test --no-ansi e2e/maestro/android/signedOut.yaml
- name: Upload test video
uses: actions/upload-artifact@v4
if: ${{ success() || failure() }}
with:
# single exemplary flow artifact for now, generated by MAESTRO_RECORD configuration
path: signedOut.mp4
name: Android
notify:
name: Notify Slack
needs: test
if: ${{ success() || failure() }}
runs-on: macos-latest
steps:
- uses: iRoachie/slack-github-actions@v2.3.0
if: env.SLACK_WEBHOOK_URL != null
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_BUILDS_WEBHOOK_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: e2e-Android
name: e2e-Android-old
on:
workflow_dispatch:

View File

@@ -72,6 +72,10 @@ Note that you can run `npx jest` as well, but that will omit some environment va
Also note that `i18next` needs to be initialized in individual test files (haven't figured out a way to await initialization before *all* tests, plus allowing tests to control initialization helps when testing different locales). Add `beforeAll( async ( ) => { await initI18next( ); } );` to a test file if it depends on localized text.
### E2E tests
> [!NOTE]
> Status of e2e tests in CI: internal / staff PRs run iOS Detox tests as a PR check. Android Detox tests are currently disabled. We are currently working to restore Android e2e tests. For now, we have a skeleton Android flow running a single smoketest flow for simplicity. This is not a PR check yet and can only be run manually (`workflow_dispatch` GH Actions trigger).
We're using [Detox](https://wix.github.io/Detox/docs/introduction/getting-started/) for E2E tests. If you want to run the e2e tests on your local machine, make sure you follow the Detox environment setup instructions.
Then you have to populate `E2E_TEST_USERNAME` and `E2E_TEST_PASSWORD` in `.env` with real iNaturalist login credentials so the e2e test can actually authenticate.
@@ -93,6 +97,13 @@ If you want to run the Android tests you need to prepare your environment. Befor
Run `npm run e2e:build:android && npm run e2e:test:android` to build the APK for testing purposes and install and run it on the emulator with the name as stated in the `.detoxrc.js` file.
#### Maestro
> [!NOTE]
> By the Maestro's team own disclosure, the `maestro record` command for generating a test run recording is unusably slow. As a workaround, we use the inline `startRecording` Maestro directive within tests, which is more performant. For convenience, this is defined in an `onFlowStart.yaml` intented to be referred to in each flow's `onFlowStart` lifecycle hook.
Some desired test flows cannot be acheived with Detox and for those, we use Maestro. These are tests that rely on behavior external to the application such as deeplinks and sharing photos into the app. These are not currently run in CI as a PR check. The in-progress Android CI uses a Maestro smoketest flow as an exemplary command while we work on restoring Android Detox tests.
## Translations
### Adding and changing new source strings

View File

@@ -107,7 +107,7 @@ android {
applicationId "org.inaturalist.iNaturalistMobile"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 188
versionCode 189
versionName "1.0.12"
setProperty("archivesBaseName", applicationId + "-v" + versionName + "+" + versionCode)
manifestPlaceholders = [ GMAPS_API_KEY:project.env.get("GMAPS_API_KEY") ]

View File

@@ -0,0 +1,7 @@
appId: org.inaturalist.iNaturalistMobile
---
- runFlow:
when:
true: ${MAESTRO_RECORD == 'true'}
commands:
- startRecording: ${MAESTRO_FILENAME}

View File

@@ -0,0 +1,22 @@
appId: org.inaturalist.iNaturalistMobile
# FLGMwt: I would prefer to be able to configure setup/teardown
# in workspace config, but this is not supported
# https://github.com/mobile-dev-inc/Maestro/issues/2063
onFlowStart:
- runFlow: onFlowStart.yaml
onFlowComplete:
# noop if not recording
- stopRecording
---
- launchApp:
appId: org.inaturalist.iNaturalistMobile
clearState: true
- extendedWaitUntil:
visible: "Close"
timeout: 30000
- waitForAnimationToEnd
- tapOn: Close
- assertVisible: Use iNaturalist to identify any living thing

View File

@@ -0,0 +1,5 @@
NEW
* Increased analytics to figure out why "app is slow"
UPDATED
* Saved observation screen in not-advanced mode

View File

@@ -548,7 +548,7 @@
CODE_SIGN_ENTITLEMENTS = iNaturalistReactNative/iNaturalistReactNative.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CURRENT_PROJECT_VERSION = 188;
CURRENT_PROJECT_VERSION = 189;
DEVELOPMENT_TEAM = N5J7L4P93Z;
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
@@ -675,7 +675,7 @@
CODE_SIGN_ENTITLEMENTS = iNaturalistReactNative/iNaturalistReactNativeRelease.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CURRENT_PROJECT_VERSION = 188;
CURRENT_PROJECT_VERSION = 189;
DEVELOPMENT_TEAM = N5J7L4P93Z;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
HEADER_SEARCH_PATHS = (
@@ -988,7 +988,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "iNaturalistReactNative-ShareExtension/iNaturalistReactNative-ShareExtension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 188;
CURRENT_PROJECT_VERSION = 189;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = N5J7L4P93Z;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64";
@@ -1033,7 +1033,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 188;
CURRENT_PROJECT_VERSION = 189;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = N5J7L4P93Z;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64";

View File

@@ -40,7 +40,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>188</string>
<string>189</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>

306
package-lock.json generated
View File

@@ -122,6 +122,7 @@
"@react-native/eslint-config": "0.80.2",
"@react-native/metro-config": "0.80.2",
"@react-native/typescript-config": "0.80.2",
"@stylistic/eslint-plugin": "^3.1.0",
"@tanstack/eslint-plugin-query": "^5.28.11",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
@@ -6614,6 +6615,263 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@stylistic/eslint-plugin": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz",
"integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/utils": "^8.13.0",
"eslint-visitor-keys": "^4.2.0",
"espree": "^10.3.0",
"estraverse": "^5.3.0",
"picomatch": "^4.0.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
"eslint": ">=8.40.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/project-service": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz",
"integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.48.1",
"@typescript-eslint/types": "^8.48.1",
"debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz",
"integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.48.1",
"@typescript-eslint/visitor-keys": "8.48.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz",
"integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz",
"integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz",
"integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.48.1",
"@typescript-eslint/tsconfig-utils": "8.48.1",
"@typescript-eslint/types": "8.48.1",
"@typescript-eslint/visitor-keys": "8.48.1",
"debug": "^4.3.4",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/utils": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz",
"integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
"@typescript-eslint/typescript-estree": "8.48.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz",
"integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.48.1",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@stylistic/eslint-plugin/node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
@@ -22477,6 +22735,54 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",

View File

@@ -158,6 +158,7 @@
"@react-native/eslint-config": "0.80.2",
"@react-native/metro-config": "0.80.2",
"@react-native/typescript-config": "0.80.2",
"@stylistic/eslint-plugin": "^3.1.0",
"@tanstack/eslint-plugin-query": "^5.28.11",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",

View File

@@ -91,6 +91,16 @@ const AddObsBottomSheet = ( {
t
] );
const optionRows = AI_CAMERA_SUPPORTED
? [
[obsCreateItems.standardCamera, obsCreateItems.photoLibrary],
[obsCreateItems.soundRecorder, obsCreateItems.aiCamera]
]
: [
[obsCreateItems.standardCamera],
[obsCreateItems.soundRecorder, obsCreateItems.photoLibrary]
];
const renderAddObsIcon = ( {
accessibilityHint,
accessibilityLabel,
@@ -133,15 +143,11 @@ const AddObsBottomSheet = ( {
>
<View className="flex-column gap-y-4 pb-4 px-4">
<View className={ROW_CLASS}>
{renderAddObsIcon( obsCreateItems.standardCamera )}
{renderAddObsIcon( obsCreateItems.photoLibrary )}
</View>
<View className={ROW_CLASS}>
{renderAddObsIcon( obsCreateItems.soundRecorder )}
{AI_CAMERA_SUPPORTED && renderAddObsIcon( obsCreateItems.aiCamera )}
</View>
{optionRows.map( row => (
<View key={row.map( i => i.testID ).join( "-" )} className={ROW_CLASS}>
{row.map( item => renderAddObsIcon( item ) )}
</View>
) )}
<Pressable
className="bg-mediumGray w-full flex-row items-center py-[10px] px-5 rounded-lg

View File

@@ -13,15 +13,29 @@ import {
Modal,
Portal
} from "react-native-paper";
import type { CameraDeviceFormat } from "react-native-vision-camera";
import { useDebugMode } from "sharedHooks";
import colors from "styles/tailwindColors";
import SliderControl from "./SliderControl";
interface Props {
debugFormat: CameraDeviceFormat | null;
changeDebugFormat: () => void;
confidenceThreshold: number;
setConfidenceThreshold: ( value: number ) => void;
fps: number;
setFPS: ( value: number ) => void;
numStoredResults: number;
setNumStoredResults: ( value: number ) => void;
cropRatio: number;
setCropRatio: ( value: number ) => void;
}
const AIDebugButton = ( {
debugFormat,
changeDebugFormat,
confidenceThreshold,
debugFormat,
setConfidenceThreshold,
fps,
setFPS,
@@ -29,7 +43,7 @@ const AIDebugButton = ( {
setNumStoredResults,
cropRatio,
setCropRatio
} ) => {
}: Props ) => {
const [modalVisible, setModalVisible] = useState( false );
const [slideIndex, setSlideIndex] = useState( 0 );
const { isDebug } = useDebugMode( );

View File

@@ -12,11 +12,12 @@ import {
} from "providers/ExploreContext";
import type { Node } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useCurrentUser } from "sharedHooks";
import { useCurrentUser, useDebugMode } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission";
import useStore from "stores/useStore";
import Explore from "./Explore";
import ExploreV2 from "./ExploreV2";
import mapParamsToAPI from "./helpers/mapParamsToAPI";
import useExploreHeaderCount from "./hooks/useExploreHeaderCount";
import useParams from "./hooks/useParams";
@@ -24,6 +25,8 @@ import useParams from "./hooks/useParams";
const ExploreContainerWithContext = ( ): Node => {
const navigation = useNavigation( );
const { isConnected } = useNetInfo( );
const { isDebug } = useDebugMode();
const exploreView = useStore( state => state.exploreView );
const setExploreView = useStore( state => state.setExploreView );
@@ -133,32 +136,48 @@ const ExploreContainerWithContext = ( ): Node => {
return (
<>
<Explore
canFetch={canFetch}
closeFiltersModal={closeFiltersModal}
count={count}
currentExploreView={exploreView}
setCurrentExploreView={setExploreView}
handleUpdateCount={handleUpdateCount}
hideBackButton={false}
filterByIconicTaxonUnknown={
() => dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
}
isConnected={isConnected}
isFetchingHeaderCount={isFetchingHeaderCount}
openFiltersModal={openFiltersModal}
queryParams={queryParams}
showFiltersModal={showFiltersModal}
updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
placeMode={state.placeMode}
hasLocationPermissions={hasLocationPermissions}
renderLocationPermissionsGate={renderPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
startFetching={startFetching}
/>
{!isDebug
? (
<Explore
canFetch={canFetch}
closeFiltersModal={closeFiltersModal}
count={count}
currentExploreView={exploreView}
setCurrentExploreView={setExploreView}
handleUpdateCount={handleUpdateCount}
hideBackButton={false}
filterByIconicTaxonUnknown={
() => dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
}
isConnected={isConnected}
isFetchingHeaderCount={isFetchingHeaderCount}
openFiltersModal={openFiltersModal}
queryParams={queryParams}
showFiltersModal={showFiltersModal}
updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
placeMode={state.placeMode}
hasLocationPermissions={hasLocationPermissions}
renderLocationPermissionsGate={renderPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
startFetching={startFetching}
/>
)
: (
<ExploreV2
canFetch={canFetch}
currentExploreView={exploreView}
handleUpdateCount={handleUpdateCount}
hasLocationPermissions={hasLocationPermissions}
isConnected={isConnected}
placeMode={state.placeMode}
queryParams={queryParams}
renderLocationPermissionsGate={renderPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
/>
)}
{renderPermissionsGate( {
onPermissionGranted: startFetching
} ) }

View File

@@ -0,0 +1,193 @@
import { refresh } from "@react-native-community/netinfo";
import classnames from "classnames";
import {
Body2,
Button,
INatIconButton,
OfflineNotice,
ViewWrapper
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import { PLACE_MODE } from "providers/ExploreContext";
import React from "react";
import { Alert } from "react-native";
import {
useDebugMode,
useStoredLayout,
useTranslation
} from "sharedHooks";
import type { RenderLocationPermissionsGateFunction } from "sharedHooks/useLocationPermission";
import { getShadow } from "styles/global";
import IdentifiersView from "./IdentifiersView";
import ObservationsView from "./ObservationsView";
import ObservationsViewBar from "./ObservationsViewBar";
import ObserversView from "./ObserversView";
import SpeciesView from "./SpeciesView";
const DROP_SHADOW = getShadow( {
offsetHeight: 4,
elevation: 6
} );
enum EXPLORE_VIEW {
OBSERVATIONS = "observations",
IDENTIFIERS = "identifiers",
OBSERVERS = "observers",
SPECIES = "species"
}
enum EXPLORE_OBSERVATIONS_LAYOUT {
GRID = "grid",
LIST = "list",
MAP = "map"
}
interface Props {
canFetch?: boolean;
currentExploreView: EXPLORE_VIEW;
handleUpdateCount: ( exploreView: EXPLORE_VIEW, totalResults: number ) => void;
hasLocationPermissions?: boolean;
isConnected: boolean;
placeMode: string;
queryParams: object;
renderLocationPermissionsGate: RenderLocationPermissionsGateFunction;
requestLocationPermissions: () => void;
}
const ExploreV2 = ( {
canFetch,
currentExploreView,
handleUpdateCount,
hasLocationPermissions,
isConnected,
placeMode,
queryParams,
renderLocationPermissionsGate,
requestLocationPermissions
}: Props ) => {
const { t } = useTranslation( );
const { layout, writeLayoutToStorage } = useStoredLayout( "exploreObservationsLayout" ) as {
layout: EXPLORE_OBSERVATIONS_LAYOUT | null;
writeLayoutToStorage: ( newValue: EXPLORE_OBSERVATIONS_LAYOUT ) => void;
};
const { isDebug } = useDebugMode( );
const renderMainContent = ( ) => {
if ( isConnected === false ) {
return (
<OfflineNotice
onPress={() => refresh()}
/>
);
}
// hasLocationPermissions === undefined means we haven't checked for location permissions yet
if ( placeMode === PLACE_MODE.NEARBY && hasLocationPermissions === false ) {
return (
<View className="flex-1 justify-center p-4">
<View className="items-center">
<Body2>{t( "To-view-nearby-organisms-please-enable-location" )}</Body2>
</View>
<Button
className="mt-5"
text={t( "ALLOW-LOCATION-ACCESS" )}
accessibilityHint={t( "Opens-location-permission-prompt" )}
level="focus"
onPress={( ) => requestLocationPermissions()}
/>
</View>
);
}
return (
<View className="flex-1">
{currentExploreView === EXPLORE_VIEW.OBSERVATIONS && (
<ObservationsView
canFetch={canFetch}
layout={layout}
queryParams={queryParams}
handleUpdateCount={handleUpdateCount}
hasLocationPermissions={hasLocationPermissions}
renderLocationPermissionsGate={renderLocationPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
/>
)}
{currentExploreView === EXPLORE_VIEW.SPECIES && (
<SpeciesView
canFetch={canFetch}
isConnected={isConnected}
queryParams={queryParams}
handleUpdateCount={handleUpdateCount}
/>
)}
{currentExploreView === EXPLORE_VIEW.OBSERVERS && (
<ObserversView
canFetch={canFetch}
isConnected={isConnected}
queryParams={queryParams}
handleUpdateCount={handleUpdateCount}
/>
)}
{currentExploreView === EXPLORE_VIEW.IDENTIFIERS && (
<IdentifiersView
canFetch={canFetch}
isConnected={isConnected}
queryParams={queryParams}
handleUpdateCount={handleUpdateCount}
/>
)}
</View>
);
};
return (
<>
<ViewWrapper testID="ExploreV2" wrapperClassName="overflow-hidden">
<View className="flex-1 overflow-hidden">
{currentExploreView === "observations" && (
<ObservationsViewBar
layout={layout}
updateObservationsView={writeLayoutToStorage}
/>
)}
{renderMainContent()}
{isDebug && (
<INatIconButton
icon="triangle-exclamation"
className={classnames(
"absolute",
"bg-white",
"bottom-[100px]",
"h-[55px]",
"right-5",
"rounded-full",
"w-[55px]",
"z-10"
)}
color="white"
size={27}
style={[
DROP_SHADOW,
// eslint-disable-next-line react-native/no-inline-styles
{ backgroundColor: "deeppink" }
]}
accessibilityLabel="Diagnostics"
onPress={() => {
Alert.alert(
"ExploreV2 Info",
`queryParams: ${JSON.stringify( queryParams )}`
);
}}
/>
)}
</View>
</ViewWrapper>
{/*
Leaving this here so that it is easier to reason about differences between Explore
and ExploreRedesign.
*/}
{null}
</>
);
};
export default ExploreV2;

View File

@@ -25,7 +25,7 @@ import MapView from "./MapView";
type Props = {
canFetch?: boolean,
layout: string,
layout: ?string,
queryParams: Object,
handleUpdateCount: Function,
hasLocationPermissions?: boolean,

View File

@@ -9,7 +9,7 @@ import colors from "styles/tailwindColors";
type Props = {
hideMap?: boolean,
layout: string,
layout: ?string,
updateObservationsView: Function
};

View File

@@ -17,11 +17,12 @@ import React, {
useRef,
useState
} from "react";
import { useCurrentUser } from "sharedHooks";
import { useCurrentUser, useDebugMode } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission";
import useStore from "stores/useStore";
import Explore from "./Explore";
import ExploreV2 from "./ExploreV2";
import mapParamsToAPI from "./helpers/mapParamsToAPI";
import useExploreHeaderCount from "./hooks/useExploreHeaderCount";
@@ -29,6 +30,7 @@ const RootExploreContainerWithContext = ( ): Node => {
const navigation = useNavigation( );
const { isConnected } = useNetInfo( );
const currentUser = useCurrentUser( );
const { isDebug } = useDebugMode();
const rootExploreView = useStore( state => state.rootExploreView );
const setRootExploreView = useStore( state => state.setRootExploreView );
const rootStoredParams = useStore( state => state.rootStoredParams );
@@ -211,32 +213,48 @@ const RootExploreContainerWithContext = ( ): Node => {
return (
<>
<Explore
canFetch={canFetch}
closeFiltersModal={closeFiltersModal}
count={count}
hideBackButton
filterByIconicTaxonUnknown={
() => dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
}
currentExploreView={rootExploreView}
setCurrentExploreView={setRootExploreView}
isConnected={isConnected}
isFetchingHeaderCount={isFetchingHeaderCount}
handleUpdateCount={handleUpdateCount}
openFiltersModal={openFiltersModal}
queryParams={queryParams}
showFiltersModal={showFiltersModal}
updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
placeMode={state.placeMode}
hasLocationPermissions={hasLocationPermissions}
requestLocationPermissions={requestLocationPermissions}
startFetching={startFetching}
renderLocationPermissionsGate={renderPermissionsGate}
/>
{!isDebug
? (
<Explore
canFetch={canFetch}
closeFiltersModal={closeFiltersModal}
count={count}
hideBackButton
filterByIconicTaxonUnknown={
() => dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
}
currentExploreView={rootExploreView}
setCurrentExploreView={setRootExploreView}
isConnected={isConnected}
isFetchingHeaderCount={isFetchingHeaderCount}
handleUpdateCount={handleUpdateCount}
openFiltersModal={openFiltersModal}
queryParams={queryParams}
showFiltersModal={showFiltersModal}
updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
placeMode={state.placeMode}
hasLocationPermissions={hasLocationPermissions}
requestLocationPermissions={requestLocationPermissions}
startFetching={startFetching}
renderLocationPermissionsGate={renderPermissionsGate}
/>
)
: (
<ExploreV2
canFetch={canFetch}
currentExploreView={rootExploreView}
handleUpdateCount={handleUpdateCount}
hasLocationPermissions={hasLocationPermissions}
isConnected={isConnected}
placeMode={state.placeMode}
queryParams={queryParams}
renderLocationPermissionsGate={renderPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
/>
)}
{renderPermissionsGate( {
onPermissionGranted: async ( ) => {
await updateLocation( "nearby" );

View File

@@ -9,9 +9,9 @@ import {
} from "components/styledComponents";
import _, { compact } from "lodash";
import React, { useEffect, useState } from "react";
import { Image as RNImage } from "react-native";
import Photo from "realmModels/Photo";
import type { RealmObservationPhoto, RealmPhoto, RealmTaxon } from "realmModels/types";
import getImageDimensions from "sharedHelpers/getImageDimensions";
type Props = {
representativePhoto?: ApiPhoto,
@@ -73,7 +73,7 @@ const PhotosSection = ( {
useEffect( ( ) => {
const checkImageOrientation = async ( ) => {
if ( observationPhoto ) {
const imageDimensions = await getImageDimensions( observationPhoto );
const imageDimensions = await RNImage.getSize( observationPhoto );
if ( imageDimensions.width < imageDimensions.height ) {
setDisplayPortraitLayout( true );
} else {

View File

@@ -1,7 +1,7 @@
import { ScrollView, View } from "components/styledComponents";
import React, { useEffect, useState } from "react";
import { Image } from "react-native";
import Photo from "realmModels/Photo";
import getImageDimensions from "sharedHelpers/getImageDimensions";
import PhotoContainer from "./PhotoContainer";
import SoundContainer from "./SoundContainer";
@@ -25,7 +25,7 @@ const MasonryLayout = ( { items, onImagePress } ) => {
if ( item.file_url ) {
return item;
}
const imageDimensions = await getImageDimensions( photoUrl( item ) );
const imageDimensions = await Image.getSize( photoUrl( item ) );
return Object.assign( item, imageDimensions );
} );

View File

@@ -1,15 +0,0 @@
import { Image } from "react-native";
type ImageDimensions = {
width: number;
height: number;
};
// A helper function to get the image dimensions
const getImageDimensions = async ( uri: string ) => new Promise<ImageDimensions>( resolve => {
Image.getSize( uri, ( width, height ) => {
resolve( { width, height } );
} );
} );
export default getImageDimensions;

View File

@@ -18,6 +18,10 @@ export interface LocationPermissionCallbacks {
onModalHide?: ( ) => void;
}
export type RenderLocationPermissionsGateFunction = (
callbacks: LocationPermissionCallbacks | undefined
) => React.JSX.Element | null;
/**
* A hook to check and request location permissions.
* @returns {boolean} hasPermissions - Undefined if permissions have not been checked yet.
@@ -33,45 +37,49 @@ const useLocationPermission = ( ) => {
// PermissionGate callbacks need to use useCallback, otherwise they'll
// trigger re-renders if/when they change
const renderPermissionsGate = useCallback( ( callbacks?: LocationPermissionCallbacks ) => {
const {
onPermissionGranted,
onPermissionDenied,
onPermissionBlocked,
onModalHide
} = callbacks || { };
const renderPermissionsGate: RenderLocationPermissionsGateFunction
= useCallback(
( callbacks?: LocationPermissionCallbacks ) => {
const {
onPermissionGranted,
onPermissionDenied,
onPermissionBlocked,
onModalHide
} = callbacks || {};
// this prevents infinite rerenders of the LocationPermissionGate component
if ( !showPermissionGate ) {
return null;
}
// this prevents infinite rerenders of the LocationPermissionGate component
if ( !showPermissionGate ) {
return null;
}
return (
<LocationPermissionGate
permissionNeeded
withoutNavigation
onModalHide={( ) => {
setShowPermissionGate( false );
if ( onModalHide ) onModalHide( );
}}
onPermissionGranted={( ) => {
setShowPermissionGate( false );
setHasPermissions( true );
setHasBlockedPermissions( false );
if ( onPermissionGranted ) onPermissionGranted( );
}}
onPermissionDenied={( ) => {
if ( onPermissionDenied ) onPermissionDenied( );
}}
onPermissionBlocked={( ) => {
setHasPermissions( false );
setHasBlockedPermissions( true );
setShowPermissionGate( true );
if ( onPermissionBlocked ) onPermissionBlocked( );
}}
/>
return (
<LocationPermissionGate
permissionNeeded
withoutNavigation
onModalHide={() => {
setShowPermissionGate( false );
if ( onModalHide ) onModalHide();
}}
onPermissionGranted={() => {
setShowPermissionGate( false );
setHasPermissions( true );
setHasBlockedPermissions( false );
if ( onPermissionGranted ) onPermissionGranted();
}}
onPermissionDenied={() => {
if ( onPermissionDenied ) onPermissionDenied();
}}
onPermissionBlocked={() => {
setHasPermissions( false );
setHasBlockedPermissions( true );
setShowPermissionGate( true );
if ( onPermissionBlocked ) onPermissionBlocked();
}}
/>
);
},
[showPermissionGate]
);
}, [showPermissionGate] );
// This gets exported and used as a dependency, so it needs to have
// referential stability

View File

@@ -6,9 +6,7 @@ import { Image } from "react-native";
import factory from "tests/factory";
import faker from "tests/helpers/faker";
Image.getSize = jest.fn( ( uri, callback ) => {
callback( { width: 1024, height: 768 } );
} );
Image.getSize = jest.fn( ( _uri, _callback ) => async () => ( { width: 1024, height: 768 } ) );
const mockObservation = factory( "LocalObservation", {
created_at: "2022-11-27T19:07:41-08:00",