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:
Amanda Bullington
2023-04-26 10:51:05 -07:00
committed by GitHub
parent 1439c40c6a
commit 5ee4a433df
31 changed files with 787 additions and 166 deletions

View File

@@ -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
View File

@@ -78,4 +78,4 @@ artifacts/
*.log
# VisualStudioCode #
.vscode
.vscode

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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 ) => {

View File

@@ -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
View File

@@ -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": {

View File

@@ -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",

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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( );

View 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;

View File

@@ -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";

View File

@@ -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.

View File

@@ -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 observations 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

View File

@@ -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 observations 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",

View File

@@ -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 observations 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

View File

@@ -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;
}

View File

@@ -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>

View 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;

View File

@@ -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;

View File

@@ -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
};

View 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( ) )
]
}
} ) );

View 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( );
} );
}
);
} );