diff --git a/.detoxrc.js b/.detoxrc.js index 5ea56ba83d..7e41bb76a7 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -6,7 +6,8 @@ module.exports = { config: "e2e/jest.config.js", }, jest: { - setupTimeout: 120000, + setupTimeout: 900000, + teardownTimeout: 900000, }, }, apps: { @@ -27,16 +28,16 @@ module.exports = { simulator: { type: "ios.simulator", device: { - type: "iPhone 14 Plus", + type: "iPhone 15 Pro", }, }, }, configurations: { - "ios.sim.debug": { + "ios.debug": { device: "simulator", app: "ios.debug", }, - "ios.sim.release": { + "ios.release": { device: "simulator", app: "ios.release", }, diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 18fd49b408..44c86436ac 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -6,9 +6,6 @@ labels: '' assignees: '' --- -**Is this an AR camera feature?** -If so, please head to [react-native-inat-camera](https://github.com/inaturalist/react-native-inat-camera) library and fill out a feature request there. - **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] diff --git a/README.md b/README.md index 178db247fa..8998e36e86 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ 2. Build locally to a device or simulator by running `npm run ios` or `npm run android` ## Manual Linking -Most third-party libraries use autolinking as of [React Native 0.60.0](https://facebook.github.io/react-native/blog/2019/07/03/version-60#native-modules-are-now-autolinked). Any exceptions are listed in the `react-native.config.js` file. Currently, [react-native-inat-camera](https://github.com/inaturalist/react-native-inat-camera) on Android is manually linked. +Most third-party libraries use autolinking as of [React Native 0.60.0](https://facebook.github.io/react-native/blog/2019/07/03/version-60#native-modules-are-now-autolinked). Any exceptions are listed in the `react-native.config.js` file. ## Tests We currently have three kinds of tests: diff --git a/android/app/build.gradle b/android/app/build.gradle index 36b099c067..521100fff9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -131,8 +131,6 @@ android { } dependencies { - implementation project(':react-native-inat-camera') - // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") implementation("com.facebook.react:flipper-integration") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 799af6a354..901d579d30 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,11 +10,10 @@ - + - = PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: - add(INatCameraViewPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/settings.gradle b/android/settings.gradle index 1943f9df4e..49ef1b59ef 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,8 +1,6 @@ rootProject.name = 'Seek' include ':@react-native-camera-roll_camera-roll' project(':@react-native-camera-roll_camera-roll').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-camera-roll/camera-roll/android') -include ':react-native-inat-camera' -project(':react-native-inat-camera').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-inat-camera/android') include ':react-native-check-app-install' project(':react-native-check-app-install').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-check-app-install/android') include ':RNSendIntentModule', ':app' diff --git a/assets/icons.ts b/assets/icons.ts index 6b9e3dd3f6..2f98531ba5 100644 --- a/assets/icons.ts +++ b/assets/icons.ts @@ -65,7 +65,8 @@ const icons: Icons = { iNat_valueprop_bullet_2: require( "./icons/icon-cv-green.webp" ), iNat_valueprop_bullet_3: require( "./icons/icon-obs-green.webp" ), iNat_valueprop_bullet_4: require( "./icons/icon-person-green.webp" ), - noProfilePhoto: require( "./icons/img-inatlogin-nophoto.webp" ) + noProfilePhoto: require( "./icons/img-inatlogin-nophoto.webp" ), + gallery: require( "./icons/icon-gallery.webp" ) }; export default icons; diff --git a/assets/icons/icon-gallery.webp b/assets/icons/icon-gallery.webp new file mode 100644 index 0000000000..e031856069 Binary files /dev/null and b/assets/icons/icon-gallery.webp differ diff --git a/assets/icons/icon-gallery@2x.webp b/assets/icons/icon-gallery@2x.webp new file mode 100644 index 0000000000..d0d55fc9ec Binary files /dev/null and b/assets/icons/icon-gallery@2x.webp differ diff --git a/assets/icons/icon-gallery@3x.webp b/assets/icons/icon-gallery@3x.webp new file mode 100644 index 0000000000..dd99b6cdb2 Binary files /dev/null and b/assets/icons/icon-gallery@3x.webp differ diff --git a/components/About/AboutScreen.js b/components/About/AboutScreen.js index 3a3c6f7973..7ba1379b48 100644 --- a/components/About/AboutScreen.js +++ b/components/About/AboutScreen.js @@ -37,7 +37,7 @@ const AboutScreen = (): Node => { {i18n.t( "about.seek_designed_by" )} - {i18n.t( "about.inat_team_credits_4" )} + {i18n.t( "about.inat_team_credits_5" )} {i18n.t( "about.support_from" )} diff --git a/components/Camera/ARCamera/ARCamera.e2e-mock.js b/components/Camera/ARCamera/ARCamera.e2e-mock.js deleted file mode 100644 index 627d459a03..0000000000 --- a/components/Camera/ARCamera/ARCamera.e2e-mock.js +++ /dev/null @@ -1,214 +0,0 @@ -// @flow - -import React, { - useReducer, - useEffect, - useRef, - useCallback, - useContext -} from "react"; -import { - Image, - TouchableOpacity, - View, - Platform -} from "react-native"; -import { CameraRoll } from "@react-native-camera-roll/camera-roll"; -import { useNavigation } from "@react-navigation/native"; -import type { Node } from "react"; -import RNFS from "react-native-fs"; - -import i18n from "../../../i18n"; -import icons from "../../../assets/icons"; -import { - showCameraSaveFailureAlert -} from "../../../utility/cameraHelpers"; -import { checkCameraPermissions } from "../../../utility/androidHelpers.android"; -import { createTimestamp } from "../../../utility/dateHelpers"; -import { fetchImageLocationOrErrorCode } from "../../../utility/resultsHelpers"; -import { useObservation } from "../../Providers/ObservationProvider"; -import { UserContext } from "../../UserContext"; - -const useVisionCamera = Platform.OS === "android"; - -const ARCamera = ( ): Node => { - const navigation = useNavigation( ); - const camera = useRef( null ); - const { setObservation, observation } = useObservation(); - - // determines whether or not to fetch untruncated coords or precise coords for posting to iNat - const { login } = useContext( UserContext ); - - // eslint-disable-next-line no-shadow - const [state, dispatch] = useReducer( ( state, action ) => { - switch ( action.type ) { - case "RESET_RANKS": - return { ...state, ranks: {} }; - case "SET_RANKS": - return { ...state, ranks: action.ranks }; - case "PHOTO_TAKEN": - return { ...state, pictureTaken: true }; - case "RESET_STATE": - return { - ...state, - pictureTaken: false, - error: null, - ranks: {} - }; - case "FILTER_TAXON": - return { - ...state, - pictureTaken: false, - error: null, - ranks: {} - }; - case "ERROR": - return { ...state, error: action.error }; - default: - throw new Error( ); - } - }, { - ranks: {}, - error: null, - pictureTaken: false - } ); - - const { - error, - pictureTaken - } = state; - - const updateError = useCallback( ( err, errEvent?: string ) => { - console.log( "updateError" ); - // don't update error on first camera load - if ( err === null && error === null ) { - return; - } - dispatch( { type: "ERROR", error: err, errorEvent: errEvent } ); - }, [error] ); - - const navigateToResults = useCallback( async ( uri, predictions ) => { - const userImage = { - time: createTimestamp( ), // add current time to AR camera photos - uri, - predictions - }; - - // AR camera photos don't come with a location - // especially when user has location permissions off - // this is also needed for ancestor screen, species nearby - const { image, errorCode } = await fetchImageLocationOrErrorCode( userImage, login ); - image.errorCode = errorCode; - image.arCamera = true; - setObservation( { image } ); - }, [setObservation, login] ); - - useEffect( ( ) => { - if ( observation && observation.taxon && observation.image.arCamera && pictureTaken ) { - navigation.navigate( "Drawer", { - screen: "Match" - } ); - } - }, [observation, navigation, pictureTaken] ); - - const handleCameraRollSaveError = useCallback( async ( uri, predictions, e ) => { - // react-native-cameraroll does not yet have granular detail about read vs. write permissions - // but there's a pull request for it as of March 2021 - - await showCameraSaveFailureAlert( e, uri ); - navigateToResults( uri, predictions ); - }, [navigateToResults] ); - - const savePhoto = useCallback( async ( photo: { uri: string, predictions: Array } ) => { - console.log( "savePhoto" ); - console.log( "photo.uri", photo.uri ); - CameraRoll.save( photo.uri, { type: "photo", album: "Seek" } ) - .then( uri => navigateToResults( uri, photo.predictions ) ) - .catch( e => handleCameraRollSaveError( photo.uri, photo.predictions, e ) ); - }, [handleCameraRollSaveError, navigateToResults] ); - - const resetState = ( ) => dispatch( { type: "RESET_STATE" } ); - - const requestAndroidPermissions = useCallback( ( ) => { - if ( Platform.OS === "android" ) { - checkCameraPermissions( ).then( ( result ) => { - if ( result === "permissions" ) { - updateError( "permissions" ); - } - updateError( null ); - } ).catch( e => console.log( e, "couldn't get camera permissions" ) ); - } - }, [updateError] ); - - useEffect( ( ) => { - navigation.addListener( "focus", ( ) => { - setObservation( null ); - // reset when camera loads, not when leaving page, for quicker transition - resetState( ); - requestAndroidPermissions( ); - } ); - }, [navigation, requestAndroidPermissions, setObservation] ); - - const takePicture = useCallback( async () => { - dispatch( { type: "PHOTO_TAKEN" } ); - - if ( Platform.OS === "ios" ) { - CameraRoll.getPhotos( { - first: 20, - assetType: "Photos" - } ) - .then( async ( r ) => { - console.log( "r.edges", r.edges ); - const testPhoto = r.edges[r.edges.length - 1].node.image; - console.log( "testPhoto", testPhoto ); - let oldUri = testPhoto.uri; - if ( testPhoto.uri.includes( "ph://" ) ) { - let id = testPhoto.uri.replace( "ph://", "" ); - id = id.substring( 0, id.indexOf( "/" ) ); - oldUri = `assets-library://asset/asset.jpg?id=${id}&ext=jpg`; - console.log( `Converted file uri to ${oldUri}` ); - } - const encodedUri = encodeURI( oldUri ); - const destPath = `${RNFS.TemporaryDirectoryPath}temp.jpg`; - const newUri = await RNFS.copyAssetsFileIOS( encodedUri, destPath, 0, 0 ); - console.log( "newUri", newUri ); - const photo = { uri: newUri, predictions: [] }; - if ( typeof photo !== "object" ) { - updateError( "photoError", photo ); - } else { - savePhoto( photo ); - } - } ) - .catch( ( err ) => { - updateError( "take", err ); - } ); - } else if ( Platform.OS === "android" ) { - if ( camera.current ) { - if ( useVisionCamera ) { - // TODO: mock vision camera with frame processor takePhoto() method - } else { - // TODO: mock react-native-inat-camera takePictureAsync() method - } - } - } - }, [ - savePhoto, - updateError - ] ); - - return ( - - - - - - ); -}; - -export default ARCamera; diff --git a/components/Camera/ARCamera/ARCamera.js b/components/Camera/ARCamera/ARCamera.js index d3c8bc2ab4..721f4b95cb 100644 --- a/components/Camera/ARCamera/ARCamera.js +++ b/components/Camera/ARCamera/ARCamera.js @@ -34,7 +34,10 @@ import { rotatePhotoPatch, rotationTempPhotoPatch } from "../../../utility/visionCameraPatches"; -import { checkCameraPermissions, checkSavePermissions } from "../../../utility/androidHelpers.android"; +import { + checkCameraPermissions, + checkSavePermissions +} from "../../../utility/androidHelpers.android"; import { savePostingSuccess } from "../../../utility/loginHelpers"; // TODO: this can be imported in FrameProcessorCamera directly instead of here import { dirModel, dirGeomodel, dirTaxonomy } from "../../../utility/dirStorage"; @@ -459,6 +462,7 @@ const ARCamera = ( ): Node => { takePicture={takePicture} cameraLoaded={cameraLoaded.value} filterByTaxonId={filterByTaxonId} + setIsActive={setIsActive} /> ) } diff --git a/components/Camera/ARCamera/ARCameraHeader.tsx b/components/Camera/ARCamera/ARCameraHeader.tsx index 07e04f558b..eac95b5fc7 100644 --- a/components/Camera/ARCamera/ARCameraHeader.tsx +++ b/components/Camera/ARCamera/ARCameraHeader.tsx @@ -22,27 +22,21 @@ interface Prediction { } interface Props { - ranks: { - [key: string]: { - taxon_id: number; - name: string; - }[]; - }; prediction: Prediction; } -const ARCameraHeader = ( { ranks, prediction }: Props ) => { +const ARCameraHeader = ( { prediction }: Props ) => { const { isLandscape } = useAppOrientation( ); - const rankToRender = ranks ? Object.keys( ranks )[0] || null : prediction?.rank || null; + const rankToRender = prediction?.rank || null; const [commonName, setCommonName] = useState( null ); const settings = useFetchUserSettings( ); const scientificNames = settings?.scientificNames; const showScientificName = scientificNames || !commonName; - let id = null; + let id: number | null = null; if ( rankToRender && !scientificNames ) { - id = ranks ? ranks[rankToRender][0].taxon_id : prediction?.taxon_id; + id = prediction?.taxon_id; } else { id = null; } @@ -107,11 +101,11 @@ const ARCameraHeader = ( { ranks, prediction }: Props ) => { return null; }; - const scientificName = ranks ? ranks[rankToRender][0].name : prediction?.name; + const scientificName = prediction?.name; return ( - {( ( ranks || prediction ) && rankToRender ) && ( - + {( prediction && rankToRender ) && ( + void; - ranks?: { - [key: string]: { - taxon_id: number; - name: string; - }[]; - }; prediction?: Prediction; pictureTaken: boolean; cameraLoaded: boolean; filterByTaxonId: ( taxonId: string | null, negativeFilter: boolean ) => void; + setIsActive: ( arg0: boolean ) => void; } const isAndroid = Platform.OS === "android"; const ARCameraOverlay = ( { takePicture, - ranks, prediction, pictureTaken, cameraLoaded, - filterByTaxonId + filterByTaxonId, + setIsActive }: Props ) => { - const { isLandscape, height } = useAppOrientation( ); + const { isLandscape } = useAppOrientation( ); const { navigate } = useNavigation( ); - const rankToRender = ranks ? ( Object.keys( ranks )[0] || null ) : prediction?.rank || null; + const rankToRender = prediction?.rank || null; const helpText = setCameraHelpText( rankToRender ); const userSettings = useFetchUserSettings( ); const autoCapture = userSettings?.autoCapture; const [filterIndex, setFilterIndex] = useState( null ); - const shutterButtonPositionLandscape = height / 2 - 65 - 31; - const helpButtonPositionLandscape = height / 2 + 50; - const settings = useMemo( ( ) => ( [ { negativeFilter: true, @@ -146,7 +139,7 @@ const ARCameraOverlay = ( { return ( <> {( pictureTaken || !cameraLoaded ) && } - + {isAndroid && showFilterText( )} {( isAndroid && filterIndex === 0 ) && ( {helpText} - {isAndroid && ( + + + + {isAndroid && ( + + + + )} + + + + + - + - )} - - - - - - + + + + + ); }; diff --git a/components/Camera/ARCamera/FrameProcessorCamera.tsx b/components/Camera/ARCamera/FrameProcessorCamera.tsx index 0f57beb45f..51fdc78ef3 100644 --- a/components/Camera/ARCamera/FrameProcessorCamera.tsx +++ b/components/Camera/ARCamera/FrameProcessorCamera.tsx @@ -2,18 +2,12 @@ import { useIsFocused, useNavigation } from "@react-navigation/native"; import React, { useCallback, useEffect, useState } from "react"; import { Platform, StyleSheet } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import { - Camera, - CameraRuntimeError, - useCameraDevice, - useCameraFormat, - useFrameProcessor -} from "react-native-vision-camera"; +import type { CameraRuntimeError } from "react-native-vision-camera"; import { Worklets } from "react-native-worklets-core"; -import * as InatVision from "vision-camera-plugin-inatvision"; import { useIsForeground, useDeviceOrientation } from "../../../utility/customHooks"; import { useSpeciesNearby } from "../../Providers/SpeciesNearbyProvider"; +import InatVision from "./helpers/visionPluginWrapper"; import { orientationPatch, @@ -21,6 +15,12 @@ import { usePatchedRunAsync } from "../../../utility/visionCameraPatches"; +import { + Camera, + useCameraDevice, + useCameraFormat, + useFrameProcessor +} from "./helpers/visionCameraWrapper"; import FocusSquare from "./FocusSquare"; import useFocusTap from "./hooks/useFocusTap"; @@ -181,7 +181,7 @@ const FrameProcessorCamera = ( props: Props ) => { tapToFocus } = useFocusTap( props.cameraRef, device.supportsFocus ); - const [lastTimestamp, setLastTimestamp] = useState( Date.now() ); + const [lastTimestamp, setLastTimestamp] = useState( undefined ); const fps = 1; const handleResult = Worklets.createRunOnJS( ( result: InatVision.Result, timeTaken: number ) => { setLastTimestamp( result.timestamp ); @@ -219,12 +219,14 @@ const FrameProcessorCamera = ( props: Props ) => { // Reminder: this is a worklet, running on a C++ thread. Make sure to check the // react-native-worklets-core documentation for what is supported in those worklets. + // If there is no lastTimestamp, i.e. the first time this runs do not compare const timestamp = Date.now(); - const timeSinceLastFrame = timestamp - lastTimestamp; - if ( timeSinceLastFrame < 1000 / fps ) { - return; + if ( lastTimestamp ) { + const timeSinceLastFrame = timestamp - lastTimestamp; + if ( timeSinceLastFrame < 1000 / fps ) { + return; + } } - patchedRunAsync( frame, () => { "worklet"; try { diff --git a/components/Camera/ARCamera/GalleryButton.tsx b/components/Camera/ARCamera/GalleryButton.tsx new file mode 100644 index 0000000000..23d8b36eb3 --- /dev/null +++ b/components/Camera/ARCamera/GalleryButton.tsx @@ -0,0 +1,165 @@ +import React, { useContext, useEffect, useState } from "react"; +import { getPredictionsForImage } from "vision-camera-plugin-inatvision"; +import { useNavigation } from "@react-navigation/native"; +import * as ImagePicker from "react-native-image-picker"; +import { + TouchableOpacity, + View, + Image, + Platform +} from "react-native"; + +import i18n from "../../../i18n"; +import { checkForPhotoMetaData } from "../../../utility/photoHelpers"; +import { dirTaxonomy, dirModel } from "../../../utility/dirStorage"; +import { UserContext } from "../../UserContext"; +import { useObservation } from "../../Providers/ObservationProvider"; +import { viewStyles } from "../../../styles/camera/arCameraOverlay"; +import icons from "../../../assets/icons"; +import { readExifFromMultiplePhotos } from "../../../utility/parseExif"; +import { getUnixTime } from "date-fns"; +import LoadingWheel from "../../UIComponents/LoadingWheel"; + +interface Props { + setIsActive: ( arg0: boolean ) => void; +} + +const GalleryButton = ( { setIsActive }: Props ) => { + const { setObservation, observation } = useObservation(); + const { login } = useContext( UserContext ); + const navigation = useNavigation( ); + const [imageSelected, setImageSelected] = useState( false ); + + const navigateToResults = ( uri, time, location, predictions ) => { + const { navigate } = navigation; + + const image = { + time, + uri, + predictions: [], + errorCode: 0, + latitude: null, + longitude: null, + preciseCoords: {}, + onlineVision: false + }; + + if ( checkForPhotoMetaData( location ) ) { + const { latitude, longitude } = location; + image.latitude = latitude || null; + image.longitude = longitude || null; + + if ( login ) { + image.preciseCoords = { + latitude, + longitude, + accuracy: null + }; + } + } else if ( login ) { + image.preciseCoords = { + latitude: null, + longitude: null, + accuracy: null + }; + } + + if ( predictions && predictions.length > 0 ) { + image.predictions = predictions; + setObservation( { image } ); + } else { + image.onlineVision = true; + setObservation( { image } ); + navigate( "Confirm" ); + } + }; + + // TODO: this is a useEffect that waits until the image is attached to the new observation + // and then navigates to the match screen; this needs to be refactored + useEffect( ( ) => { + if ( observation + && observation.taxon + && !observation.image.onlineVision + && imageSelected + ) { + // changed to navigate from push bc on Android, with RN > 0.65.x, the camera was + // popping up over the top of the match screen + navigation.navigate( "Match" ); + } + }, [observation, navigation, imageSelected] ); + + const getPredictions = ( uri, timestamp, location ) => { + const path = uri.split( "file://" ); + const reactUri = Platform.OS === "android" ? path[1] : uri; + + getPredictionsForImage( { + uri: reactUri, + modelPath: dirModel, + taxonomyPath: dirTaxonomy, + version: "1.0" + } ) + .then( ( result ) => { + const { predictions } = result; + navigateToResults( uri, timestamp, location, predictions ); + } ) + .catch( ( err ) => { + console.log( "Error", err ); + } ); + }; + + const showPhotoGallery = async () => { + setIsActive( false ); + // According to the native code of the image picker library, it never rejects the promise, + // just returns a response object with errorCode + const response = await ImagePicker.launchImageLibrary( { + selectionLimit: 1, + mediaType: "photo", + includeBase64: false, + forceOldAndroidPhotoPicker: true, + chooserTitle: i18n.t( "gallery.import_photos_from" ), + presentationStyle: "overFullScreen" + } ); + + if ( !response || response.didCancel || !response.assets || response.errorCode ) { + // User cancelled selection of photos - nothing to do here + setIsActive( true ); + return; + } + + // TODO: This was in this order in gallery image list on press but what does it do? + setImageSelected( true ); + + const asset = response.assets[0]; + const { timestamp, uri } = asset; + if ( !uri ) { + throw new Error( "No URI in pick photo response" ); + } + + const exif = await readExifFromMultiplePhotos( [uri] ); + const { latitude, longitude, observed_on_string } = exif; + const location = { latitude, longitude }; + const unixTimestamp = getUnixTime( new Date( observed_on_string ) ); + getPredictions( uri, timestamp || unixTimestamp, location ); + }; + + if ( imageSelected ) { + return + + ; + } + + return ( + + + + ); +}; + +export default GalleryButton; diff --git a/components/Camera/ARCamera/LegacyARCamera.js b/components/Camera/ARCamera/LegacyARCamera.js deleted file mode 100644 index c72afb114f..0000000000 --- a/components/Camera/ARCamera/LegacyARCamera.js +++ /dev/null @@ -1,441 +0,0 @@ -// @flow - -import React, { - useReducer, - useEffect, - useRef, - useCallback, - useContext -} from "react"; -import { - Image, - TouchableOpacity, - View, - Platform, - NativeModules -} from "react-native"; -import { CameraRoll } from "@react-native-camera-roll/camera-roll"; -import { useNavigation, useIsFocused, useFocusEffect } from "@react-navigation/native"; -import { INatCamera } from "react-native-inat-camera"; -import type { Node } from "react"; - -import i18n from "../../../i18n"; -import { viewStyles, imageStyles } from "../../../styles/camera/arCamera"; -import icons from "../../../assets/icons"; -import CameraError from "../CameraError"; -import { - checkForSystemVersion, - handleLog, - showCameraSaveFailureAlert, - checkForCameraAPIAndroid -} from "../../../utility/cameraHelpers"; -import { checkCameraPermissions, checkSavePermissions } from "../../../utility/androidHelpers.android"; -import { savePostingSuccess } from "../../../utility/loginHelpers"; -import { dirModel, dirTaxonomy } from "../../../utility/dirStorage"; -import { createTimestamp } from "../../../utility/dateHelpers"; -import ARCameraOverlay from "./ARCameraOverlay"; -import { resetRouter } from "../../../utility/navigationHelpers"; -import { fetchImageLocationOrErrorCode } from "../../../utility/resultsHelpers"; -import { checkIfCameraLaunched } from "../../../utility/helpers"; -import { colors } from "../../../styles/global"; -import Modal from "../../UIComponents/Modals/Modal"; -import WarningModal from "../../Modals/WarningModal"; -import { ObservationContext, UserContext, AppOrientationContext } from "../../UserContext"; -import { log } from "../../../react-native-logs.config"; - -const logger = log.extend( "ARCamera.js" ); - -const LegacyARCamera = ( ): Node => { - // getting width and height passes correct dimensions to camera - // on orientation change - const isFocused = useIsFocused( ); - const { width, height } = useContext( AppOrientationContext ); - const navigation = useNavigation( ); - const camera = useRef( null ); - const { setObservation, observation } = useContext( ObservationContext ); - - // determines whether or not to fetch untruncated coords or precise coords for posting to iNat - const { login } = useContext( UserContext ); - - // eslint-disable-next-line no-shadow - const [state, dispatch] = useReducer( ( state, action ) => { - switch ( action.type ) { - case "CAMERA_LOADED": - return { ...state, cameraLoaded: true }; - case "RESET_RANKS": - return { ...state, ranks: {} }; - case "SET_RANKS": - return { ...state, ranks: action.ranks }; - case "PHOTO_TAKEN": - return { ...state, pictureTaken: true }; - case "RESET_STATE": - return { - ...state, - pictureTaken: false, - error: null, - ranks: {} - }; - case "FILTER_TAXON": - return { - ...state, - negativeFilter: action.negativeFilter, - taxonId: action.taxonId, - pictureTaken: false, - error: null, - ranks: {} - }; - case "SHOW_FRONT_CAMERA": - return { ...state, cameraType: action.cameraType }; - case "ERROR": - return { ...state, error: action.error, errorEvent: action.errorEvent }; - case "SHOW_MODAL": - return { ...state, showModal: true }; - case "CLOSE_MODAL": - return { ...state, showModal: false }; - case "SPECIES_TIMEOUT": - return { ...state, speciesTimeoutSet: action.speciesTimeoutSet }; - default: - throw new Error( ); - } - }, { - ranks: {}, - error: null, - errorEvent: null, - pictureTaken: false, - cameraLoaded: false, - negativeFilter: false, - taxonId: null, - cameraType: "back", - showModal: false, - speciesTimeoutSet: false - } ); - - const { - ranks, - error, - errorEvent, - pictureTaken, - cameraLoaded, - negativeFilter, - taxonId, - cameraType, - showModal, - speciesTimeoutSet - } = state; - - const updateError = useCallback( ( err, errEvent?: string ) => { - // don't update error on first camera load - if ( err === null && error === null ) { - return; - } - dispatch( { type: "ERROR", error: err, errorEvent: errEvent } ); - }, [error] ); - - const navigateToResults = useCallback( async ( uri, predictions ) => { - const userImage = { - time: createTimestamp( ), // add current time to AR camera photos - uri, - predictions - }; - - // AR camera photos don't come with a location - // especially when user has location permissions off - // this is also needed for ancestor screen, species nearby - const { image, errorCode } = await fetchImageLocationOrErrorCode( userImage, login ); - image.errorCode = errorCode; - image.arCamera = true; - setObservation( { image } ); - }, [setObservation, login] ); - - useEffect( ( ) => { - if ( observation && observation.taxon && observation.image.arCamera && pictureTaken ) { - navigation.navigate( "Drawer", { - screen: "Match" - } ); - } - }, [observation, navigation, pictureTaken] ); - - const handleCameraRollSaveError = useCallback( async ( uri, predictions, e ) => { - // react-native-cameraroll does not yet have granular detail about read vs. write permissions - // but there's a pull request for it as of March 2021 - - await showCameraSaveFailureAlert( e, uri ); - navigateToResults( uri, predictions ); - }, [navigateToResults] ); - - const savePhoto = useCallback( async ( photo: { uri: string, predictions: Array } ) => { - CameraRoll.save( photo.uri, { type: "photo", album: "Seek" } ) - .then( uri => navigateToResults( uri, photo.predictions ) ) - .catch( e => handleCameraRollSaveError( photo.uri, photo.predictions, e ) ); - }, [handleCameraRollSaveError, navigateToResults] ); - - const filterByTaxonId = useCallback( ( id: number, filter: ?boolean ) => { - dispatch( { type: "FILTER_TAXON", taxonId: id, negativeFilter: filter } ); - }, [] ); - - const handleTaxaDetected = ( event ) => { - const predictions = { ...event.nativeEvent }; - - if ( pictureTaken ) { return; } - - if ( predictions && !cameraLoaded ) { - dispatch( { type: "CAMERA_LOADED" } ); - } - - let predictionSet = false; - - // don't bother with trying to set predictions if a species timeout is in place - if ( speciesTimeoutSet ) { return; } - - // not looking at kingdom or phylum as we are currently not displaying results for those ranks - ["species", "genus", "family", "order", "class"].forEach( ( rank: string ) => { - // skip this block if a prediction state has already been set - if ( predictionSet ) { return; } - if ( predictions[rank] ) { - if ( predictions[rank] === "species" ) { - // this block keeps the last species seen displayed for 2.5 seconds - dispatch( { type: "SPECIES_TIMEOUT", speciesTimeoutSet: true } ); - setTimeout( ( ) => { - dispatch( { type: "SPECIES_TIMEOUT", speciesTimeoutSet: false } ); - }, 2500 ); - } - predictionSet = true; - const prediction = predictions[rank][0]; - - //$FlowFixMe - dispatch( { type: "SET_RANKS", ranks: { [rank]: [prediction] } } ); - } - if ( !predictionSet ) { - // only rerender if state has different values than before - if ( Object.keys( ranks ).length > 0 ) { - dispatch( { type: "RESET_RANKS" } ); - } - } - } ); - }; - - const handleCameraError = ( event: { nativeEvent: { error?: string } } ) => { - const permissions = "Camera Input Failed: This app is not authorized to use Back Camera."; - // iOS camera permissions error is handled by handleCameraError, not permission missing - if ( error === "device" ) { - // do nothing if there is already a device error - return; - } - - if ( event.nativeEvent.error === permissions ) { - updateError( "permissions" ); - } else { - updateError( "camera", event.nativeEvent.error ); - } - }; - - // event.nativeEvent.error is not implemented on Android - // it shows up via handleCameraError on iOS - // ignoring this callback since we're checking all permissions in React Native - const handleCameraPermissionMissing = ( ) => {}; - - const handleClassifierError = ( event: { nativeEvent?: { error: string } } ) => { - if ( event.nativeEvent && event.nativeEvent.error ) { - updateError( "classifier", event.nativeEvent.error ); - } else { - updateError( "classifier" ); - } - }; - - const handleDeviceNotSupported = ( event: { nativeEvent?: { reason: string } } ) => { - if ( event.nativeEvent && event.nativeEvent.reason ) { - updateError( "device", event.nativeEvent.reason ); - } else { - updateError( "device", checkForSystemVersion( ) ); - } - }; - - const requestAndroidSavePermissions = useCallback( ( photo ) => { - const checkPermissions = async ( ) => { - const result = await checkSavePermissions( ); - - if ( result === "gallery" ) { - savePhoto( photo ); - } else { - savePhoto( photo ); - } - }; - // on Android, this permission check will pop up every time; on iOS it only pops up first time a user opens camera - checkPermissions( ); - }, [savePhoto] ); - - const takePicture = useCallback( async ( ) => { - dispatch( { type: "PHOTO_TAKEN" } ); - - if ( Platform.OS === "ios" ) { - const CameraManager = NativeModules.INatCameraViewManager; - if ( CameraManager ) { - try { - const photo = await CameraManager.takePictureAsync( ); - logger.debug( "takePictureAsync resolved" ); - if ( typeof photo !== "object" ) { - updateError( "photoError", photo ); - } else { - savePhoto( photo ); - } - } catch ( e ) { - updateError( "take", e ); - } - } else { - updateError( "cameraManager" ); - } - } else if ( Platform.OS === "android" ) { - if ( camera.current ) { - camera.current.takePictureAsync( { - pauseAfterCapture: true - } ).then( ( photo ) => { - logger.debug( "takePictureAsync resolved" ); - requestAndroidSavePermissions( photo ); - } ).catch( e => updateError( "take", e ) ); - } - } - }, [savePhoto, updateError, requestAndroidSavePermissions] ); - - const resetState = ( ) => dispatch( { type: "RESET_STATE" } ); - - const requestAndroidPermissions = useCallback( ( ) => { - if ( Platform.OS === "android" ) { - checkCameraPermissions( ).then( ( result ) => { - if ( result === "permissions" ) { - updateError( "permissions" ); - } - updateError( null ); - } ).catch( e => console.log( e, "couldn't get camera permissions" ) ); - } - }, [updateError] ); - - const checkCameraHardware = async ( ) => { - // the goal of this is to make Seek usable for Android devices - // which lack a back camera, like most Chromebooks - const cameraHardware = await checkForCameraAPIAndroid( ); - - if ( cameraHardware === "front" ) { - dispatch( { type: "SHOW_FRONT_CAMERA", cameraType: "front" } ); - } - }; - - const closeModal = useCallback( ( ) => dispatch( { type: "CLOSE_MODAL" } ), [] ); - - useEffect( ( ) => { - const checkForFirstCameraLaunch = async ( ) => { - const isFirstLaunch = await checkIfCameraLaunched( ); - if ( isFirstLaunch ) { - dispatch( { type: "SHOW_MODAL" } ); - } - }; - - navigation.addListener( "focus", ( ) => { - setObservation( null ); - // reset when camera loads, not when leaving page, for quicker transition - resetState( ); - checkForFirstCameraLaunch( ); - requestAndroidPermissions( ); - checkCameraHardware( ); - } ); - }, [navigation, requestAndroidPermissions, setObservation] ); - - useFocusEffect( - useCallback( ( ) => { - let isActive = true; - - if ( isActive ) { - // reset user ability to post to iNat from Match Screen - savePostingSuccess( false ); - } - - return ( ) => { - isActive = false; - }; - }, [] ) - ); - - const navHome = ( ) => resetRouter( navigation ); - const navToSettings = ( ) => navigation.navigate( "Settings" ); - - const confidenceThreshold = Platform.OS === "ios" ? 0.7 : "0.7"; - const taxaDetectionInterval = Platform.OS === "ios" ? 1000 : "1000"; - - const cameraStyle = { - width, - height - }; - - if ( !isFocused ) { - // this is necessary for camera to load properly in iOS - // if removed, it means a user will see a frozen camera preview the second - // time they try to navigate to the camera (like, after the match screen) - return null; - } - - const renderCamera = ( ) => ( - - ); - - return ( - - } - /> - {error - ? - : ( - - ) - } - - - - - {/* $FlowFixMe */} - - - {renderCamera( )} - - ); -}; - -export default LegacyARCamera; - diff --git a/components/Camera/ARCamera/helpers/visionCameraWrapper.e2e-mock.js b/components/Camera/ARCamera/helpers/visionCameraWrapper.e2e-mock.js new file mode 100644 index 0000000000..a4ccc659cc --- /dev/null +++ b/components/Camera/ARCamera/helpers/visionCameraWrapper.e2e-mock.js @@ -0,0 +1,20 @@ +// This wraps the react-native-vision-camera component and methods we use, +// so we can mock them for e2e tests in simulators without camera. +/* + Note that we are not mocking the useFrameProcessor hook. So, in the e2e test + a real frame processor in the sense of react-native-vision-camera is built. + As you can see in the next wrapper file our plugin is not used though in this + frame processor and only a mocked prediction is immediately returned. +*/ +import { useFrameProcessor } from "react-native-vision-camera"; +import { + mockCamera, + mockUseCameraDevice, + mockUseCameraFormat +} from "tests/vision-camera/vision-camera"; + +const Camera = mockCamera; +const useCameraDevice = mockUseCameraDevice; +const useCameraFormat = mockUseCameraFormat; + +export { Camera, useCameraDevice, useCameraFormat, useFrameProcessor }; diff --git a/components/Camera/ARCamera/helpers/visionCameraWrapper.js b/components/Camera/ARCamera/helpers/visionCameraWrapper.js new file mode 100644 index 0000000000..01c577d41a --- /dev/null +++ b/components/Camera/ARCamera/helpers/visionCameraWrapper.js @@ -0,0 +1,10 @@ +// This wraps the react-native-vision-camera component and methods we use, +// so we can mock them for e2e tests in simulators without camera. +import { + Camera, + useCameraDevice, + useCameraFormat, + useFrameProcessor +} from "react-native-vision-camera"; + +export { Camera, useCameraDevice, useCameraFormat, useFrameProcessor }; diff --git a/components/Camera/ARCamera/helpers/visionPluginWrapper.e2e-mock.js b/components/Camera/ARCamera/helpers/visionPluginWrapper.e2e-mock.js new file mode 100644 index 0000000000..a1d1513edb --- /dev/null +++ b/components/Camera/ARCamera/helpers/visionPluginWrapper.e2e-mock.js @@ -0,0 +1,34 @@ +// This wraps our vision camera plugin, +// so we can mock it for e2e tests in simulators without camera. +import * as InatVision from "vision-camera-plugin-inatvision"; + +const mockModelResult = { + timestamp: Date.now(), + predictions: [ + { + name: "Sempervivum tectorum", + rank_level: 10, + rank: "species", + score: 0.96, + taxon_id: 51779 + } + ] +}; + +const mockVision = ( ) => { + "worklet"; + + return mockModelResult; +}; + +/* + We are mocking the frame processor plugin to return a defined mocked prediction. + Note that we are not mocking the getPredictionsForImage function of the plugin, + so in the e2e test when the mocked camera "saves" the photo and the app navigates + to the suggestions screen, the real example cv model is run on the still image and + the e2e test checks for a real prediction from the model. +*/ +export default { + ...InatVision, + inatVision: mockVision +}; diff --git a/components/Camera/ARCamera/helpers/visionPluginWrapper.js b/components/Camera/ARCamera/helpers/visionPluginWrapper.js new file mode 100644 index 0000000000..fd48423bdd --- /dev/null +++ b/components/Camera/ARCamera/helpers/visionPluginWrapper.js @@ -0,0 +1,5 @@ +// This wraps our vision camera plugin, +// so we can mock it for e2e tests in simulators without camera. +import * as InatVision from "vision-camera-plugin-inatvision"; + +export default InatVision; diff --git a/components/Camera/ARCamera/hooks/useFocusTap.ts b/components/Camera/ARCamera/hooks/useFocusTap.ts index b8a09dfd4a..fe67bec239 100644 --- a/components/Camera/ARCamera/hooks/useFocusTap.ts +++ b/components/Camera/ARCamera/hooks/useFocusTap.ts @@ -7,8 +7,7 @@ import { GestureStateChangeEvent, TapGestureHandlerEventPayload } from "react-native-gesture-handler"; -import { Camera } from "react-native-vision-camera"; - +import { Camera } from "../helpers/visionCameraWrapper"; const HALF_SIZE_FOCUS_BOX = 40; interface Coordinates { diff --git a/components/Camera/CameraError.tsx b/components/Camera/CameraError.tsx index 70b9a09101..9458bf759c 100644 --- a/components/Camera/CameraError.tsx +++ b/components/Camera/CameraError.tsx @@ -12,10 +12,9 @@ import { baseTextStyles } from "../../styles/textStyles"; interface Props { readonly error: string; readonly errorEvent: string | null; - album?: string | null; } -const CameraError = ( { error, errorEvent, album }: Props ) => { +const CameraError = ( { error, errorEvent }: Props ) => { const { name } = useRoute(); const setCameraErrorText = ( err: string, event: string ) => { @@ -23,7 +22,7 @@ const CameraError = ( { error, errorEvent, album }: Props ) => { if ( event ) { errorText += `\n\n${event.toString()}`; - } else if ( Platform.OS === "ios" && album === null ) { + } else if ( Platform.OS === "ios" ) { const OS = getSystemVersion( ); const majorVersionNumber = Number( OS.split( "." )[0] ); diff --git a/components/Camera/Gallery/AlbumPicker.tsx b/components/Camera/Gallery/AlbumPicker.tsx deleted file mode 100644 index 1b5d760e2e..0000000000 --- a/components/Camera/Gallery/AlbumPicker.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { useCallback } from "react"; -import { Image } from "react-native"; -import RNPickerSelect from "react-native-picker-select"; - -import icons from "../../../assets/icons"; -import styles from "../../../styles/camera/galleryHeader"; - -interface Props { - readonly updateAlbum: ( newAlbum: string | null ) => void; - readonly albumNames: { - label: string; - value: string; - }[]; -} - -const placeholder = {}; -const pickerStyles = { ...styles }; - -const AlbumPicker = ( { updateAlbum, albumNames }: Props ) => { - const handleValueChange = useCallback( ( newAlbum: string ) => { - updateAlbum( newAlbum !== "All" ? newAlbum : null ); - }, [updateAlbum] ); - - const showIcon = useCallback( () => { - if ( albumNames.length > 1 ) { - return ; - } - return <>; - }, [albumNames] ); - - return ( - - ); -}; - -export default AlbumPicker; diff --git a/components/Camera/Gallery/GalleryHeader.js b/components/Camera/Gallery/GalleryHeader.js deleted file mode 100644 index f96fcaed00..0000000000 --- a/components/Camera/Gallery/GalleryHeader.js +++ /dev/null @@ -1,59 +0,0 @@ -// @flow - -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { Image, TouchableOpacity, View } from "react-native"; -import { useNavigation } from "@react-navigation/native"; -import type { Node } from "react"; - -import i18n from "../../../i18n"; -import { colors } from "../../../styles/global"; -import { viewStyles, imageStyles } from "../../../styles/camera/gallery"; -import icons from "../../../assets/icons"; -import AlbumPicker from "./AlbumPicker"; -import { fetchAlbums } from "../../../utility/cameraRollHelpers"; -import { resetRouter } from "../../../utility/navigationHelpers"; - -type Props = { - updateAlbum: ( ?string ) => mixed -} - -const GalleryHeader = ( { updateAlbum }: Props ): Node => { - const navigation = useNavigation( ); - - const cameraRoll = useMemo( ( ) => ( [{ - label: i18n.t( "gallery.photo_library" ).toLocaleUpperCase( ), - value: "All" - }] ), [] ); - - const [albumNames, setAlbumNames] = useState( cameraRoll ); - - useEffect( ( ) => { - const fetch = async ( ) => setAlbumNames( await fetchAlbums( cameraRoll ) ); - if ( albumNames.length === 1 ) { - fetch( ); - } - }, [albumNames, cameraRoll] ); - - const handleBackNav = useCallback( ( ) => resetRouter( navigation ), [navigation] ); - - return ( - - - {/* $FlowFixMe */} - - - - - ); -}; - -export default GalleryHeader; diff --git a/components/Camera/Gallery/GalleryImageList.js b/components/Camera/Gallery/GalleryImageList.js deleted file mode 100644 index 38a35da057..0000000000 --- a/components/Camera/Gallery/GalleryImageList.js +++ /dev/null @@ -1,142 +0,0 @@ -// @flow - -import React, { useCallback, useContext, useEffect, useState } from "react"; -import { Platform, FlatList } from "react-native"; -import type { Node } from "react"; -import { getPredictionsForImage } from "vision-camera-plugin-inatvision"; -import { useNavigation } from "@react-navigation/native"; - -import { checkForPhotoMetaData } from "../../../utility/photoHelpers"; -import { viewStyles } from "../../../styles/camera/gallery"; -import { dirTaxonomy, dirModel } from "../../../utility/dirStorage"; -import { UserContext } from "../../UserContext"; -import { dimensions } from "../../../styles/global"; -import GalleryImage from "./GalleryImage"; -import { useObservation } from "../../Providers/ObservationProvider"; - -type Props = { - photos: Array, - onEndReached: Function, - setLoading: ( ) => void -} - -const GalleryImageList = ( { onEndReached, photos, setLoading }: Props ): Node => { - const { setObservation, observation } = useObservation(); - const { login } = useContext( UserContext ); - const navigation = useNavigation( ); - const [imageSelected, setImageSelected] = useState( false ); - - // TODO: this is now only ever used once, so it doesn't need to be a callback - const navigateToResults = useCallback( ( uri, time, location, predictions ) => { - const { navigate } = navigation; - - const image = { - time, - uri, - predictions: [], - errorCode: 0, - latitude: null, - longitude: null, - preciseCoords: {}, - onlineVision: false - }; - - if ( checkForPhotoMetaData( location ) ) { - const { latitude, longitude } = location; - image.latitude = latitude || null; - image.longitude = longitude || null; - - if ( login ) { - image.preciseCoords = { - latitude, - longitude, - accuracy: null - }; - } - } else if ( login ) { - image.preciseCoords = { - latitude: null, - longitude: null, - accuracy: null - }; - } - - if ( predictions && predictions.length > 0 ) { - image.predictions = predictions; - setObservation( { image } ); - } else { - image.onlineVision = true; - setObservation( { image } ); - navigate( "Confirm" ); - } - }, [navigation, setObservation, login] ); - - useEffect( ( ) => { - if ( observation - && observation.taxon - && !observation.image.onlineVision - && imageSelected - ) { - // changed to navigate from push bc on Android, with RN > 0.65.x, the camera was - // popping up over the top of the match screen - navigation.navigate( "Drawer", { - screen: "Match" - } ); - } - }, [observation, navigation, imageSelected] ); - - const getPredictions = useCallback( ( uri, timestamp, location ) => { - const path = uri.split( "file://" ); - const reactUri = Platform.OS === "android" ? path[1] : uri; - - getPredictionsForImage( { - uri: reactUri, - modelPath: dirModel, - taxonomyPath: dirTaxonomy, - version: "1.0" - } ) - .then( ( result ) => { - const { predictions } = result; - navigateToResults( uri, timestamp, location, predictions ); - } ) - .catch( ( err ) => { - console.log( "Error", err ); - } ); - }, [navigateToResults] ); - - const selectImage = useCallback( ( item ) => { - setImageSelected( true ); - setLoading( ); - const { timestamp, location, image } = item.node; - getPredictions( image.uri, timestamp, location ); - }, [getPredictions, setLoading] ); - - const renderImage = useCallback( ( { item } ) => , [selectImage] ); - - // skips measurement of dynamic content for faster loading - const getItemLayout = useCallback( ( data, index ) => ( { - length: ( dimensions.width / 4 - 2 ), - offset: ( dimensions.width / 4 - 2 ) * index, - index - } ), [] ); - - const extractKey = useCallback( ( item, index ) => `${item}${index}`, [] ); - - return ( - - ); -}; - -export default GalleryImageList; diff --git a/components/Camera/Gallery/GalleryScreen.js b/components/Camera/Gallery/GalleryScreen.js deleted file mode 100644 index b32eb83f4b..0000000000 --- a/components/Camera/Gallery/GalleryScreen.js +++ /dev/null @@ -1,194 +0,0 @@ -// @flow - -import React, { useReducer, useEffect, useCallback, useRef } from "react"; -import { Platform, StatusBar } from "react-native"; -import { useNavigation } from "@react-navigation/native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import type { Node } from "react"; - -import { checkCameraRollPermissions } from "../../../utility/androidHelpers.android"; -import { viewStyles } from "../../../styles/camera/gallery"; -import GalleryHeader from "./GalleryHeader"; -import GalleryImageList from "./GalleryImageList"; -import CameraError from "../CameraError"; -import { fetchGalleryPhotos, checkForUniquePhotos } from "../../../utility/cameraRollHelpers"; -import { colors } from "../../../styles/global"; -import LoadingWheel from "../../UIComponents/LoadingWheel"; -import { useObservation } from "../../Providers/ObservationProvider"; - -const GalleryScreen = (): Node => { - const navigation = useNavigation( ); - const { setObservation } = useObservation(); - // eslint-disable-next-line no-shadow - const [state, dispatch] = useReducer( ( state, action ) => { - switch ( action.type ) { - case "SET_ALBUM": - return { - album: action.album, - photos: [], - error: null, - hasNextPage: true, - lastCursor: null, - stillFetching: false, - errorEvent: null, - photoSelectedLoading: false, - seen: new Set( ) - }; - case "FETCH_PHOTOS": - return { ...state, stillFetching: true }; - case "APPEND_PHOTOS": - return { - ...state, - photos: action.photos, - stillFetching: false, - hasNextPage: action.pageInfo.has_next_page, - lastCursor: action.pageInfo.end_cursor - }; - case "ERROR": - return { - ...state, - error: - action.error, - errorEvent: action.errorEvent - }; - case "SET_LOADING": - return { ...state, photoSelectedLoading: true }; - case "RESET_LOADING": - return { ...state, photoSelectedLoading: false }; - default: - throw new Error(); - } - }, { - album: null, - photos: [], - hasNextPage: true, - lastCursor: null, - stillFetching: false, - error: null, - errorEvent: null, - photoSelectedLoading: false, - seen: new Set( ) - } ); - - const { - album, - photos, - hasNextPage, - lastCursor, - stillFetching, - error, - errorEvent, - photoSelectedLoading, - seen - } = state; - - const photoCount = useRef( photos.length ); - photoCount.current = photos.length; - - const setLoading = useCallback( ( ) => dispatch( { type: "SET_LOADING" } ), [] ); - - const appendPhotos = useCallback( ( data, pageInfo ) => { - if ( data.length === 0 ) { - // this is triggered in certain edge cases, like when iOS user has "selected albums" - // permission but has not given Seek access to a single photo - dispatch( { type: "ERROR", error: "photos", errorEvent: null } ); - } else { - const uniquePhotos = checkForUniquePhotos( seen, data ); - dispatch( { type: "APPEND_PHOTOS", photos: photos.concat( uniquePhotos ), pageInfo } ); - } - }, [photos, seen] ); - - const handleFetchError = useCallback( ( e ) => { - if ( e.message === "Access to photo library was denied" ) { - dispatch( { type: "ERROR", error: "gallery", errorEvent: null } ); - } else { - dispatch( { type: "ERROR", error: "photos", errorEvent: e.message } ); - } - }, [] ); - - const fetchPhotos = useCallback( async ( ) => { - dispatch( { type: "FETCH_PHOTOS" } ); - - try { - const results = await fetchGalleryPhotos( album, lastCursor ); - appendPhotos( results.edges, results.page_info ); - } catch ( e ) { - handleFetchError( e ); - } - }, [album, lastCursor, appendPhotos, handleFetchError] ); - - const updateAlbum = useCallback( ( newAlbum: ?string ) => { - // prevent user from reloading the same album twice - if ( album === newAlbum ) { return; } - dispatch( { type: "SET_ALBUM", album: newAlbum } ); - }, [album] ); - - const onEndReached = useCallback( ( ) => { - if ( hasNextPage && !stillFetching ) { - fetchPhotos( ); - } - }, [hasNextPage, fetchPhotos, stillFetching] ); - - useEffect( ( ) => { - if ( photos.length === 0 ) { - fetchPhotos( ); - } - }, [photos.length, fetchPhotos] ); - - const initialFetch = useCallback( ( ) => { - // attempting to fix issue on some iOS devices where photos never appear - // assuming the above useEffect hook does not get called for some reason - const timer = setTimeout( ( ) => { - if ( photoCount.current === 0 ) { - fetchPhotos( ); - } - }, 3000 ); - - if ( photoCount.current > 0 ) { - clearTimeout( timer ); - } - return ( ) => clearTimeout( timer ); - }, [fetchPhotos] ); - - useEffect( ( ) => { - const requestAndroidPermissions = async ( ) => { - if ( Platform.OS === "android" ) { - const permission = await checkCameraRollPermissions( ); - if ( permission !== true ) { - dispatch( { type: "ERROR", error: "gallery", errorEvent: null } ); - } - } - }; - - navigation.addListener( "focus", ( ) => { - setObservation( null ); - requestAndroidPermissions( ); - if ( Platform.OS === "ios" ) { - initialFetch( ); - } - } ); - navigation.addListener( "blur", ( ) => dispatch( { type: "RESET_LOADING" } ) ); - }, [navigation, initialFetch, setObservation] ); - - const renderImageList = ( ) => { - if ( error ) { - return ; - } - // If there are no photos, render a loading wheel - if ( photos.length === 0 ) { - return ; - } - return ; - }; - - return ( - - - - {renderImageList( )} - {photoSelectedLoading && } - - ); -}; - -export default GalleryScreen; diff --git a/components/Challenges/ChallengeDetails/SpeciesNearbyChallenge.js b/components/Challenges/ChallengeDetails/SpeciesNearbyChallenge.js index 1bf8a709d1..397d94ba22 100644 --- a/components/Challenges/ChallengeDetails/SpeciesNearbyChallenge.js +++ b/components/Challenges/ChallengeDetails/SpeciesNearbyChallenge.js @@ -4,7 +4,7 @@ import React, { useEffect, useState, useCallback } from "react"; import { View } from "react-native"; import type { Node } from "react"; -import { viewStyles, textStyles } from "../../../styles/challenges/challengeDetails"; +import { viewStyles } from "../../../styles/challenges/challengeDetails"; import SpeciesNearbyList from "../../UIComponents/SpeciesNearby/SpeciesNearbyList"; import TapToLoad from "../../UIComponents/SpeciesNearby/TapToLoad"; import GreenText from "../../UIComponents/GreenText"; diff --git a/components/Match/MatchContainer.tsx b/components/Match/MatchContainer.tsx index f9f587f054..c2029b2ace 100644 --- a/components/Match/MatchContainer.tsx +++ b/components/Match/MatchContainer.tsx @@ -73,7 +73,7 @@ const MatchContainer = ( { {headerText} {screenType !== "unidentified" && ( - + {showScientificName ? scientificName : commonName} )} @@ -94,7 +94,7 @@ const MatchContainer = ( { )} {speciesIdentified && ( - + {i18n.t( "results.back" )} )} diff --git a/components/Modals/ReplacePhotoModal.tsx b/components/Modals/ReplacePhotoModal.tsx index 077d9457b6..13d96eb98f 100644 --- a/components/Modals/ReplacePhotoModal.tsx +++ b/components/Modals/ReplacePhotoModal.tsx @@ -68,6 +68,7 @@ const ReplacePhotoModal = ( { {i18n.t( "replace_photo.description" )}