mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2025-12-23 22:18:36 -05:00
Refactored location fetching for accurate locations (#1788)
* Refactor to use watchPosition
* Update useWatchPosition with permissions/retry
* Replace useUserLocation with useWatchPosition and fix tests; return userLocation from watch position hook
* Only update observation keys when there's an observation
* Improve TypeScript definitions
* Revert TypeScript commit
* Revert "Only update observation keys when there's an observation"
This reverts commit a4cd17a513.
* Code cleanup: make useWatchPosition more modular
* Code cleanup; location permission in ObsEdit instead of subcomponent
* Use correct accuracy in Camera photos
* Camera fixes
* Fixes to watching position in ObsEdit
* Fix useWatchPosition tests
* Fix tests
* Make sure state updates when renavigating to OsEdit; test fixes
This commit is contained in:
committed by
GitHub
parent
b06ffd0f5e
commit
abd4bcee23
@@ -27,6 +27,7 @@ node_modules/react-native/Libraries/polyfills/.*
|
||||
.*/node_modules/@react-native/community-cli-plugin/dist/commands/ram-bundle/index.js.flow
|
||||
.*/node_modules/@react-native/community-cli-plugin/dist/commands/bundle/index.js.flow
|
||||
.*/node_modules/@react-native/community-cli-plugin/dist/commands/bundle/buildBundle.js.flow
|
||||
.*/node_modules/metro/src/lib/JsonReporter.js.flow
|
||||
|
||||
[untyped]
|
||||
.*/node_modules/@react-native-community/cli/.*/.*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
|
||||
import Geolocation from "@react-native-community/geolocation";
|
||||
import RootDrawerNavigator from "navigation/rootDrawerNavigator";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
@@ -33,6 +34,10 @@ Realm.setLogLevel( "warn" );
|
||||
// https://stackoverflow.com/questions/69538962
|
||||
LogBox.ignoreLogs( ["new NativeEventEmitter"] );
|
||||
|
||||
const geolocationConfig = {
|
||||
skipPermissionRequests: true
|
||||
};
|
||||
|
||||
type Props = {
|
||||
// $FlowIgnore
|
||||
children?: unknown,
|
||||
@@ -69,6 +74,10 @@ const App = ( { children }: Props ): Node => {
|
||||
logger.info( "pickup" );
|
||||
}, [] );
|
||||
|
||||
// skipping location permissions here since we're manually showing
|
||||
// permission gates and don't want to pop up the native notification
|
||||
Geolocation.setRNConfiguration( geolocationConfig );
|
||||
|
||||
// this children prop is here for the sake of testing with jest
|
||||
// normally we would never do this in code
|
||||
return children || <RootDrawerNavigator />;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import Observation from "realmModels/Observation";
|
||||
import ObservationPhoto from "realmModels/ObservationPhoto";
|
||||
import { log } from "sharedHelpers/logger";
|
||||
import { useUserLocation } from "sharedHooks";
|
||||
import { useWatchPosition } from "sharedHooks";
|
||||
import useStore from "stores/useStore";
|
||||
|
||||
const logger = log.extend( "usePrepareStoreAndNavigate" );
|
||||
@@ -82,18 +82,22 @@ const usePrepareStoreAndNavigate = ( options: Options ): Function => {
|
||||
const observations = useStore( state => state.observations );
|
||||
const setSavingPhoto = useStore( state => state.setSavingPhoto );
|
||||
const setCameraState = useStore( state => state.setCameraState );
|
||||
const { userLocation } = useUserLocation( { untilAcc: 0, enabled: !!shouldFetchLocation } );
|
||||
const { userLocation } = useWatchPosition( {
|
||||
shouldFetchLocation
|
||||
} );
|
||||
|
||||
const numOfObsPhotos = currentObservation?.observationPhotos?.length || 0;
|
||||
|
||||
const createObsWithCameraPhotos = useCallback( async ( localFilePaths, visionResult ) => {
|
||||
const newObservation = await Observation.new( );
|
||||
|
||||
// location is needed for fetching online Suggestions on the next screen
|
||||
// 20240709 amanda - this is temporary since we'll want to move this code to
|
||||
// Suggestions after the changes to permissions github issue is complete, and
|
||||
// we'll be able to updateObservationKeys on the observation there
|
||||
if ( userLocation?.latitude ) {
|
||||
newObservation.latitude = userLocation?.latitude;
|
||||
newObservation.longitude = userLocation?.longitude;
|
||||
newObservation.positional_accuracy = userLocation?.accuracy;
|
||||
newObservation.positional_accuracy = userLocation?.positional_accuracy;
|
||||
}
|
||||
newObservation.observationPhotos = await ObservationPhoto
|
||||
.createObsPhotosWithPosition( localFilePaths, {
|
||||
@@ -116,9 +120,7 @@ const usePrepareStoreAndNavigate = ( options: Options ): Function => {
|
||||
addPhotoPermissionResult,
|
||||
cameraUris,
|
||||
setObservations,
|
||||
userLocation?.accuracy,
|
||||
userLocation?.latitude,
|
||||
userLocation?.longitude
|
||||
userLocation
|
||||
] );
|
||||
|
||||
const updateObsWithCameraPhotos = useCallback( async ( ) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import classnames from "classnames";
|
||||
import { MAX_PHOTOS_ALLOWED } from "components/Camera/StandardCamera/StandardCamera";
|
||||
import {
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
import { MAX_SOUNDS_ALLOWED } from "components/SoundRecorder/SoundRecorder";
|
||||
import { Pressable, View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import React from "react";
|
||||
import { useTheme } from "react-native-paper";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
|
||||
@@ -33,10 +32,8 @@ type Props = {
|
||||
},
|
||||
uuid: string
|
||||
}>,
|
||||
updateObservationKeys: Function,
|
||||
renderPermissionsGate: Function,
|
||||
requestPermissions: Function,
|
||||
hasPermissions: boolean
|
||||
onLocationPress: ( ) => void,
|
||||
updateObservationKeys: Function
|
||||
}
|
||||
|
||||
const EvidenceSection = ( {
|
||||
@@ -47,10 +44,8 @@ const EvidenceSection = ( {
|
||||
setShowAddEvidenceSheet,
|
||||
showAddEvidenceSheet,
|
||||
observationSounds,
|
||||
updateObservationKeys,
|
||||
renderPermissionsGate,
|
||||
requestPermissions,
|
||||
hasPermissions
|
||||
onLocationPress,
|
||||
updateObservationKeys
|
||||
}: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const theme = useTheme( );
|
||||
@@ -60,21 +55,6 @@ const EvidenceSection = ( {
|
||||
// the API
|
||||
const obsPhotos = currentObservation?.observationPhotos || currentObservation?.observation_photos;
|
||||
const obsSounds = currentObservation?.observationSounds || currentObservation?.observation_sounds;
|
||||
const navigation = useNavigation( );
|
||||
|
||||
const navToLocationPicker = useCallback( ( ) => {
|
||||
navigation.navigate( "LocationPicker", { goBackOnSave: true } );
|
||||
}, [navigation] );
|
||||
|
||||
const onLocationPress = ( ) => {
|
||||
// If we have location permissions, navigate to the location picker
|
||||
if ( hasPermissions ) {
|
||||
navToLocationPicker();
|
||||
} else {
|
||||
// If we don't have location permissions, request them
|
||||
requestPermissions( );
|
||||
}
|
||||
};
|
||||
|
||||
const latitude = currentObservation?.latitude;
|
||||
const longitude = currentObservation?.longitude;
|
||||
@@ -176,15 +156,6 @@ const EvidenceSection = ( {
|
||||
}
|
||||
</View>
|
||||
</Pressable>
|
||||
{renderPermissionsGate( {
|
||||
// If the user does not give location permissions in any form,
|
||||
// navigate to the location picker (if granted we just continue fetching the location)
|
||||
onRequestDenied: navToLocationPicker,
|
||||
onRequestBlocked: navToLocationPicker,
|
||||
onModalHide: ( ) => {
|
||||
if ( !hasPermissions ) navToLocationPicker();
|
||||
}
|
||||
} )}
|
||||
<DatePicker
|
||||
currentObservation={currentObservation}
|
||||
updateObservationKeys={updateObservationKeys}
|
||||
|
||||
@@ -12,27 +12,28 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
|
||||
import useCurrentObservationLocation from "sharedHooks/useCurrentObservationLocation";
|
||||
import useLocationPermission from "sharedHooks/useLocationPermission.tsx";
|
||||
import useStore from "stores/useStore";
|
||||
|
||||
import EvidenceSection from "./EvidenceSection";
|
||||
|
||||
type Props = {
|
||||
currentObservation: Object,
|
||||
isFetchingLocation: boolean,
|
||||
onLocationPress: ( ) => void,
|
||||
passesEvidenceTest: boolean,
|
||||
setPassesEvidenceTest: Function,
|
||||
currentObservation: Object,
|
||||
updateObservationKeys: Function
|
||||
}
|
||||
|
||||
const EvidenceSectionContainer = ( {
|
||||
setPassesEvidenceTest,
|
||||
passesEvidenceTest,
|
||||
currentObservation,
|
||||
isFetchingLocation,
|
||||
onLocationPress,
|
||||
passesEvidenceTest,
|
||||
setPassesEvidenceTest,
|
||||
updateObservationKeys
|
||||
}: Props ): Node => {
|
||||
const cameraRollUris = useStore( state => state.cameraRollUris );
|
||||
@@ -42,55 +43,13 @@ const EvidenceSectionContainer = ( {
|
||||
const hasImportedPhotos = hasPhotos && cameraRollUris.length === 0;
|
||||
const observationPhotos = currentObservation?.observationPhotos || [];
|
||||
const observationSounds = currentObservation?.observationSounds || [];
|
||||
const mountedRef = useRef( true );
|
||||
|
||||
const [showAddEvidenceSheet, setShowAddEvidenceSheet] = useState( false );
|
||||
const [currentPlaceGuess, setCurrentPlaceGuess] = useState( );
|
||||
|
||||
const [
|
||||
shouldRetryCurrentObservationLocation,
|
||||
setShouldRetryCurrentObservationLocation
|
||||
] = useState( true );
|
||||
const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission( );
|
||||
|
||||
// Hook version of componentWillUnmount. We use a ref to track mounted
|
||||
// state (not useState, which might get frozen in a closure for other
|
||||
// useEffects), and set it to false in the cleanup cleanup function. The
|
||||
// effect has an empty dependency array so it should only run when the
|
||||
// component mounts and when it unmounts, unlike in the cleanup effects of
|
||||
// other hooks, which will run when any of there dependency values change,
|
||||
// and maybe even before other hooks execute. If we ever need to do this
|
||||
// again we could probably wrap this into its own hook, like useMounted
|
||||
// ( ).
|
||||
useEffect( ( ) => {
|
||||
mountedRef.current = true;
|
||||
return function cleanup( ) {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [mountedRef] );
|
||||
|
||||
const {
|
||||
hasLocation,
|
||||
isFetchingLocation
|
||||
} = useCurrentObservationLocation(
|
||||
mountedRef,
|
||||
currentObservation,
|
||||
updateObservationKeys,
|
||||
hasPermissions,
|
||||
{
|
||||
retry: hasPermissions && shouldRetryCurrentObservationLocation
|
||||
}
|
||||
);
|
||||
|
||||
const latitude = currentObservation?.latitude;
|
||||
const longitude = currentObservation?.longitude;
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( latitude ) {
|
||||
setShouldRetryCurrentObservationLocation( false );
|
||||
}
|
||||
}, [latitude] );
|
||||
|
||||
const hasPhotoOrSound = useMemo( ( ) => {
|
||||
if ( currentObservation?.observationPhotos?.length > 0
|
||||
|| currentObservation?.observationSounds?.length > 0 ) {
|
||||
@@ -114,9 +73,7 @@ const EvidenceSectionContainer = ( {
|
||||
validPositionalAccuracy = true;
|
||||
}
|
||||
|
||||
if (
|
||||
hasLocation
|
||||
&& coordinatesExist
|
||||
if ( coordinatesExist
|
||||
&& latitudeInRange
|
||||
&& longitudeInRange
|
||||
&& validPositionalAccuracy
|
||||
@@ -128,7 +85,6 @@ const EvidenceSectionContainer = ( {
|
||||
currentObservation,
|
||||
longitude,
|
||||
latitude,
|
||||
hasLocation,
|
||||
isNewObs,
|
||||
hasImportedPhotos
|
||||
] );
|
||||
@@ -210,17 +166,15 @@ const EvidenceSectionContainer = ( {
|
||||
return (
|
||||
<EvidenceSection
|
||||
currentObservation={currentObservation}
|
||||
updateObservationKeys={updateObservationKeys}
|
||||
locationTextClassNames={locationTextClassNames}
|
||||
passesEvidenceTest={fullEvidenceTest}
|
||||
isFetchingLocation={isFetchingLocation}
|
||||
locationTextClassNames={locationTextClassNames}
|
||||
observationPhotos={observationPhotos}
|
||||
observationSounds={observationSounds}
|
||||
onLocationPress={onLocationPress}
|
||||
passesEvidenceTest={fullEvidenceTest}
|
||||
setShowAddEvidenceSheet={setShowAddEvidenceSheet}
|
||||
showAddEvidenceSheet={showAddEvidenceSheet}
|
||||
observationSounds={observationSounds}
|
||||
hasPermissions={hasPermissions}
|
||||
renderPermissionsGate={renderPermissionsGate}
|
||||
requestPermissions={requestPermissions}
|
||||
updateObservationKeys={updateObservationKeys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// @flow
|
||||
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { useIsFocused, useNavigation } from "@react-navigation/native";
|
||||
import { ViewWrapper } from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
|
||||
import { useCurrentUser } from "sharedHooks";
|
||||
import shouldFetchObservationLocation from "sharedHelpers/shouldFetchObservationLocation.ts";
|
||||
import { useCurrentUser, useLocationPermission, useWatchPosition } from "sharedHooks";
|
||||
import useStore from "stores/useStore";
|
||||
import { getShadowForColor } from "styles/global";
|
||||
import colors from "styles/tailwindColors";
|
||||
@@ -23,6 +24,7 @@ const DROP_SHADOW = getShadowForColor( colors.black, {
|
||||
} );
|
||||
|
||||
const ObsEdit = ( ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const currentObservation = useStore( state => state.currentObservation );
|
||||
const currentObservationIndex = useStore( state => state.currentObservationIndex );
|
||||
const observations = useStore( state => state.observations );
|
||||
@@ -33,6 +35,51 @@ const ObsEdit = ( ): Node => {
|
||||
const [resetScreen, setResetScreen] = useState( false );
|
||||
const isFocused = useIsFocused( );
|
||||
const currentUser = useCurrentUser( );
|
||||
const [shouldFetchLocation, setShouldFetchLocation] = useState( false );
|
||||
const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission( );
|
||||
|
||||
const {
|
||||
isFetchingLocation,
|
||||
userLocation
|
||||
} = useWatchPosition( {
|
||||
shouldFetchLocation
|
||||
} );
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( shouldFetchObservationLocation( currentObservation ) ) {
|
||||
setShouldFetchLocation( true );
|
||||
}
|
||||
}, [currentObservation] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( userLocation?.latitude ) {
|
||||
updateObservationKeys( userLocation );
|
||||
}
|
||||
}, [userLocation, updateObservationKeys] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
// this is needed to make sure watchPosition is called when a user
|
||||
// navigates to ObsEdit a second time during an app session,
|
||||
// otherwise, state will indicate that fetching is not needed
|
||||
const unsubscribe = navigation.addListener( "blur", ( ) => {
|
||||
setShouldFetchLocation( false );
|
||||
} );
|
||||
return unsubscribe;
|
||||
}, [navigation] );
|
||||
|
||||
const navToLocationPicker = useCallback( ( ) => {
|
||||
navigation.navigate( "LocationPicker", { goBackOnSave: true } );
|
||||
}, [navigation] );
|
||||
|
||||
const onLocationPress = ( ) => {
|
||||
// If we have location permissions, navigate to the location picker
|
||||
if ( hasPermissions ) {
|
||||
navToLocationPicker();
|
||||
} else {
|
||||
// If we don't have location permissions, request them
|
||||
requestPermissions( );
|
||||
}
|
||||
};
|
||||
|
||||
if ( !isFocused ) return null;
|
||||
|
||||
@@ -67,6 +114,8 @@ const ObsEdit = ( ): Node => {
|
||||
)}
|
||||
<EvidenceSectionContainer
|
||||
currentObservation={currentObservation}
|
||||
isFetchingLocation={isFetchingLocation}
|
||||
onLocationPress={onLocationPress}
|
||||
passesEvidenceTest={passesEvidenceTest}
|
||||
setPassesEvidenceTest={setPassesEvidenceTest}
|
||||
updateObservationKeys={updateObservationKeys}
|
||||
@@ -96,6 +145,15 @@ const ObsEdit = ( ): Node => {
|
||||
passesIdentificationTest={passesIdentificationTest}
|
||||
setCurrentObservationIndex={setCurrentObservationIndex}
|
||||
/>
|
||||
{renderPermissionsGate( {
|
||||
// If the user does not give location permissions in any form,
|
||||
// navigate to the location picker (if granted we just continue fetching the location)
|
||||
onRequestDenied: navToLocationPicker,
|
||||
onRequestBlocked: navToLocationPicker,
|
||||
onModalHide: ( ) => {
|
||||
if ( !hasPermissions ) navToLocationPicker();
|
||||
}
|
||||
} )}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useAuthenticatedQuery,
|
||||
useCurrentUser,
|
||||
useTranslation,
|
||||
useUserLocation
|
||||
useWatchPosition
|
||||
} from "sharedHooks";
|
||||
import useLocationPermission from "sharedHooks/useLocationPermission.tsx";
|
||||
|
||||
@@ -27,10 +27,9 @@ const ProjectsContainer = ( ) => {
|
||||
const { t } = useTranslation( );
|
||||
const [apiParams, setApiParams] = useState( { } );
|
||||
const [currentTabId, setCurrentTabId] = useState( TAB_ID.JOINED );
|
||||
const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission();
|
||||
const { userLocation } = useUserLocation( {
|
||||
skipName: true,
|
||||
permissionsGranted: hasPermissions
|
||||
const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission( );
|
||||
const { userLocation } = useWatchPosition( {
|
||||
shouldFetchLocation: hasPermissions
|
||||
} );
|
||||
|
||||
const {
|
||||
@@ -52,7 +51,8 @@ const ProjectsContainer = ( ) => {
|
||||
} else if ( currentTabId === TAB_ID.NEARBY && userLocation ) {
|
||||
setApiParams( {
|
||||
lat: userLocation.latitude,
|
||||
lng: userLocation.longitude
|
||||
lng: userLocation.longitude,
|
||||
radius: 50
|
||||
} );
|
||||
}
|
||||
}, [
|
||||
@@ -109,7 +109,7 @@ const ProjectsContainer = ( ) => {
|
||||
hasPermissions={hasPermissions}
|
||||
requestPermissions={requestPermissions}
|
||||
/>
|
||||
{renderPermissionsGate()}
|
||||
{renderPermissionsGate( )}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
35
src/sharedHelpers/shouldFetchObservationLocation.ts
Normal file
35
src/sharedHelpers/shouldFetchObservationLocation.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { galleryPhotosPath } from "appConstants/paths.ts";
|
||||
import RNFS from "react-native-fs";
|
||||
import { RealmObservation } from "realmModels/types.d.ts";
|
||||
import {
|
||||
TARGET_POSITIONAL_ACCURACY
|
||||
} from "sharedHooks/useWatchPosition.ts";
|
||||
|
||||
const shouldFetchObservationLocation = ( observation: RealmObservation ) => {
|
||||
const latitude = observation?.latitude;
|
||||
const longitude = observation?.longitude;
|
||||
const hasLocation = !!( latitude && longitude );
|
||||
const originalPhotoUri = observation?.observationPhotos
|
||||
&& observation?.observationPhotos[0]?.originalPhotoUri;
|
||||
const isGalleryPhoto = originalPhotoUri?.includes( galleryPhotosPath );
|
||||
// Shared photo paths will look something like Shared/AppGroup/sdgsdgsdgk
|
||||
const isSharedPhoto = (
|
||||
originalPhotoUri && !originalPhotoUri.includes( RNFS.DocumentDirectoryPath )
|
||||
);
|
||||
const isNewObservation = (
|
||||
!observation?._created_at
|
||||
&& !observation?._synced_at
|
||||
);
|
||||
const accGoodEnough = (
|
||||
observation?.positional_accuracy
|
||||
&& observation.positional_accuracy <= TARGET_POSITIONAL_ACCURACY
|
||||
);
|
||||
|
||||
return observation
|
||||
&& isNewObservation
|
||||
&& ( !hasLocation || !accGoodEnough )
|
||||
&& !isGalleryPhoto
|
||||
&& !isSharedPhoto;
|
||||
};
|
||||
|
||||
export default shouldFetchObservationLocation;
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as useAuthenticatedMutation } from "./useAuthenticatedMutation";
|
||||
export { default as useAuthenticatedQuery } from "./useAuthenticatedQuery";
|
||||
export { default as useCurrentObservationLocation } from "./useCurrentObservationLocation";
|
||||
export { default as useCurrentUser } from "./useCurrentUser";
|
||||
export { default as useDebugMode } from "./useDebugMode";
|
||||
export { default as useDeviceOrientation } from "./useDeviceOrientation";
|
||||
@@ -12,6 +11,7 @@ export { default as useInterval } from "./useInterval";
|
||||
export { default as useIsConnected } from "./useIsConnected";
|
||||
export { default as useLocalObservation } from "./useLocalObservation";
|
||||
export { default as useLocalObservations } from "./useLocalObservations";
|
||||
export { default as useLocationPermission } from "./useLocationPermission";
|
||||
export { default as useObservationsUpdates } from "./useObservationsUpdates";
|
||||
export { default as useObservationUpdatesWhenFocused } from "./useObservationUpdatesWhenFocused";
|
||||
export { default as useRemoteObservation } from "./useRemoteObservation";
|
||||
@@ -20,5 +20,5 @@ export { default as useStoredLayout } from "./useStoredLayout";
|
||||
export { default as useTaxon } from "./useTaxon";
|
||||
export { default as useTaxonSearch } from "./useTaxonSearch";
|
||||
export { default as useTranslation } from "./useTranslation";
|
||||
export { default as useUserLocation } from "./useUserLocation";
|
||||
export { default as useUserMe } from "./useUserMe";
|
||||
export { default as useWatchPosition } from "./useWatchPosition";
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import { galleryPhotosPath } from "appConstants/paths.ts";
|
||||
import {
|
||||
useEffect, useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import RNFS from "react-native-fs";
|
||||
|
||||
// Please don't change this to an aliased path or the e2e mock will not get
|
||||
// used in our e2e tests on Github Actions
|
||||
import fetchUserLocation from "../sharedHelpers/fetchUserLocation";
|
||||
|
||||
const INITIAL_POSITIONAL_ACCURACY = 99999;
|
||||
const TARGET_POSITIONAL_ACCURACY = 10;
|
||||
export const LOCATION_FETCH_INTERVAL = 1000;
|
||||
|
||||
// Primarily fetches the current location for a new observation and returns
|
||||
// isFetchingLocation to tell the consumer whether this process is happening.
|
||||
// If currentObservation is not new, it will not fetch location and return
|
||||
// information about the current observation's location
|
||||
const useCurrentObservationLocation = (
|
||||
// $FlowIgnore
|
||||
mountedRef: unknown,
|
||||
currentObservation: Object,
|
||||
updateObservationKeys: Function,
|
||||
hasPermissions: boolean,
|
||||
options: Object = { }
|
||||
): Object => {
|
||||
const latitude = currentObservation?.latitude;
|
||||
const longitude = currentObservation?.longitude;
|
||||
const hasLocation = !!( latitude && longitude );
|
||||
const originalPhotoUri = currentObservation?.observationPhotos
|
||||
&& currentObservation?.observationPhotos[0]?.originalPhotoUri;
|
||||
const isGalleryPhoto = originalPhotoUri?.includes( galleryPhotosPath );
|
||||
// Shared photo paths will look something like Shared/AppGroup/sdgsdgsdgk
|
||||
const isSharedPhoto = (
|
||||
originalPhotoUri && !originalPhotoUri.includes( RNFS.DocumentDirectoryPath )
|
||||
);
|
||||
const isNewObservation = (
|
||||
!currentObservation?._created_at
|
||||
&& !currentObservation?._synced_at
|
||||
);
|
||||
const accGoodEnough = (
|
||||
currentObservation?.positional_accuracy
|
||||
&& currentObservation.positional_accuracy <= TARGET_POSITIONAL_ACCURACY
|
||||
);
|
||||
const locationNotSetYet = useRef( true );
|
||||
const prevObservation = useRef( currentObservation );
|
||||
|
||||
const [shouldFetchLocation, setShouldFetchLocation] = useState(
|
||||
currentObservation
|
||||
&& isNewObservation
|
||||
&& ( !hasLocation || !accGoodEnough )
|
||||
&& !isGalleryPhoto
|
||||
&& !isSharedPhoto
|
||||
);
|
||||
|
||||
const [numLocationFetches, setNumLocationFetches] = useState( 0 );
|
||||
const [fetchingLocation, setFetchingLocation] = useState( false );
|
||||
const [positionalAccuracy, setPositionalAccuracy] = useState( INITIAL_POSITIONAL_ACCURACY );
|
||||
const [lastLocationFetchTime, setLastLocationFetchTime] = useState( 0 );
|
||||
const [currentLocation, setCurrentLocation] = useState( null );
|
||||
|
||||
useEffect( () => {
|
||||
if ( locationNotSetYet.current ) {
|
||||
// Don't run if it's the first render
|
||||
locationNotSetYet.current = false;
|
||||
return;
|
||||
} if ( prevObservation.current !== currentObservation ) {
|
||||
// Don't run if observation was changed (only when location was changed)
|
||||
prevObservation.current = currentObservation;
|
||||
return;
|
||||
}
|
||||
|
||||
updateObservationKeys( {
|
||||
latitude: currentLocation?.latitude,
|
||||
longitude: currentLocation?.longitude,
|
||||
positional_accuracy: currentLocation?.positional_accuracy
|
||||
} );
|
||||
|
||||
prevObservation.current = currentObservation;
|
||||
}, [currentLocation, currentObservation, updateObservationKeys] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( !currentObservation ) return;
|
||||
if ( !shouldFetchLocation ) return;
|
||||
if ( fetchingLocation ) return;
|
||||
|
||||
const fetchLocation = async () => {
|
||||
// If the component is gone, you won't be able to updated it
|
||||
if ( !mountedRef.current ) return;
|
||||
if ( !shouldFetchLocation ) return;
|
||||
|
||||
if ( !hasPermissions ) {
|
||||
setFetchingLocation( false );
|
||||
setShouldFetchLocation( false );
|
||||
return;
|
||||
}
|
||||
const location = await fetchUserLocation( );
|
||||
|
||||
// If we're still receiving location updates and location is blank,
|
||||
// then we don't know where we are any more and the obs should update
|
||||
// to reflect that
|
||||
if (
|
||||
location?.latitude !== currentObservation.latitude
|
||||
|| location?.longitude !== currentObservation.longitude
|
||||
|| location?.positional_accuracy !== currentObservation.positional_accuracy
|
||||
) {
|
||||
// Cannot call updateObservationKeys directly from here, since fetchUserLocation might take
|
||||
// a while to return, in the meantime the current copy of the observation might have
|
||||
// changed, so we update the observation from useEffect of currentLocation, so it will
|
||||
// always have the latest copy of the current observation (see GH issue #584)
|
||||
setCurrentLocation( location );
|
||||
}
|
||||
|
||||
setFetchingLocation( false );
|
||||
|
||||
// The local state version of positionalAccuracy needs to be a number,
|
||||
// so don't set it to
|
||||
const newPositionalAccuracy = location?.positional_accuracy || INITIAL_POSITIONAL_ACCURACY;
|
||||
setPositionalAccuracy( newPositionalAccuracy );
|
||||
if ( newPositionalAccuracy > TARGET_POSITIONAL_ACCURACY ) {
|
||||
// This is just here to make absolutely sure the effect runs again in a second
|
||||
setTimeout(
|
||||
( ) => setNumLocationFetches( numFetches => numFetches + 1 ),
|
||||
LOCATION_FETCH_INTERVAL
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
// If we're already fetching we don't need to fetch again
|
||||
!fetchingLocation
|
||||
// We only need to fetch when we're above the target
|
||||
&& positionalAccuracy > TARGET_POSITIONAL_ACCURACY
|
||||
// Don't fetch location more than once a second
|
||||
&& Date.now() - lastLocationFetchTime >= LOCATION_FETCH_INTERVAL
|
||||
) {
|
||||
setFetchingLocation( true );
|
||||
setLastLocationFetchTime( Date.now() );
|
||||
fetchLocation( );
|
||||
} else if ( positionalAccuracy <= TARGET_POSITIONAL_ACCURACY ) {
|
||||
setShouldFetchLocation( false );
|
||||
}
|
||||
}, [
|
||||
currentObservation,
|
||||
fetchingLocation,
|
||||
hasPermissions,
|
||||
lastLocationFetchTime,
|
||||
mountedRef,
|
||||
numLocationFetches,
|
||||
positionalAccuracy,
|
||||
setFetchingLocation,
|
||||
shouldFetchLocation,
|
||||
updateObservationKeys
|
||||
] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( options.retry && !shouldFetchLocation ) {
|
||||
setTimeout( ( ) => {
|
||||
setShouldFetchLocation( true );
|
||||
}, LOCATION_FETCH_INTERVAL + 1 );
|
||||
}
|
||||
}, [
|
||||
options.retry,
|
||||
setShouldFetchLocation,
|
||||
shouldFetchLocation
|
||||
] );
|
||||
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
positionalAccuracy: currentObservation?.positional_accuracy,
|
||||
hasLocation,
|
||||
// Internally we're tracking isFetching when one of potentially many
|
||||
// location requests is in flight, but this tells the external consumer
|
||||
// whether the overall location fetching process is happening
|
||||
isFetchingLocation: shouldFetchLocation,
|
||||
numLocationFetches
|
||||
};
|
||||
};
|
||||
|
||||
export default useCurrentObservationLocation;
|
||||
@@ -1,168 +0,0 @@
|
||||
import Geolocation, {
|
||||
GeolocationError,
|
||||
GeolocationResponse
|
||||
} from "@react-native-community/geolocation";
|
||||
import {
|
||||
LOCATION_PERMISSIONS,
|
||||
permissionResultFromMultiple
|
||||
} from "components/SharedComponents/PermissionGateContainer.tsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
checkMultiple,
|
||||
RESULTS
|
||||
} from "react-native-permissions";
|
||||
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
|
||||
// import { log } from "sharedHelpers/logger";
|
||||
|
||||
// const logger = log.extend( "userUserLocation" );
|
||||
|
||||
// Max time to wait while fetching current location
|
||||
const CURRENT_LOCATION_TIMEOUT_MS = 30000;
|
||||
|
||||
interface UserLocation {
|
||||
name?: string,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
accuracy?: number
|
||||
}
|
||||
|
||||
interface UserLocationResponse {
|
||||
userLocation?: UserLocation,
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
function useUserLocation(
|
||||
options?: {
|
||||
enabled?: boolean,
|
||||
skipName?: boolean,
|
||||
permissionsGranted?: boolean,
|
||||
untilAcc?: number | undefined
|
||||
}
|
||||
): UserLocationResponse {
|
||||
const {
|
||||
enabled: enabledOpt,
|
||||
skipName = false,
|
||||
permissionsGranted: permissionsGrantedProp = false,
|
||||
untilAcc
|
||||
} = options || {};
|
||||
const enabled = enabledOpt !== false;
|
||||
const [userLocation, setUserLocation] = useState<UserLocation | undefined>( undefined );
|
||||
// logger.debug( `userLocation?.latitude: ${userLocation?.latitude}` );
|
||||
const [isLoading, setIsLoading] = useState( true );
|
||||
const [permissionsGranted, setPermissionsGranted] = useState( permissionsGrantedProp );
|
||||
const [permissionsChecked, setPermissionsChecked] = useState( false );
|
||||
const fetchingLocation = useRef<Boolean>( false );
|
||||
const [accGoodEnough, setAccGoodEnough] = useState( false );
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( permissionsGrantedProp === true && permissionsGranted === false ) {
|
||||
setPermissionsGranted( true );
|
||||
}
|
||||
}, [permissionsGranted, permissionsGrantedProp] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
async function checkPermissions() {
|
||||
const permissionsResult = permissionResultFromMultiple(
|
||||
await checkMultiple( LOCATION_PERMISSIONS )
|
||||
);
|
||||
if ( permissionsResult === RESULTS.GRANTED ) {
|
||||
setPermissionsGranted( true );
|
||||
} else {
|
||||
console.warn(
|
||||
"Location permissions have not been granted. You probably need to use a PermissionGate"
|
||||
);
|
||||
}
|
||||
setPermissionsChecked( true );
|
||||
}
|
||||
checkPermissions( );
|
||||
}, [] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const fetchLocation = async ( ) => {
|
||||
fetchingLocation.current = true;
|
||||
setIsLoading( true );
|
||||
|
||||
const success = async ( position: GeolocationResponse ) => {
|
||||
// logger.debug(
|
||||
// `getCurrentPosition success, position?.coords?.latitude: ${position?.coords?.latitude}`
|
||||
// );
|
||||
const { coords } = position;
|
||||
let locationName;
|
||||
if ( !skipName ) {
|
||||
locationName = await fetchPlaceName( coords.latitude, coords.longitude );
|
||||
}
|
||||
setUserLocation( {
|
||||
name: locationName || undefined,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
accuracy: coords.accuracy
|
||||
} );
|
||||
setIsLoading( false );
|
||||
fetchingLocation.current = false;
|
||||
setAccGoodEnough( true );
|
||||
if ( typeof ( untilAcc ) === "number" && coords?.accuracy && coords?.accuracy > untilAcc ) {
|
||||
setTimeout( ( ) => setAccGoodEnough( false ), 1000 );
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: set geolocation fetch error
|
||||
const failure = ( error: GeolocationError ) => {
|
||||
console.warn( `useUserLocation: ${error.message} (${error.code})` );
|
||||
setIsLoading( false );
|
||||
fetchingLocation.current = false;
|
||||
setAccGoodEnough( true );
|
||||
};
|
||||
|
||||
const gcpOptions = {
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 0,
|
||||
timeout: CURRENT_LOCATION_TIMEOUT_MS
|
||||
};
|
||||
|
||||
// TODO refactor to use fetchUserLocation, which is promise-ified and
|
||||
// mockable in an e2e context
|
||||
Geolocation.getCurrentPosition( success, failure, gcpOptions );
|
||||
};
|
||||
|
||||
if ( permissionsChecked && !permissionsGranted ) {
|
||||
setIsLoading( false );
|
||||
fetchingLocation.current = false;
|
||||
} else if (
|
||||
// we have permission
|
||||
permissionsGranted
|
||||
// and we're not already fetching
|
||||
&& !fetchingLocation.current
|
||||
// and we're not waiting OR we are and acc is above threshold
|
||||
&& !accGoodEnough
|
||||
&& enabled
|
||||
) {
|
||||
fetchingLocation.current = true;
|
||||
fetchLocation( );
|
||||
}
|
||||
}, [
|
||||
accGoodEnough,
|
||||
enabled,
|
||||
permissionsChecked,
|
||||
permissionsGranted,
|
||||
skipName,
|
||||
untilAcc
|
||||
] );
|
||||
|
||||
// When the consumer tells us we no longer need to fetch location, reset the
|
||||
// user location so it's not stale the next time we need to fetch
|
||||
useEffect( ( ) => {
|
||||
// logger.debug( "enabled effect" );
|
||||
if ( enabled === false ) {
|
||||
// logger.debug( "enabled effect, disabled resetting" );
|
||||
setUserLocation( undefined );
|
||||
setAccGoodEnough( false );
|
||||
}
|
||||
}, [enabled] );
|
||||
|
||||
return {
|
||||
userLocation,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
|
||||
export default useUserLocation;
|
||||
102
src/sharedHooks/useWatchPosition.ts
Normal file
102
src/sharedHooks/useWatchPosition.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import Geolocation, {
|
||||
GeolocationError,
|
||||
GeolocationResponse
|
||||
} from "@react-native-community/geolocation";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export const TARGET_POSITIONAL_ACCURACY = 10;
|
||||
|
||||
export const TIMEOUT = 2000;
|
||||
|
||||
interface UserLocation {
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
positional_accuracy: number
|
||||
}
|
||||
|
||||
const geolocationOptions = {
|
||||
distanceFilter: 0,
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 0,
|
||||
timeout: TIMEOUT
|
||||
};
|
||||
|
||||
const useWatchPosition = ( options: {
|
||||
shouldFetchLocation: boolean
|
||||
} ) => {
|
||||
const navigation = useNavigation( );
|
||||
const [currentPosition, setCurrentPosition] = useState<string | null>( null );
|
||||
const [subscriptionId, setSubscriptionId] = useState<number | null>( null );
|
||||
const [userLocation, setUserLocation] = useState<UserLocation | null>( null );
|
||||
const { shouldFetchLocation } = options;
|
||||
|
||||
const [isFetchingLocation, setIsFetchingLocation] = useState<boolean>(
|
||||
shouldFetchLocation
|
||||
);
|
||||
|
||||
const watchPosition = ( ) => {
|
||||
const success = ( position: GeolocationResponse ) => {
|
||||
setCurrentPosition( position );
|
||||
};
|
||||
|
||||
const failure = ( error: GeolocationError ) => {
|
||||
console.warn( error, ": useWatchPosition error" );
|
||||
setIsFetchingLocation( false );
|
||||
};
|
||||
|
||||
try {
|
||||
const watchID = Geolocation.watchPosition(
|
||||
success,
|
||||
failure,
|
||||
geolocationOptions
|
||||
);
|
||||
setSubscriptionId( watchID );
|
||||
} catch ( error ) {
|
||||
failure( error );
|
||||
}
|
||||
};
|
||||
|
||||
const stopWatch = useCallback( id => {
|
||||
Geolocation.clearWatch( id );
|
||||
setSubscriptionId( null );
|
||||
setCurrentPosition( null );
|
||||
setIsFetchingLocation( false );
|
||||
}, [] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( !currentPosition ) { return; }
|
||||
const newLocation = {
|
||||
latitude: currentPosition?.coords?.latitude,
|
||||
longitude: currentPosition?.coords?.longitude,
|
||||
positional_accuracy: currentPosition?.coords?.accuracy
|
||||
};
|
||||
setUserLocation( newLocation );
|
||||
if ( currentPosition?.coords?.accuracy < TARGET_POSITIONAL_ACCURACY ) {
|
||||
stopWatch( subscriptionId );
|
||||
}
|
||||
}, [currentPosition, stopWatch, subscriptionId] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( shouldFetchLocation ) {
|
||||
watchPosition( );
|
||||
}
|
||||
}, [shouldFetchLocation] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const unsubscribe = navigation.addListener( "blur", ( ) => {
|
||||
setSubscriptionId( null );
|
||||
setCurrentPosition( null );
|
||||
setIsFetchingLocation( false );
|
||||
setUserLocation( null );
|
||||
} );
|
||||
return unsubscribe;
|
||||
}, [navigation] );
|
||||
|
||||
return {
|
||||
isFetchingLocation,
|
||||
userLocation
|
||||
};
|
||||
};
|
||||
|
||||
export default useWatchPosition;
|
||||
@@ -4,7 +4,7 @@ import { screen, waitFor } from "@testing-library/react-native";
|
||||
import ObsEdit from "components/ObsEdit/ObsEdit";
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
import React from "react";
|
||||
import { LOCATION_FETCH_INTERVAL } from "sharedHooks/useCurrentObservationLocation";
|
||||
import ReactNativePermissions from "react-native-permissions";
|
||||
import useStore from "stores/useStore";
|
||||
import factory from "tests/factory";
|
||||
import faker from "tests/helpers/faker";
|
||||
@@ -44,6 +44,12 @@ beforeEach( async ( ) => {
|
||||
locale: "en"
|
||||
} );
|
||||
await signIn( mockUser, { realm: global.mockRealms[__filename] } );
|
||||
const mockedPermissions = {
|
||||
"ios.permission.LOCATION": "granted"
|
||||
};
|
||||
|
||||
jest.spyOn( ReactNativePermissions, "checkMultiple" )
|
||||
.mockResolvedValueOnce( mockedPermissions );
|
||||
} );
|
||||
|
||||
afterEach( ( ) => {
|
||||
@@ -70,25 +76,33 @@ describe( "ObsEdit offline", ( ) => {
|
||||
|
||||
describe( "creation", ( ) => {
|
||||
it( "should fetch coordinates", async ( ) => {
|
||||
const mockGetCurrentPosition = jest.fn( ( success, _error, _options ) => success( {
|
||||
const mockWatchPosition = jest.fn( ( success, _error, _options ) => success( {
|
||||
coords: {
|
||||
latitude: 1,
|
||||
longitude: 1,
|
||||
accuracy: 10,
|
||||
accuracy: 9,
|
||||
timestamp: Date.now( )
|
||||
}
|
||||
} ) );
|
||||
Geolocation.getCurrentPosition.mockImplementation( mockGetCurrentPosition );
|
||||
Geolocation.watchPosition.mockImplementation( mockWatchPosition );
|
||||
const observation = factory( "LocalObservation", {
|
||||
observationPhotos: []
|
||||
} );
|
||||
useStore.setState( { observations: [observation] } );
|
||||
useStore.setState( {
|
||||
observations: [observation],
|
||||
currentObservation: observation
|
||||
} );
|
||||
renderAppWithComponent(
|
||||
<ObsEdit />
|
||||
);
|
||||
// removing the next lines since location fetch is fast enough that the location indicator
|
||||
// won't show up in this scenario
|
||||
// expect(
|
||||
// screen.getByTestId( "EvidenceSection.fetchingLocationIndicator" )
|
||||
// ).toBeTruthy( );
|
||||
await waitFor( ( ) => {
|
||||
expect( mockGetCurrentPosition ).toHaveBeenCalled( );
|
||||
}, { timeout: LOCATION_FETCH_INTERVAL * 2 } );
|
||||
expect( mockWatchPosition ).toHaveBeenCalled( );
|
||||
} );
|
||||
const coords = await screen.findByText( /Lat:/ );
|
||||
expect( coords ).toBeTruthy( );
|
||||
expect( screen.queryByText( "Finding location..." ) ).toBeFalsy( );
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Geolocation from "@react-native-community/geolocation";
|
||||
import { screen, waitFor } from "@testing-library/react-native";
|
||||
import ObsEdit from "components/ObsEdit/ObsEdit";
|
||||
import React from "react";
|
||||
import { LOCATION_FETCH_INTERVAL } from "sharedHooks/useCurrentObservationLocation";
|
||||
import useStore from "stores/useStore";
|
||||
import factory from "tests/factory";
|
||||
import faker from "tests/helpers/faker";
|
||||
@@ -11,30 +11,8 @@ const initialStoreState = useStore.getState( );
|
||||
|
||||
const mockLocationName = "San Francisco, CA";
|
||||
|
||||
// import { checkMultiple, RESULTS } from "react-native-permissions";
|
||||
// jest.mock( "react-native-permissions", ( ) => {
|
||||
// const actual = jest.requireActual( "react-native-permissions" );
|
||||
// return {
|
||||
// ...actual,
|
||||
// checkMultiple: permissions => permissions.reduce(
|
||||
// ( memo, permission ) => {
|
||||
// memo[permission] = actual.RESULTS.GRANTED;
|
||||
// return memo;
|
||||
// },
|
||||
// {}
|
||||
// )
|
||||
// };
|
||||
// } );
|
||||
// jest.mock('react-native-permissions', () => require('react-native-permissions/mock'));
|
||||
|
||||
const mockCurrentUser = factory( "LocalUser" );
|
||||
|
||||
const mockFetchUserLocation = jest.fn( () => ( { latitude: 37, longitude: 34 } ) );
|
||||
jest.mock( "sharedHelpers/fetchUserLocation", () => ( {
|
||||
__esModule: true,
|
||||
default: () => mockFetchUserLocation()
|
||||
} ) );
|
||||
|
||||
const renderObsEdit = ( ) => renderComponent( <ObsEdit /> );
|
||||
|
||||
const mockTaxon = factory( "RemoteTaxon", {
|
||||
@@ -93,7 +71,7 @@ describe( "location fetching", () => {
|
||||
|
||||
beforeEach( () => {
|
||||
// resets mock back to original state
|
||||
mockFetchUserLocation.mockReset();
|
||||
Geolocation.watchPosition.mockReset();
|
||||
} );
|
||||
|
||||
test( "should fetch location when new observation hasn't saved", async ( ) => {
|
||||
@@ -104,14 +82,12 @@ describe( "location fetching", () => {
|
||||
observations,
|
||||
currentObservation: observations[0]
|
||||
} );
|
||||
renderObsEdit( );
|
||||
expect( mockFetchUserLocation ).not.toHaveBeenCalled();
|
||||
|
||||
renderObsEdit( );
|
||||
|
||||
await waitFor( () => {
|
||||
expect( mockFetchUserLocation ).toHaveBeenCalled();
|
||||
}, { timeout: LOCATION_FETCH_INTERVAL * 2 } );
|
||||
expect( Geolocation.watchPosition ).toHaveBeenCalled();
|
||||
} );
|
||||
// Note: it would be nice to look for an update in the UI
|
||||
} );
|
||||
|
||||
@@ -137,9 +113,9 @@ describe( "location fetching", () => {
|
||||
|
||||
// Location may not fetch immediately, so wait for twice the default fetch
|
||||
// interval before testing whether the mock was called
|
||||
await waitFor( () => undefined, { timeout: LOCATION_FETCH_INTERVAL * 2 } );
|
||||
await waitFor( () => undefined );
|
||||
|
||||
expect( mockFetchUserLocation ).not.toHaveBeenCalled();
|
||||
expect( Geolocation.watchPosition ).not.toHaveBeenCalled();
|
||||
} );
|
||||
|
||||
test( "shouldn't fetch location for existing observation created elsewhere", async () => {
|
||||
@@ -163,8 +139,8 @@ describe( "location fetching", () => {
|
||||
screen.getByText( new RegExp( `Lat: ${observation.latitude}` ) )
|
||||
).toBeTruthy();
|
||||
|
||||
await waitFor( () => undefined, { timeout: LOCATION_FETCH_INTERVAL * 2 } );
|
||||
await waitFor( () => undefined );
|
||||
|
||||
expect( mockFetchUserLocation ).not.toHaveBeenCalled();
|
||||
expect( Geolocation.watchPosition ).not.toHaveBeenCalled();
|
||||
} );
|
||||
} );
|
||||
@@ -27,13 +27,14 @@ getPredictionsForImage.mockImplementation(
|
||||
async ( ) => ( mockModelResult )
|
||||
);
|
||||
|
||||
const mockGetCurrentPosition = jest.fn( ( success, _error, _options ) => success( {
|
||||
const mockWatchPosition = jest.fn( ( success, _error, _options ) => success( {
|
||||
coords: {
|
||||
latitude: 56,
|
||||
longitude: 9
|
||||
longitude: 9,
|
||||
accuracy: 8
|
||||
}
|
||||
} ) );
|
||||
Geolocation.getCurrentPosition.mockImplementation( mockGetCurrentPosition );
|
||||
Geolocation.watchPosition.mockImplementation( mockWatchPosition );
|
||||
|
||||
// UNIQUE REALM SETUP
|
||||
const mockRealmIdentifier = __filename;
|
||||
|
||||
@@ -79,13 +79,14 @@ describe( "AICamera navigation with advanced user layout", ( ) => {
|
||||
|
||||
describe( "to Suggestions", ( ) => {
|
||||
beforeEach( ( ) => {
|
||||
const mockGetCurrentPosition = jest.fn( ( success, _error, _options ) => success( {
|
||||
const mockWatchPosition = jest.fn( ( success, _error, _options ) => success( {
|
||||
coords: {
|
||||
latitude: 56,
|
||||
longitude: 9
|
||||
longitude: 9,
|
||||
accuracy: 8
|
||||
}
|
||||
} ) );
|
||||
Geolocation.getCurrentPosition.mockImplementation( mockGetCurrentPosition );
|
||||
Geolocation.watchPosition.mockImplementation( mockWatchPosition );
|
||||
} );
|
||||
|
||||
it( "should advance to suggestions screen", async ( ) => {
|
||||
|
||||
@@ -62,13 +62,14 @@ describe( "StandardCamera navigation with advanced user layout", ( ) => {
|
||||
} );
|
||||
|
||||
it( "should advance to Suggestions when photo taken and checkmark tapped", async ( ) => {
|
||||
const mockGetCurrentPosition = jest.fn( ( success, _error, _options ) => success( {
|
||||
const mockWatchPosition = jest.fn( ( success, _error, _options ) => success( {
|
||||
coords: {
|
||||
latitude: 56,
|
||||
longitude: 9
|
||||
longitude: 9,
|
||||
accuracy: 8
|
||||
}
|
||||
} ) );
|
||||
Geolocation.getCurrentPosition.mockImplementation( mockGetCurrentPosition );
|
||||
Geolocation.watchPosition.mockImplementation( mockWatchPosition );
|
||||
renderApp( );
|
||||
expect( await screen.findByText( /Log in to contribute/ ) ).toBeVisible( );
|
||||
const tabBar = await screen.findByTestId( "CustomTabBar" );
|
||||
|
||||
@@ -233,7 +233,12 @@ jest.mock( "react-native-permissions", () => require( "react-native-permissions/
|
||||
|
||||
// mocking globally since this currently affects a handful of unit and integration tests
|
||||
jest.mock( "@react-native-community/geolocation", ( ) => ( {
|
||||
getCurrentPosition: jest.fn( )
|
||||
getCurrentPosition: jest.fn( ),
|
||||
watchPosition: jest.fn( ),
|
||||
clearWatch: jest.fn( ),
|
||||
setRNConfiguration: jest.fn( ( ) => ( {
|
||||
skipPermissionRequests: true
|
||||
} ) )
|
||||
} ) );
|
||||
require( "react-native" ).NativeModules.RNCGeolocation = { };
|
||||
|
||||
|
||||
@@ -5,12 +5,11 @@ import useStore from "stores/useStore";
|
||||
import factory from "tests/factory";
|
||||
import { renderComponent } from "tests/helpers/render";
|
||||
|
||||
jest.mock( "sharedHooks/useCurrentObservationLocation", () => ( {
|
||||
jest.mock( "sharedHooks/useWatchPosition", () => ( {
|
||||
__esModule: true,
|
||||
default: ( ) => ( {
|
||||
hasLocation: true,
|
||||
isFetchingLocation: false,
|
||||
permissionResult: "granted"
|
||||
isFetchingLocation: false
|
||||
} )
|
||||
} ) );
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ jest.mock( "@react-navigation/native", ( ) => {
|
||||
...actualNav,
|
||||
useNavigation: () => ( {
|
||||
navigate: mockedNavigate,
|
||||
setOptions: jest.fn( )
|
||||
setOptions: jest.fn( ),
|
||||
addListener: jest.fn( )
|
||||
} ),
|
||||
useRoute: () => ( {} )
|
||||
};
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import Geolocation from "@react-native-community/geolocation";
|
||||
import { renderHook, waitFor } from "@testing-library/react-native";
|
||||
import useUserLocation from "sharedHooks/useUserLocation.ts";
|
||||
|
||||
const mockPositions = [
|
||||
{
|
||||
coords: {
|
||||
latitude: 1,
|
||||
longitude: 1,
|
||||
accuracy: 100
|
||||
}
|
||||
},
|
||||
{
|
||||
coords: {
|
||||
latitude: 2,
|
||||
longitude: 2,
|
||||
accuracy: 20
|
||||
}
|
||||
},
|
||||
{
|
||||
coords: {
|
||||
latitude: 3,
|
||||
longitude: 3,
|
||||
accuracy: 8
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
describe( "useUserLocation", ( ) => {
|
||||
beforeEach( ( ) => {
|
||||
Geolocation.getCurrentPosition.mockReset( );
|
||||
// Mock so success gets called immediately and so that three subsequent
|
||||
// calls succeed with changing coordinates and improving accuracy
|
||||
Geolocation.getCurrentPosition
|
||||
.mockImplementationOnce( success => success( mockPositions[0] ) )
|
||||
.mockImplementationOnce( success => success( mockPositions[1] ) )
|
||||
.mockImplementationOnce( success => success( mockPositions[2] ) );
|
||||
} );
|
||||
|
||||
// Geolocation.getCurrentPosition should have been called and that roughly
|
||||
// marks the end of async effects, so hopefull this prevents "outside of
|
||||
// act" warnings
|
||||
afterEach( ( ) => waitFor( ( ) => {
|
||||
expect( Geolocation.getCurrentPosition ).toHaveBeenCalled( );
|
||||
} ) );
|
||||
|
||||
it( "should be loading by default", async ( ) => {
|
||||
const { result } = renderHook( ( ) => useUserLocation( ) );
|
||||
await waitFor( ( ) => {
|
||||
expect( result.current.isLoading ).toBeTruthy( );
|
||||
} );
|
||||
} );
|
||||
|
||||
it( "should return a user location", async ( ) => {
|
||||
const { result } = renderHook( ( ) => useUserLocation( ) );
|
||||
await waitFor( ( ) => {
|
||||
expect( result.current.userLocation ).toBeDefined( );
|
||||
} );
|
||||
expect( result?.current?.userLocation?.longitude )
|
||||
.toEqual( mockPositions[0].coords.latitude );
|
||||
} );
|
||||
|
||||
describe( "untilAcc", ( ) => {
|
||||
it( "should fetch coordinates until target accuracy reached", async ( ) => {
|
||||
const { result } = renderHook( ( ) => useUserLocation( {
|
||||
untilAcc: 10
|
||||
} ) );
|
||||
await waitFor( ( ) => {
|
||||
expect( result.current.userLocation?.accuracy )
|
||||
.toEqual( mockPositions[2].coords.accuracy );
|
||||
}, { timeout: 4000, interval: 1000 } );
|
||||
expect( Geolocation.getCurrentPosition ).toHaveBeenCalledTimes( 3 );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
90
tests/unit/sharedHooks/useWatchPosition.test.js
Normal file
90
tests/unit/sharedHooks/useWatchPosition.test.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import Geolocation from "@react-native-community/geolocation";
|
||||
import { renderHook, waitFor } from "@testing-library/react-native";
|
||||
import { useWatchPosition } from "sharedHooks";
|
||||
|
||||
const mockPositions = [
|
||||
{
|
||||
coords: {
|
||||
latitude: 1,
|
||||
longitude: 1,
|
||||
accuracy: 100
|
||||
}
|
||||
},
|
||||
{
|
||||
coords: {
|
||||
latitude: 2,
|
||||
longitude: 2,
|
||||
accuracy: 20
|
||||
}
|
||||
},
|
||||
{
|
||||
coords: {
|
||||
latitude: 3,
|
||||
longitude: 3,
|
||||
accuracy: 8
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
describe( "useWatchPosition with inaccurate location", ( ) => {
|
||||
beforeEach( ( ) => {
|
||||
Geolocation.watchPosition.mockReset( );
|
||||
// Mock so success gets called immediately and so that three subsequent
|
||||
// calls succeed with changing coordinates and improving accuracy
|
||||
Geolocation.watchPosition.mockImplementationOnce( success => success( mockPositions[0] ) );
|
||||
} );
|
||||
|
||||
// Geolocation.watchPosition should have been called and that roughly
|
||||
// marks the end of async effects, so hopefull this prevents "outside of
|
||||
// act" warnings
|
||||
afterEach( ( ) => waitFor( ( ) => {
|
||||
expect( Geolocation.watchPosition ).toHaveBeenCalled( );
|
||||
} ) );
|
||||
|
||||
it( "should be loading by default", async ( ) => {
|
||||
const { result } = renderHook( ( ) => useWatchPosition( { shouldFetchLocation: true } ) );
|
||||
await waitFor( ( ) => {
|
||||
expect( result.current.isFetchingLocation ).toBeTruthy( );
|
||||
} );
|
||||
} );
|
||||
|
||||
it( "should return a user location", async ( ) => {
|
||||
const { result } = renderHook( ( ) => useWatchPosition( { shouldFetchLocation: true } ) );
|
||||
await waitFor( ( ) => {
|
||||
expect( result.current.userLocation ).toBeDefined( );
|
||||
} );
|
||||
expect( result?.current?.userLocation?.latitude )
|
||||
.toEqual( mockPositions[0].coords.latitude );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( "useWatchPosition with accurate location", ( ) => {
|
||||
beforeEach( ( ) => {
|
||||
Geolocation.watchPosition.mockReset( );
|
||||
|
||||
Geolocation.watchPosition
|
||||
.mockImplementationOnce( success => success( mockPositions[2] ) );
|
||||
} );
|
||||
|
||||
it( "should stop watching position when target accuracy reached", async ( ) => {
|
||||
const { result } = renderHook( ( ) => useWatchPosition( { shouldFetchLocation: true } ) );
|
||||
await waitFor( ( ) => {
|
||||
expect( result.current.userLocation?.positional_accuracy )
|
||||
.toEqual( mockPositions[2].coords.accuracy );
|
||||
} );
|
||||
expect( Geolocation.clearWatch ).toHaveBeenCalledTimes( 1 );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( "useWatchPosition when shouldn't fetch", ( ) => {
|
||||
beforeEach( ( ) => {
|
||||
Geolocation.watchPosition.mockReset( );
|
||||
} );
|
||||
|
||||
it( "should not watch position when shouldFetchLocation is false", async ( ) => {
|
||||
renderHook( ( ) => useWatchPosition( { shouldFetchLocation: false } ) );
|
||||
await waitFor( ( ) => {
|
||||
expect( Geolocation.watchPosition ).not.toHaveBeenCalled( );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
Reference in New Issue
Block a user