mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-04-21 07:20:03 -04:00
Add location picker to ObsEdit (#593)
* Crosshairs, location accuracy warnings, text input for location picker * Add LocationPicker tests * UI improvements and code refactor into smaller components; get GMaps working * Add shadows to icons/text boxes * Use debouncing to avoid map jitter when typing in location * Show place results & let user pick new location from web api * Add tests for location picker with remote results * Add gmaps api key to github actions * Try adding manifest placeholders for env variable to work in github actions * Add key to printf in github actions * Try accessing GMAPS_API_KEY a different way * Update android e2e env file step This updates the "Create .env file" step to use printf to print the Google Maps key into a newly created .env file in the GitHub Action runner. Using the same key as in env.example. * Fix newline --------- Co-authored-by: Johannes Klein <johannes.t.klein@gmail.com>
This commit is contained in:
committed by
GitHub
parent
1439c40c6a
commit
5ee4a433df
3
.github/workflows/e2e_android.yml
vendored
3
.github/workflows/e2e_android.yml
vendored
@@ -83,7 +83,8 @@ jobs:
|
||||
E2E_TEST_USERNAME: ${{ secrets.E2E_TEST_USERNAME }}
|
||||
E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}
|
||||
JWT_ANONYMOUS_API_SECRET: ${{ secrets.JWT_ANONYMOUS_API_SECRET }}
|
||||
run: printf 'API_URL=https://stagingapi.inaturalist.org/v2\nOAUTH_API_URL=https://staging.inaturalist.org\nJWT_ANONYMOUS_API_SECRET=%s\nOAUTH_CLIENT_ID=%s\nOAUTH_CLIENT_SECRET=%s\nE2E_TEST_USERNAME=%s\nE2E_TEST_PASSWORD=%s\n' "JWT_ANONYMOUS_API_SECRET" "$OAUTH_CLIENT_ID" "$OAUTH_CLIENT_SECRET" "$E2E_TEST_USERNAME" "$E2E_TEST_PASSWORD" > .env
|
||||
GMAPS_API_KEY: ${{ secrets.GMAPS_API_KEY }}
|
||||
run: printf 'API_URL=https://stagingapi.inaturalist.org/v2\nOAUTH_API_URL=https://staging.inaturalist.org\nJWT_ANONYMOUS_API_SECRET=%s\nOAUTH_CLIENT_ID=%s\nOAUTH_CLIENT_SECRET=%s\nE2E_TEST_USERNAME=%s\nE2E_TEST_PASSWORD=%s\nGMAPS_API_KEY=%s\n' "JWT_ANONYMOUS_API_SECRET" "$OAUTH_CLIENT_ID" "$OAUTH_CLIENT_SECRET" "$E2E_TEST_USERNAME" "$E2E_TEST_PASSWORD" "$GMAPS_API_KEY" > .env
|
||||
- name: Create keystore.properties file
|
||||
env:
|
||||
ANDROID_KEY_STORE_PASSWORD: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -78,4 +78,4 @@ artifacts/
|
||||
*.log
|
||||
|
||||
# VisualStudioCode #
|
||||
.vscode
|
||||
.vscode
|
||||
@@ -129,6 +129,7 @@ android {
|
||||
// Detox Android setup
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
manifestPlaceholders = [ GMAPS_API_KEY:project.env.get("GMAPS_API_KEY") ]
|
||||
// Detox end
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
android:theme="@style/AppTheme"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<meta-data android:name="com.google.android.geo.API_KEY" android:value="${GMAPS_API_KEY}" />
|
||||
<!-- <meta-data android:name="com.google.android.geo.API_KEY" android:value="@string/GMAPS_API_KEY" /> -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -13,4 +13,6 @@ OAUTH_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# Credentials to log in a user for e2e testing
|
||||
E2E_TEST_USERNAME=test-username
|
||||
E2E_TEST_PASSWORD=test-password
|
||||
E2E_TEST_PASSWORD=test-password
|
||||
|
||||
GMAPS_API_KEY=some-key
|
||||
3
index.js
3
index.js
@@ -19,12 +19,15 @@ import { AppRegistry } from "react-native";
|
||||
import Config from "react-native-config";
|
||||
import { setJSExceptionHandler, setNativeExceptionHandler } from "react-native-exception-handler";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { enableLatestRenderer } from "react-native-maps";
|
||||
import { startNetworkLogging } from "react-native-network-logger";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
|
||||
import { name as appName } from "./app.json";
|
||||
import { log } from "./react-native-logs.config";
|
||||
|
||||
enableLatestRenderer( );
|
||||
|
||||
const logger = log.extend( "index.js" );
|
||||
|
||||
const jsErrorHandler = ( e, isFatal ) => {
|
||||
|
||||
@@ -293,7 +293,7 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-mail (6.1.1):
|
||||
- React-Core
|
||||
- react-native-maps (0.31.1):
|
||||
- react-native-maps (1.7.1):
|
||||
- React-Core
|
||||
- react-native-netinfo (9.3.9):
|
||||
- React-Core
|
||||
@@ -723,7 +723,7 @@ SPEC CHECKSUMS:
|
||||
react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa
|
||||
react-native-keep-awake: acbee258db16483744910f0da3ace39eb9ab47fd
|
||||
react-native-mail: 8fdcd3aef007c33a6877a18eb4cf7447a1d4ce4a
|
||||
react-native-maps: 8b8bfada2c86205a7f5a07dd1f92f29b33ea83aa
|
||||
react-native-maps: 667f9b975549c6fa9b1631bf859440f68ebd3b8f
|
||||
react-native-netinfo: 22c082970cbd99071a4e5aa7a612ac20d66b08f0
|
||||
react-native-orientation-locker: 851f6510d8046ea2f14aa169b1e01fcd309a94ba
|
||||
react-native-render-html: 984dfe2294163d04bf5fe25d7c9f122e60e05ebe
|
||||
@@ -765,4 +765,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: 1e393412e8a65a884dff99189d809b73ea234bcd
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.12.0
|
||||
|
||||
44
package-lock.json
generated
44
package-lock.json
generated
@@ -71,7 +71,7 @@
|
||||
"react-native-localize": "^2.2.6",
|
||||
"react-native-logs": "^5.0.1",
|
||||
"react-native-mail": "github:chirag04/react-native-mail",
|
||||
"react-native-maps": "^0.31.1",
|
||||
"react-native-maps": "^1.7.1",
|
||||
"react-native-modal": "^13.0.1",
|
||||
"react-native-modal-datetime-picker": "^15.0.0",
|
||||
"react-native-network-logger": "^1.14.1",
|
||||
@@ -8893,16 +8893,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/deprecated-react-native-prop-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/deprecated-react-native-prop-types/-/deprecated-react-native-prop-types-2.3.0.tgz",
|
||||
"integrity": "sha512-pWD0voFtNYxrVqvBMYf5gq3NA2GCpfodS1yNynTPc93AYA/KEMGeWDqqeUB6R2Z9ZofVhks2aeJXiuQqKNpesA==",
|
||||
"dependencies": {
|
||||
"@react-native/normalize-color": "*",
|
||||
"invariant": "*",
|
||||
"prop-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
@@ -21525,16 +21515,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-maps": {
|
||||
"version": "0.31.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-0.31.1.tgz",
|
||||
"integrity": "sha512-vipeOPykqLRMCLcLUCZEB+cTdNSlq88NLb0jChY4UGTY5fgOS7GYWkfswy6bW1ayTRLxJS3zpMGFDUY59/ZrXA==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.7.1.tgz",
|
||||
"integrity": "sha512-CHVLzL+Q2jiUGgbt4/vosxVI1SukWyaLGjD62VLgR/wZpnH4Umi9ql1bmKDPWcfj2C1QZwMU0Yc7rXTbvZUIiw==",
|
||||
"dependencies": {
|
||||
"@types/geojson": "^7946.0.7",
|
||||
"deprecated-react-native-prop-types": "^2.3.0"
|
||||
"@types/geojson": "^7946.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.0",
|
||||
"react-native": ">= 0.51",
|
||||
"react": ">= 17.0.1",
|
||||
"react-native": ">= 0.64.3",
|
||||
"react-native-web": ">= 0.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -31556,16 +31545,6 @@
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
|
||||
},
|
||||
"deprecated-react-native-prop-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/deprecated-react-native-prop-types/-/deprecated-react-native-prop-types-2.3.0.tgz",
|
||||
"integrity": "sha512-pWD0voFtNYxrVqvBMYf5gq3NA2GCpfodS1yNynTPc93AYA/KEMGeWDqqeUB6R2Z9ZofVhks2aeJXiuQqKNpesA==",
|
||||
"requires": {
|
||||
"@react-native/normalize-color": "*",
|
||||
"invariant": "*",
|
||||
"prop-types": "*"
|
||||
}
|
||||
},
|
||||
"destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
@@ -40876,12 +40855,11 @@
|
||||
"from": "react-native-mail@github:chirag04/react-native-mail"
|
||||
},
|
||||
"react-native-maps": {
|
||||
"version": "0.31.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-0.31.1.tgz",
|
||||
"integrity": "sha512-vipeOPykqLRMCLcLUCZEB+cTdNSlq88NLb0jChY4UGTY5fgOS7GYWkfswy6bW1ayTRLxJS3zpMGFDUY59/ZrXA==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.7.1.tgz",
|
||||
"integrity": "sha512-CHVLzL+Q2jiUGgbt4/vosxVI1SukWyaLGjD62VLgR/wZpnH4Umi9ql1bmKDPWcfj2C1QZwMU0Yc7rXTbvZUIiw==",
|
||||
"requires": {
|
||||
"@types/geojson": "^7946.0.7",
|
||||
"deprecated-react-native-prop-types": "^2.3.0"
|
||||
"@types/geojson": "^7946.0.10"
|
||||
}
|
||||
},
|
||||
"react-native-modal": {
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"react-native-localize": "^2.2.6",
|
||||
"react-native-logs": "^5.0.1",
|
||||
"react-native-mail": "github:chirag04/react-native-mail",
|
||||
"react-native-maps": "^0.31.1",
|
||||
"react-native-maps": "^1.7.1",
|
||||
"react-native-modal": "^13.0.1",
|
||||
"react-native-modal-datetime-picker": "^15.0.0",
|
||||
"react-native-network-logger": "^1.14.1",
|
||||
|
||||
57
src/components/LocationPicker/CrosshairCircle.js
Normal file
57
src/components/LocationPicker/CrosshairCircle.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// @flow
|
||||
|
||||
import classnames from "classnames";
|
||||
import { INatIcon } from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { useTheme } from "react-native-paper";
|
||||
|
||||
type Props = {
|
||||
accuracyTest: string,
|
||||
containerStyle: Object
|
||||
};
|
||||
|
||||
const CrosshairCircle = ( { accuracyTest, containerStyle }: Props ): Node => {
|
||||
const theme = useTheme( );
|
||||
|
||||
return (
|
||||
<View className="absolute" style={containerStyle}>
|
||||
<View
|
||||
className={
|
||||
classnames(
|
||||
"h-[254px] w-[254px] bg-transparent rounded-full border-[5px]",
|
||||
{
|
||||
"border-inatGreen": accuracyTest === "pass",
|
||||
"border-warningYellow border-dashed": accuracyTest === "acceptable",
|
||||
"border-warningRed": accuracyTest === "fail"
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* vertical crosshair */}
|
||||
<View className={classnames( "h-[244px] border border-darkGray absolute left-[122px]" )} />
|
||||
{/* horizontal crosshair */}
|
||||
<View className={classnames( "w-[244px] border border-darkGray absolute top-[122px]" )} />
|
||||
</View>
|
||||
<View className="absolute left-[234px]">
|
||||
{accuracyTest === "pass" && (
|
||||
<INatIcon
|
||||
name="checkmark-circle"
|
||||
size={19}
|
||||
color={theme.colors.secondary}
|
||||
/>
|
||||
)}
|
||||
{accuracyTest === "fail" && (
|
||||
<INatIcon
|
||||
name="triangle-exclamation"
|
||||
size={19}
|
||||
color={theme.colors.error}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default CrosshairCircle;
|
||||
47
src/components/LocationPicker/DisplayLatLng.js
Normal file
47
src/components/LocationPicker/DisplayLatLng.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// @flow
|
||||
|
||||
import {
|
||||
Body4
|
||||
} from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { useTheme } from "react-native-paper";
|
||||
|
||||
type Props = {
|
||||
region: Object,
|
||||
accuracy: number,
|
||||
getShadow: Function
|
||||
};
|
||||
|
||||
const DisplayLatLng = ( { region, accuracy, getShadow }: Props ): Node => {
|
||||
const theme = useTheme( );
|
||||
const formatDecimal = coordinate => coordinate && coordinate.toFixed( 6 );
|
||||
|
||||
const displayLocation = ( ) => {
|
||||
let location = "";
|
||||
if ( region.latitude ) {
|
||||
location += `Lat: ${formatDecimal( region.latitude )}`;
|
||||
}
|
||||
if ( region.longitude ) {
|
||||
location += `, Lon: ${formatDecimal( region.longitude )}`;
|
||||
}
|
||||
if ( accuracy ) {
|
||||
location += `, Acc: ${accuracy.toFixed( 0 )}`;
|
||||
}
|
||||
return location;
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-white h-[27px] rounded-lg absolute top-[74px] right-[26px] left-[26px]"
|
||||
style={getShadow( theme.colors.primary )}
|
||||
>
|
||||
<Body4 className="pt-[7px] pl-[14px]">
|
||||
{displayLocation( )}
|
||||
</Body4>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisplayLatLng;
|
||||
45
src/components/LocationPicker/Footer.js
Normal file
45
src/components/LocationPicker/Footer.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import {
|
||||
Button, StickyToolbar
|
||||
} from "components/SharedComponents";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useContext
|
||||
} from "react";
|
||||
import { Platform } from "react-native";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
|
||||
type Props = {
|
||||
keysToUpdate: Object,
|
||||
goBackOnSave: boolean
|
||||
};
|
||||
|
||||
const Footer = ( { keysToUpdate, goBackOnSave }: Props ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const { t } = useTranslation( );
|
||||
const {
|
||||
updateObservationKeys
|
||||
} = useContext( ObsEditContext );
|
||||
|
||||
return (
|
||||
<StickyToolbar containerClass={Platform.OS === "ios" && "bottom-6"}>
|
||||
<Button
|
||||
className="px-[25px]"
|
||||
onPress={( ) => {
|
||||
updateObservationKeys( keysToUpdate );
|
||||
if ( goBackOnSave ) {
|
||||
navigation.goBack( );
|
||||
}
|
||||
}}
|
||||
testID="LocationPicker.saveButton"
|
||||
text={t( "SAVE-LOCATION" )}
|
||||
level="neutral"
|
||||
/>
|
||||
</StickyToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
191
src/components/LocationPicker/LocationPicker.js
Normal file
191
src/components/LocationPicker/LocationPicker.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import {
|
||||
CloseButton, Heading4,
|
||||
ViewWrapper
|
||||
} from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext, useEffect, useRef, useState
|
||||
} from "react";
|
||||
import { Dimensions } from "react-native";
|
||||
import MapView from "react-native-maps";
|
||||
import { IconButton, useTheme } from "react-native-paper";
|
||||
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
|
||||
import fetchUserLocation from "sharedHelpers/fetchUserLocation";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
import { getShadowStyle } from "styles/global";
|
||||
|
||||
import CrosshairCircle from "./CrosshairCircle";
|
||||
import DisplayLatLng from "./DisplayLatLng";
|
||||
import Footer from "./Footer";
|
||||
import LocationSearch from "./LocationSearch";
|
||||
import WarningText from "./WarningText";
|
||||
|
||||
const getShadow = shadowColor => getShadowStyle( {
|
||||
shadowColor,
|
||||
offsetWidth: 0,
|
||||
offsetHeight: 2,
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 2,
|
||||
elevation: 5
|
||||
} );
|
||||
|
||||
const { width, height } = Dimensions.get( "screen" );
|
||||
|
||||
const DELTA = 0.2;
|
||||
const CROSSHAIRLENGTH = 254;
|
||||
const DESIRED_LOCATION_ACCURACY = 4000000;
|
||||
|
||||
type Props = {
|
||||
route: {
|
||||
params: {
|
||||
goBackOnSave: boolean
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const centerCrosshair = ( height / 2 ) - CROSSHAIRLENGTH + 30;
|
||||
|
||||
const LocationPicker = ( { route }: Props ): Node => {
|
||||
const theme = useTheme( );
|
||||
const { t } = useTranslation( );
|
||||
const mapView = useRef( );
|
||||
const { currentObservation } = useContext( ObsEditContext );
|
||||
const navigation = useNavigation( );
|
||||
const { goBackOnSave } = route.params;
|
||||
const [mapType, setMapType] = useState( "standard" );
|
||||
const [locationName, setLocationName] = useState( currentObservation?.place_guess );
|
||||
const [accuracy, setAccuracy] = useState( currentObservation?.positional_accuracy );
|
||||
const [accuracyTest, setAccuracyTest] = useState( "pass" );
|
||||
const [region, setRegion] = useState( {
|
||||
latitude: currentObservation?.latitude || 0.0,
|
||||
longitude: currentObservation?.longitude || 0.0,
|
||||
latitudeDelta: DELTA,
|
||||
longitudeDelta: DELTA
|
||||
} );
|
||||
|
||||
const keysToUpdate = {
|
||||
latitude: region.latitude,
|
||||
longitude: region.longitude,
|
||||
positional_accuracy: accuracy,
|
||||
place_guess: locationName
|
||||
};
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( accuracy < 10 ) {
|
||||
setAccuracyTest( "pass" );
|
||||
} else if ( accuracy < DESIRED_LOCATION_ACCURACY ) {
|
||||
setAccuracyTest( "acceptable" );
|
||||
} else {
|
||||
setAccuracyTest( "fail" );
|
||||
}
|
||||
}, [accuracy] );
|
||||
|
||||
const updateRegion = async newRegion => {
|
||||
const estimatedAccuracy = newRegion.longitudeDelta * 1000 * (
|
||||
( CROSSHAIRLENGTH / width ) * 100
|
||||
);
|
||||
|
||||
const placeName = await fetchPlaceName( newRegion.latitude, newRegion.longitude );
|
||||
if ( placeName ) {
|
||||
setLocationName( placeName );
|
||||
}
|
||||
setRegion( newRegion );
|
||||
setAccuracy( estimatedAccuracy );
|
||||
};
|
||||
|
||||
const renderBackButton = useCallback(
|
||||
( ) => <CloseButton black className="absolute" size={19} />,
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect( ( ) => {
|
||||
const renderHeaderTitle = ( ) => <Heading4>{t( "EDIT-LOCATION" )}</Heading4>;
|
||||
|
||||
const headerOptions = {
|
||||
headerRight: renderBackButton,
|
||||
headerTitle: renderHeaderTitle
|
||||
};
|
||||
|
||||
navigation.setOptions( headerOptions );
|
||||
}, [renderBackButton, navigation, t] );
|
||||
|
||||
const toggleMapLayer = ( ) => {
|
||||
if ( mapType === "standard" ) {
|
||||
setMapType( "satellite" );
|
||||
} else {
|
||||
setMapType( "standard" );
|
||||
}
|
||||
};
|
||||
|
||||
const returnToUserLocation = async ( ) => {
|
||||
const userLocation = await fetchUserLocation( );
|
||||
setRegion( {
|
||||
...region,
|
||||
latitude: userLocation?.latitude,
|
||||
longitude: userLocation?.longitude
|
||||
} );
|
||||
};
|
||||
|
||||
return (
|
||||
<ViewWrapper testID="location-picker">
|
||||
<MapView
|
||||
className="flex-1"
|
||||
showsCompass={false}
|
||||
region={region}
|
||||
ref={mapView}
|
||||
mapType={mapType}
|
||||
onRegionChangeComplete={async newRegion => {
|
||||
updateRegion( newRegion );
|
||||
// console.log( await mapView?.current?.getMapBoundaries( ) );
|
||||
}}
|
||||
/>
|
||||
<CrosshairCircle
|
||||
accuracyTest={accuracyTest}
|
||||
// eslint-disable-next-line react-native/no-inline-styles
|
||||
containerStyle={{
|
||||
alignSelf: "center",
|
||||
top: centerCrosshair
|
||||
}}
|
||||
/>
|
||||
<LocationSearch
|
||||
region={region}
|
||||
setRegion={setRegion}
|
||||
locationName={locationName}
|
||||
setLocationName={setLocationName}
|
||||
getShadow={getShadow}
|
||||
/>
|
||||
<DisplayLatLng
|
||||
region={region}
|
||||
accuracy={accuracy}
|
||||
getShadow={getShadow}
|
||||
/>
|
||||
<WarningText accuracyTest={accuracyTest} getShadow={getShadow} />
|
||||
<View style={getShadow( theme.colors.primary )}>
|
||||
<IconButton
|
||||
className="absolute bottom-20 bg-white left-2"
|
||||
icon="layers"
|
||||
onPress={toggleMapLayer}
|
||||
/>
|
||||
</View>
|
||||
<View style={getShadow( theme.colors.primary )}>
|
||||
<IconButton
|
||||
className="absolute bottom-20 bg-white right-2"
|
||||
icon="location-crosshairs"
|
||||
onPress={returnToUserLocation}
|
||||
/>
|
||||
</View>
|
||||
<Footer
|
||||
keysToUpdate={keysToUpdate}
|
||||
goBackOnSave={goBackOnSave}
|
||||
/>
|
||||
</ViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationPicker;
|
||||
83
src/components/LocationPicker/LocationSearch.js
Normal file
83
src/components/LocationPicker/LocationSearch.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// @flow
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import fetchSearchResults from "api/search";
|
||||
import {
|
||||
Body3,
|
||||
SearchBar
|
||||
} from "components/SharedComponents";
|
||||
import { Pressable, View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTheme } from "react-native-paper";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
|
||||
type Props = {
|
||||
region: Object,
|
||||
setRegion: Function,
|
||||
locationName: string,
|
||||
setLocationName: Function,
|
||||
getShadow: Function
|
||||
};
|
||||
|
||||
const LocationSearch = ( {
|
||||
region, setRegion, locationName, setLocationName, getShadow
|
||||
}: Props ): Node => {
|
||||
const [hideResults, setHideResults] = useState( false );
|
||||
const theme = useTheme( );
|
||||
const queryClient = useQueryClient( );
|
||||
|
||||
// this seems necessary for clearing the cache between searches
|
||||
queryClient.invalidateQueries( ["fetchSearchResults"] );
|
||||
|
||||
const {
|
||||
data: placeResults
|
||||
} = useAuthenticatedQuery(
|
||||
["fetchSearchResults", locationName],
|
||||
optsWithAuth => fetchSearchResults( {
|
||||
q: locationName,
|
||||
sources: "places",
|
||||
fields: "place,place.display_name,place.point_geojson"
|
||||
}, optsWithAuth )
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchBar
|
||||
handleTextChange={locationText => {
|
||||
setLocationName( locationText );
|
||||
setHideResults( false );
|
||||
}}
|
||||
value={locationName}
|
||||
testID="LocationPicker.locationSearch"
|
||||
containerClass="absolute top-[20px] right-[26px] left-[26px]"
|
||||
/>
|
||||
<View
|
||||
className="absolute top-[65px] right-[26px] left-[26px] bg-white rounded-lg z-50"
|
||||
style={getShadow( theme.colors.primary )}
|
||||
>
|
||||
{!hideResults && placeResults?.map( place => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
key={place.id}
|
||||
className="p-2 border-[0.5px] border-lightGray"
|
||||
onPress={( ) => {
|
||||
setHideResults( true );
|
||||
setLocationName( place.display_name );
|
||||
const { coordinates } = place.point_geojson;
|
||||
setRegion( {
|
||||
...region,
|
||||
latitude: coordinates[1],
|
||||
longitude: coordinates[0]
|
||||
} );
|
||||
}}
|
||||
>
|
||||
<Body3>{place.display_name}</Body3>
|
||||
</Pressable>
|
||||
) )}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationSearch;
|
||||
53
src/components/LocationPicker/WarningText.js
Normal file
53
src/components/LocationPicker/WarningText.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// @flow
|
||||
|
||||
import classnames from "classnames";
|
||||
import {
|
||||
Body3
|
||||
} from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { useTheme } from "react-native-paper";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
|
||||
type Props = {
|
||||
accuracyTest: string,
|
||||
getShadow: Function
|
||||
};
|
||||
|
||||
const WarningText = ( { accuracyTest, getShadow }: Props ): Node => {
|
||||
const theme = useTheme( );
|
||||
const { t } = useTranslation( );
|
||||
|
||||
const displayWarningText = ( ) => {
|
||||
if ( accuracyTest === "acceptable" ) {
|
||||
return t( "Zoom-in" );
|
||||
}
|
||||
if ( accuracyTest === "fail" ) {
|
||||
return t( "Location-accuracy-is-too-imprecise" );
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="justify-center items-center" style={getShadow( theme.colors.primary )}>
|
||||
<View
|
||||
className={classnames( "p-4 rounded-xl bottom-[180px] max-w-[316px]", {
|
||||
"bg-transparent": accuracyTest === "pass",
|
||||
"bg-white": accuracyTest === "acceptable",
|
||||
"bg-warningRed": accuracyTest === "fail"
|
||||
} )}
|
||||
>
|
||||
<Body3
|
||||
className={classnames( "text-black", {
|
||||
"text-white": accuracyTest === "fail"
|
||||
} )}
|
||||
>
|
||||
{displayWarningText( )}
|
||||
</Body3>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default WarningText;
|
||||
@@ -1,11 +1,12 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { MAX_PHOTOS_ALLOWED } from "components/Camera/StandardCamera";
|
||||
import MediaViewerModal from "components/MediaViewer/MediaViewerModal";
|
||||
import {
|
||||
Body3, Body4, Heading4, INatIcon
|
||||
} from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import { Pressable, View } from "components/styledComponents";
|
||||
import {
|
||||
differenceInCalendarYears,
|
||||
isFuture,
|
||||
@@ -46,6 +47,12 @@ const EvidenceSection = ( ): Node => {
|
||||
obsPhoto => Photo.displayLocalOrRemoteSquarePhoto( obsPhoto.photo )
|
||||
) : [];
|
||||
const mountedRef = useRef( true );
|
||||
const navigation = useNavigation( );
|
||||
|
||||
const navToLocationPicker = ( ) => {
|
||||
navigation.navigate( "LocationPicker", { goBackOnSave: true } );
|
||||
};
|
||||
|
||||
const [showAddEvidenceSheet, setShowAddEvidenceSheet] = useState( false );
|
||||
const handleAddEvidence = ( ) => setShowAddEvidenceSheet( true );
|
||||
|
||||
@@ -216,13 +223,13 @@ const EvidenceSection = ( ): Node => {
|
||||
/>
|
||||
<View className="flex-row flex-nowrap my-4">
|
||||
<INatIcon size={14} name="map-marker-outline" />
|
||||
<View className="ml-5">
|
||||
<Pressable accessibilityRole="button" className="ml-5" onPress={navToLocationPicker}>
|
||||
{displayPlaceName( )}
|
||||
{/* $FlowIgnore */}
|
||||
<Body4 className={( !latitude || !longitude ) && "color-warningRed"}>
|
||||
{displayLocation( )}
|
||||
</Body4>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<DatePicker currentObservation={currentObservation} />
|
||||
</View>
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import { Button } from "components/SharedComponents";
|
||||
import InputField from "components/SharedComponents/InputField";
|
||||
import Map from "components/SharedComponents/Map";
|
||||
import ViewWrapper from "components/SharedComponents/ViewWrapper";
|
||||
import type { Node } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
|
||||
import useCoords from "sharedHooks/useCoords";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
import { viewStyles } from "styles/obsEdit/locationPicker";
|
||||
|
||||
type Props = {
|
||||
closeLocationPicker: Function,
|
||||
updateLocation: Function,
|
||||
};
|
||||
|
||||
const LocationPicker = ( {
|
||||
closeLocationPicker,
|
||||
updateLocation
|
||||
}: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const [searchQuery, setSearchQuery] = useState( "" );
|
||||
const [region, setRegion] = useState( {
|
||||
latitude: null,
|
||||
longitude: null
|
||||
} );
|
||||
|
||||
const locationName = fetchPlaceName( region.latitude, region.longitude );
|
||||
const newCoords = useCoords( searchQuery );
|
||||
|
||||
const updateLocationAndClose = () => {
|
||||
updateLocation( {
|
||||
latitude: region.latitude,
|
||||
longitude: region.longitude,
|
||||
placeGuess: locationName
|
||||
} );
|
||||
closeLocationPicker();
|
||||
};
|
||||
|
||||
useEffect( () => {
|
||||
// update region when user types search term
|
||||
if ( !searchQuery ) {
|
||||
return;
|
||||
}
|
||||
if ( newCoords.latitude !== null && newCoords.latitude !== region.latitude ) {
|
||||
setRegion( newCoords );
|
||||
}
|
||||
}, [newCoords, region, searchQuery] );
|
||||
|
||||
const updateCoords = newMapRegion => {
|
||||
setSearchQuery( "" );
|
||||
setRegion( newMapRegion );
|
||||
};
|
||||
|
||||
return (
|
||||
<ViewWrapper>
|
||||
<InputField
|
||||
handleTextChange={setSearchQuery}
|
||||
placeholder={locationName || ""}
|
||||
text={searchQuery}
|
||||
type="addressCity"
|
||||
testID="LocationPicker.search"
|
||||
/>
|
||||
<Map updateCoords={updateCoords} region={region} mapHeight={600} />
|
||||
<View style={viewStyles.confirmButtonFooter}>
|
||||
<Button
|
||||
level="focus"
|
||||
text={t( "confirm location" )}
|
||||
onPress={updateLocationAndClose}
|
||||
testID="LocationPicker.confirmButton"
|
||||
/>
|
||||
</View>
|
||||
</ViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationPicker;
|
||||
@@ -9,10 +9,13 @@ import { IconButton, useTheme } from "react-native-paper";
|
||||
type Props = {
|
||||
className?: string,
|
||||
handleClose?: Function,
|
||||
black?: boolean,
|
||||
size?: number
|
||||
}
|
||||
|
||||
const CloseButton = ( { className, handleClose, size }: Props ): Node => {
|
||||
const CloseButton = ( {
|
||||
className, handleClose, black, size
|
||||
}: Props ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const theme = useTheme( );
|
||||
|
||||
@@ -21,7 +24,7 @@ const CloseButton = ( { className, handleClose, size }: Props ): Node => {
|
||||
icon="close"
|
||||
size={size}
|
||||
className={className}
|
||||
iconColor={theme.colors.background}
|
||||
iconColor={black ? theme.colors.tertiary : theme.colors.background}
|
||||
onPress={( ) => {
|
||||
if ( handleClose ) {
|
||||
handleClose( );
|
||||
|
||||
67
src/components/SharedComponents/SearchBar.js
Normal file
67
src/components/SharedComponents/SearchBar.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// @flow
|
||||
import { INatIcon } from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import * as React from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { TextInput, useTheme } from "react-native-paper";
|
||||
import { getShadowStyle } from "styles/global";
|
||||
|
||||
const getShadow = shadowColor => getShadowStyle( {
|
||||
shadowColor,
|
||||
offsetWidth: 0,
|
||||
offsetHeight: 2,
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 2,
|
||||
elevation: 5
|
||||
} );
|
||||
|
||||
type Props = {
|
||||
containerClass?: string,
|
||||
handleTextChange: Function,
|
||||
value: string,
|
||||
testID?: string
|
||||
}
|
||||
|
||||
// Ensure this component is placed outside of scroll views
|
||||
|
||||
const SearchBar = ( {
|
||||
containerClass,
|
||||
testID,
|
||||
handleTextChange,
|
||||
value
|
||||
}: Props ): React.Node => {
|
||||
const theme = useTheme( );
|
||||
|
||||
return (
|
||||
<View className={containerClass}>
|
||||
<TextInput
|
||||
accessibilityLabel="Search bar"
|
||||
keyboardType="default"
|
||||
mode="flat"
|
||||
onChangeText={handleTextChange}
|
||||
value={value}
|
||||
className="bg-white w-full rounded-lg h-[45px]"
|
||||
testID={testID}
|
||||
style={getShadow( theme.colors.primary )}
|
||||
underlineColor={theme.colors.primary}
|
||||
activeUnderlineColor={theme.colors.primary}
|
||||
// kind of tricky to change the font here:
|
||||
// https://github.com/callstack/react-native-paper/issues/3615#issuecomment-1402025033
|
||||
theme={{
|
||||
fonts: {
|
||||
bodyLarge:
|
||||
{
|
||||
...theme.fonts.bodyLarge,
|
||||
fontFamily: `Whitney-Light${Platform.OS === "ios" ? "" : "-Pro"}`
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<View className="absolute right-4 top-4">
|
||||
<INatIcon name="magnifying-glass" size={14} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -12,11 +12,13 @@ export { default as FloatingActionBar } from "./FloatingActionBar";
|
||||
export { default as INatIcon } from "./INatIcon";
|
||||
export { default as InlineUser } from "./InlineUser/InlineUser";
|
||||
export { default as KebabMenu } from "./KebabMenu";
|
||||
export { default as Map } from "./Map";
|
||||
export { default as ObservationLocation } from "./ObservationLocation";
|
||||
export { default as PhotoCarousel } from "./PhotoCarousel";
|
||||
export { default as PhotoCount } from "./PhotoCount";
|
||||
export { default as QualityGradeStatus } from "./QualityGradeStatus/QualityGradeStatus";
|
||||
export { default as ScrollViewWrapper } from "./ScrollViewWrapper";
|
||||
export { default as SearchBar } from "./SearchBar";
|
||||
export { default as BottomSheet } from "./Sheets/BottomSheet";
|
||||
export { default as BottomSheetStandardBackdrop } from "./Sheets/BottomSheetStandardBackdrop";
|
||||
export { default as RadioButtonSheet } from "./Sheets/RadioButtonSheet";
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ObservationLocation,
|
||||
PhotoCount,
|
||||
QualityGradeStatus,
|
||||
SearchBar,
|
||||
StickyToolbar,
|
||||
Subheading1,
|
||||
Tabs,
|
||||
@@ -516,7 +517,7 @@ const UiLibrary = (): Node => {
|
||||
|
||||
<Heading2 className="my-2">ActivityItem</Heading2>
|
||||
<ActivityItem item={exampleId} currentUserId={userId} />
|
||||
|
||||
<SearchBar value="search" />
|
||||
<Heading2 className="my-2">More Stuff!</Heading2>
|
||||
<Body1 className="h-[400px]">
|
||||
Useless spacer at the end because height in NativeWind is confusing.
|
||||
|
||||
@@ -151,6 +151,8 @@ Display-Name = Display Name
|
||||
|
||||
Do-not-collect-stability-and-usage-data-using-third-party-services = Do not collect stability and usage data using third-party services
|
||||
|
||||
EDIT-LOCATION = EDIT LOCATION
|
||||
|
||||
# Appears above the email text field
|
||||
Email = email
|
||||
|
||||
@@ -263,6 +265,8 @@ Licensing = Licensing
|
||||
|
||||
Location = Location
|
||||
|
||||
Location-accuracy-is-too-imprecise = Location accuracy is too imprecise to help identifiers. Please zoom in.
|
||||
|
||||
LOCATION-TOO-IMPRECISE = LOCATION TOO IMPRECISE
|
||||
|
||||
Log-in = Log in
|
||||
@@ -542,6 +546,8 @@ RG = RG
|
||||
|
||||
Save = Save
|
||||
|
||||
SAVE-LOCATION = SAVE LOCATION
|
||||
|
||||
SAVE-ALL = SAVE ALL
|
||||
|
||||
SAVE-CHANGES = SAVE CHANGES
|
||||
@@ -761,6 +767,8 @@ You-will-lose-all-existing-observations = {$count ->
|
||||
You-can-still-share-the-file =
|
||||
You can still share the file with another app. If you can email it, please send it to { $email }
|
||||
|
||||
Zoom-in = Zoom in so that the observation’s accuracy is as low as possible.
|
||||
|
||||
Your-location-uncertainty-is-over-4000km = Your location uncertainty is over 4000km, which is too high to be helpful to identifiers. Edit the location and zoom in until the accuracy circle turns green and is centered on where you observed the organism.
|
||||
|
||||
# Identification category
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"Display": "Display",
|
||||
"Display-Name": "Display Name",
|
||||
"Do-not-collect-stability-and-usage-data-using-third-party-services": "Do not collect stability and usage data using third-party services",
|
||||
"EDIT-LOCATION": "EDIT LOCATION",
|
||||
"Email": {
|
||||
"comment": "Appears above the email text field",
|
||||
"val": "email"
|
||||
@@ -174,6 +175,7 @@
|
||||
},
|
||||
"Licensing": "Licensing",
|
||||
"Location": "Location",
|
||||
"Location-accuracy-is-too-imprecise": "Location accuracy is too imprecise to help identifiers. Please zoom in.",
|
||||
"LOCATION-TOO-IMPRECISE": "LOCATION TOO IMPRECISE",
|
||||
"Log-in": "Log in",
|
||||
"LOG-IN-TO-INATURALIST": "LOG IN TO INATURALIST",
|
||||
@@ -359,6 +361,7 @@
|
||||
"val": "RG"
|
||||
},
|
||||
"Save": "Save",
|
||||
"SAVE-LOCATION": "SAVE LOCATION",
|
||||
"SAVE-ALL": "SAVE ALL",
|
||||
"SAVE-CHANGES": "SAVE CHANGES",
|
||||
"Search-for-a-location": "Search for a location",
|
||||
@@ -489,6 +492,7 @@
|
||||
"You-must-be-logged-in-to-view-messages": "You must be logged in to view messages",
|
||||
"You-will-lose-all-existing-observations": "{ $count ->\n [one] You will lose all existing observations. Would you like to discard 1 observation?\n *[other] You will lose all existing observations. Would you like to discard { $count } observations?\n}",
|
||||
"You-can-still-share-the-file": "You can still share the file with another app. If you can email it, please send it to { $email }",
|
||||
"Zoom-in": "Zoom in so that the observation’s accuracy is as low as possible.",
|
||||
"Your-location-uncertainty-is-over-4000km": "Your location uncertainty is over 4000km, which is too high to be helpful to identifiers. Edit the location and zoom in until the accuracy circle turns green and is centered on where you observed the organism.",
|
||||
"Category-leading": {
|
||||
"comment": "Identification category",
|
||||
|
||||
@@ -151,6 +151,8 @@ Display-Name = Display Name
|
||||
|
||||
Do-not-collect-stability-and-usage-data-using-third-party-services = Do not collect stability and usage data using third-party services
|
||||
|
||||
EDIT-LOCATION = EDIT LOCATION
|
||||
|
||||
# Appears above the email text field
|
||||
Email = email
|
||||
|
||||
@@ -263,6 +265,8 @@ Licensing = Licensing
|
||||
|
||||
Location = Location
|
||||
|
||||
Location-accuracy-is-too-imprecise = Location accuracy is too imprecise to help identifiers. Please zoom in.
|
||||
|
||||
LOCATION-TOO-IMPRECISE = LOCATION TOO IMPRECISE
|
||||
|
||||
Log-in = Log in
|
||||
@@ -542,6 +546,8 @@ RG = RG
|
||||
|
||||
Save = Save
|
||||
|
||||
SAVE-LOCATION = SAVE LOCATION
|
||||
|
||||
SAVE-ALL = SAVE ALL
|
||||
|
||||
SAVE-CHANGES = SAVE CHANGES
|
||||
@@ -761,6 +767,8 @@ You-will-lose-all-existing-observations = {$count ->
|
||||
You-can-still-share-the-file =
|
||||
You can still share the file with another app. If you can email it, please send it to { $email }
|
||||
|
||||
Zoom-in = Zoom in so that the observation’s accuracy is as low as possible.
|
||||
|
||||
Your-location-uncertainty-is-over-4000km = Your location uncertainty is over 4000km, which is too high to be helpful to identifiers. Edit the location and zoom in until the accuracy circle turns green and is centered on where you observed the organism.
|
||||
|
||||
# Identification category
|
||||
|
||||
@@ -83,6 +83,7 @@ const CustomTabBar = ( { state, descriptors, navigation }: Props ): Node => {
|
||||
|| currentRoute.includes( "ObsEdit" )
|
||||
|| currentRoute.includes( "AddID" )
|
||||
|| currentRoute.includes( "Login" )
|
||||
|| currentRoute.includes( "LocationPicker" )
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import About from "components/About";
|
||||
import StandardCamera from "components/Camera/StandardCamera";
|
||||
import Explore from "components/Explore/Explore";
|
||||
import Identify from "components/Identify/Identify";
|
||||
import LocationPicker from "components/LocationPicker/LocationPicker";
|
||||
import Login from "components/LoginSignUp/Login";
|
||||
import Messages from "components/Messages/Messages";
|
||||
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer";
|
||||
@@ -280,6 +281,15 @@ const BottomTabs = () => {
|
||||
title: t( "Add-an-ID" )
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="LocationPicker"
|
||||
component={LocationPicker}
|
||||
options={{
|
||||
...blankHeaderTitle,
|
||||
...hideHeaderLeft
|
||||
// title: t( "EDIT-LOCATION" )
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen name="Login" component={MortalLogin} options={hideHeader} />
|
||||
</Tab.Navigator>
|
||||
</Mortal>
|
||||
|
||||
19
src/sharedHelpers/fetchCoordinates.js
Normal file
19
src/sharedHelpers/fetchCoordinates.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// @flow
|
||||
import Geocoder from "react-native-geocoder-reborn";
|
||||
|
||||
const fetchCoordinates = async ( locationName: ?string ): any => {
|
||||
if ( !locationName ) { return null; }
|
||||
try {
|
||||
const results = await Geocoder.geocodeAddress( locationName );
|
||||
if ( results.length === 0 ) { return null; }
|
||||
const { position } = results[0];
|
||||
return {
|
||||
latitude: position.lat,
|
||||
longitude: position.lng
|
||||
};
|
||||
} catch ( e ) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default fetchCoordinates;
|
||||
@@ -36,9 +36,13 @@ const setPlaceName = ( results: Array<Object> ): string => {
|
||||
const fetchPlaceName = async ( lat: ?number, lng: ?number ): any => {
|
||||
const { isInternetReachable } = await NetInfo.fetch( );
|
||||
if ( !lat || !lng || !isInternetReachable ) { return null; }
|
||||
const results = await Geocoder.geocodePosition( { lat, lng } );
|
||||
if ( results.length === 0 ) { return null; }
|
||||
return setPlaceName( results );
|
||||
try {
|
||||
const results = await Geocoder.geocodePosition( { lat, lng } );
|
||||
if ( results.length === 0 || typeof results !== "object" ) { return null; }
|
||||
return setPlaceName( results );
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default fetchPlaceName;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import { Dimensions, StyleSheet } from "react-native";
|
||||
import type {
|
||||
ImageStyleProp,
|
||||
TextStyleProp,
|
||||
ViewStyleProp
|
||||
} from "react-native/Libraries/StyleSheet/StyleSheet";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
const { width } = Dimensions.get( "screen" );
|
||||
|
||||
const textStyles: { [string]: TextStyleProp } = StyleSheet.create( {
|
||||
|
||||
} );
|
||||
|
||||
const imageStyles: { [string]: ImageStyleProp } = StyleSheet.create( {
|
||||
|
||||
} );
|
||||
|
||||
const viewStyles: { [string]: ViewStyleProp } = StyleSheet.create( {
|
||||
confirmButtonFooter: {
|
||||
zIndex: 1,
|
||||
height: 100,
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
backgroundColor: colors.white,
|
||||
width,
|
||||
paddingTop: 10
|
||||
}
|
||||
} );
|
||||
|
||||
export {
|
||||
imageStyles,
|
||||
textStyles,
|
||||
viewStyles
|
||||
};
|
||||
12
tests/factories/RemotePlace.js
Normal file
12
tests/factories/RemotePlace.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { define } from "factoria";
|
||||
|
||||
export default define( "RemotePlace", faker => ( {
|
||||
id: faker.datatype.number( ),
|
||||
display_name: faker.address.cityName( ),
|
||||
point_geojson: {
|
||||
coordinates: [
|
||||
Number( faker.address.longitude( ) ),
|
||||
Number( faker.address.latitude( ) )
|
||||
]
|
||||
}
|
||||
} ) );
|
||||
129
tests/unit/components/LocationPicker/LocationPicker.test.js
Normal file
129
tests/unit/components/LocationPicker/LocationPicker.test.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react-native";
|
||||
import LocationPicker from "components/LocationPicker/LocationPicker";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import ObsEditProvider from "providers/ObsEditProvider";
|
||||
import React from "react";
|
||||
|
||||
import factory from "../../../factory";
|
||||
import { renderComponent } from "../../../helpers/render";
|
||||
|
||||
jest.mock( "@react-navigation/native", ( ) => {
|
||||
const actualNav = jest.requireActual( "@react-navigation/native" );
|
||||
return {
|
||||
...actualNav,
|
||||
useNavigation: ( ) => ( {
|
||||
setOptions: jest.fn( )
|
||||
} )
|
||||
};
|
||||
} );
|
||||
|
||||
// Mock ObservationProvider so it provides a specific array of observations
|
||||
// without any current observation or ability to update or fetch
|
||||
// observations
|
||||
jest.mock( "providers/ObsEditProvider" );
|
||||
const mockObsEditProviderWithObs = obs => ObsEditProvider.mockImplementation( ( { children } ) => (
|
||||
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
||||
<ObsEditContext.Provider value={{
|
||||
observations: obs,
|
||||
currentObservation: obs[0]
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ObsEditContext.Provider>
|
||||
) );
|
||||
|
||||
const renderLocationPicker = ( ) => renderComponent(
|
||||
<ObsEditProvider>
|
||||
<LocationPicker route={{
|
||||
params: {
|
||||
goBackOnSave: jest.fn( )
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ObsEditProvider>
|
||||
);
|
||||
|
||||
const mockPlaceResult = factory( "RemotePlace", {
|
||||
display_name: "New York"
|
||||
} );
|
||||
|
||||
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
|
||||
__esModule: true,
|
||||
default: ( ) => ( {
|
||||
data: [mockPlaceResult]
|
||||
} )
|
||||
} ) );
|
||||
|
||||
describe( "LocationPicker", () => {
|
||||
beforeAll( async ( ) => {
|
||||
await initI18next( );
|
||||
} );
|
||||
|
||||
it(
|
||||
"should display latitude corresponding with location name",
|
||||
async ( ) => {
|
||||
const observations = [
|
||||
factory( "RemoteObservation", {
|
||||
// Oakland, CA latlng
|
||||
latitude: 37.804855,
|
||||
longitude: -122.272504
|
||||
} )
|
||||
];
|
||||
mockObsEditProviderWithObs( observations );
|
||||
renderLocationPicker( );
|
||||
const initialLatitude = screen.getByText( new RegExp( observations[0].latitude ) );
|
||||
expect( initialLatitude ).toBeTruthy( );
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"should show search results when a user changes search text",
|
||||
async ( ) => {
|
||||
const observations = [
|
||||
factory( "RemoteObservation", {
|
||||
// Oakland, CA latlng
|
||||
latitude: 37.804855,
|
||||
longitude: -122.272504
|
||||
} )
|
||||
];
|
||||
mockObsEditProviderWithObs( observations );
|
||||
renderLocationPicker( );
|
||||
const input = screen.getByTestId( "LocationPicker.locationSearch" );
|
||||
const initialLatitude = screen.getByText( new RegExp( observations[0].latitude ) );
|
||||
expect( initialLatitude ).toBeTruthy( );
|
||||
fireEvent.changeText( input, "New" );
|
||||
await waitFor( ( ) => {
|
||||
expect( screen.getByText( mockPlaceResult.display_name ) ).toBeTruthy( );
|
||||
} );
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"should move map to new coordinates when a user presses place result",
|
||||
async ( ) => {
|
||||
const observations = [
|
||||
factory( "RemoteObservation", {
|
||||
// Oakland, CA latlng
|
||||
latitude: 37.804855,
|
||||
longitude: -122.272504
|
||||
} )
|
||||
];
|
||||
mockObsEditProviderWithObs( observations );
|
||||
renderLocationPicker( );
|
||||
const input = screen.getByTestId( "LocationPicker.locationSearch" );
|
||||
const initialLatitude = screen.getByText( new RegExp( observations[0].latitude ) );
|
||||
expect( initialLatitude ).toBeTruthy( );
|
||||
fireEvent.changeText( input, "New" );
|
||||
await waitFor( ( ) => {
|
||||
expect( screen.getByText( mockPlaceResult.display_name ) ).toBeTruthy( );
|
||||
} );
|
||||
fireEvent.press( screen.getByText( mockPlaceResult.display_name ) );
|
||||
await waitFor( ( ) => {
|
||||
expect( screen.getByText(
|
||||
new RegExp( mockPlaceResult.point_geojson.coordinates[0] )
|
||||
) ).toBeTruthy( );
|
||||
} );
|
||||
}
|
||||
);
|
||||
} );
|
||||
Reference in New Issue
Block a user