Merge branch 'main' into obs-detail

This commit is contained in:
Angie Ta
2023-07-26 16:01:47 -07:00
23 changed files with 611 additions and 341 deletions

View File

@@ -7,18 +7,19 @@ import type { Node } from "react";
import React from "react";
import { useTheme } from "react-native-paper";
import WarningText from "./WarningText";
type Props = {
accuracyTest: string,
containerStyle: Object
getShadow: Function
};
const CrosshairCircle = ( { accuracyTest, containerStyle }: Props ): Node => {
const CrosshairCircle = ( { accuracyTest, getShadow }: Props ): Node => {
const theme = useTheme( );
return (
<View
className="absolute"
style={containerStyle}
className="right-[127px] bottom-[127px]"
pointerEvents="none"
>
<View
@@ -54,6 +55,9 @@ const CrosshairCircle = ( { accuracyTest, containerStyle }: Props ): Node => {
/>
)}
</View>
<View className="absolute m-auto left-0 right-0 top-[300px]">
<WarningText accuracyTest={accuracyTest} getShadow={getShadow} />
</View>
</View>
);
};

View File

@@ -1,14 +1,11 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import {
Button, StickyToolbar
} from "components/SharedComponents";
import { Button } from "components/SharedComponents";
import { View } from "components/styledComponents";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React, {
useContext
} from "react";
import React, { useContext } from "react";
import useTranslation from "sharedHooks/useTranslation";
type Props = {
@@ -24,9 +21,9 @@ const Footer = ( { keysToUpdate, goBackOnSave }: Props ): Node => {
} = useContext( ObsEditContext );
return (
<StickyToolbar>
<View className="h-[73px] justify-center">
<Button
className="px-[25px]"
className="mx-[25px]"
onPress={( ) => {
updateObservationKeys( keysToUpdate );
if ( goBackOnSave ) {
@@ -37,7 +34,7 @@ const Footer = ( { keysToUpdate, goBackOnSave }: Props ): Node => {
text={t( "SAVE-LOCATION" )}
level="neutral"
/>
</StickyToolbar>
</View>
);
};

View File

@@ -0,0 +1,14 @@
// @flow
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { ActivityIndicator } from "react-native-paper";
const LoadingIndicator = ( ): Node => (
<View className="h-[80px] w-[80px] bg-white right-[40px] bottom-[40px] justify-center">
<ActivityIndicator large />
</View>
);
export default LoadingIndicator;

View File

@@ -1,30 +1,27 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import {
Body3,
CloseButton, Heading4,
INatIconButton,
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 React from "react";
import MapView from "react-native-maps";
import { IconButton, useTheme } from "react-native-paper";
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
import fetchUserLocation from "sharedHelpers/fetchUserLocation";
import { useTheme } from "react-native-paper";
import useTranslation from "sharedHooks/useTranslation";
import { getShadowStyle } from "styles/global";
import CrosshairCircle from "./CrosshairCircle";
import DisplayLatLng from "./DisplayLatLng";
import Footer from "./Footer";
import LoadingIndicator from "./LoadingIndicator";
import LocationSearch from "./LocationSearch";
import WarningText from "./WarningText";
export const DESIRED_LOCATION_ACCURACY = 100;
export const REQUIRED_LOCATION_ACCURACY = 500000;
const getShadow = shadowColor => getShadowStyle( {
shadowColor,
@@ -35,152 +32,131 @@ const getShadow = shadowColor => getShadowStyle( {
elevation: 5
} );
const { width, height } = Dimensions.get( "screen" );
const DELTA = 0.2;
const CROSSHAIRLENGTH = 254;
export const DESIRED_LOCATION_ACCURACY = 100;
export const REQUIRED_LOCATION_ACCURACY = 500000;
type Props = {
route: {
params: {
goBackOnSave: boolean
},
},
showMap: boolean,
loading: boolean,
accuracyTest: string,
region: Object,
mapView: any,
updateRegion: Function,
locationName: ?string,
updateLocationName: Function,
accuracy: number,
returnToUserLocation: Function,
keysToUpdate: Object,
goBackOnSave: Function,
toggleMapLayer: Function,
mapType: string,
setMapReady: Function,
selectPlaceResult: Function,
hidePlaceResults: boolean
};
const centerCrosshair = ( height / 2 ) - CROSSHAIRLENGTH + 30;
const LocationPicker = ( { route }: Props ): Node => {
const LocationPicker = ( {
showMap,
loading,
accuracyTest,
region,
mapView,
updateRegion,
locationName,
updateLocationName,
accuracy,
returnToUserLocation,
keysToUpdate,
goBackOnSave,
toggleMapLayer,
mapType,
setMapReady,
selectPlaceResult,
hidePlaceResults
}: 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 < DESIRED_LOCATION_ACCURACY ) {
setAccuracyTest( "pass" );
} else if ( accuracy < REQUIRED_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}
<ViewWrapper testID="location-picker" className="flex-1">
<View className="justify-center">
<Heading4 className="self-center my-4">{t( "EDIT-LOCATION" )}</Heading4>
<View className="absolute right-2">
<CloseButton black size={19} />
</View>
</View>
<View className="z-20">
<LocationSearch
locationName={locationName}
updateLocationName={updateLocationName}
getShadow={getShadow}
selectPlaceResult={selectPlaceResult}
hidePlaceResults={hidePlaceResults}
/>
</View>
<View style={getShadow( theme.colors.primary )}>
<IconButton
className="absolute bottom-20 bg-white right-2"
icon="location-crosshairs"
onPress={returnToUserLocation}
<View className="z-10">
<DisplayLatLng
region={region}
accuracy={accuracy}
getShadow={getShadow}
/>
</View>
<View className="top-1/2 left-1/2 absolute z-10">
{showMap && (
<CrosshairCircle
accuracyTest={accuracyTest}
getShadow={getShadow}
/>
)}
</View>
<View className="top-1/2 left-1/2 absolute z-10">
{loading && <LoadingIndicator />}
</View>
<View className="flex-shrink">
{showMap
? (
<MapView
className="h-full"
showsCompass={false}
region={region}
ref={mapView}
mapType={mapType}
// TODO: figure out the right zoom level here
// don't think it's necessary to let a user zoom out far beyond cities
minZoomLevel={5}
onRegionChangeComplete={async newRegion => {
updateRegion( newRegion );
}}
onMapReady={setMapReady}
/>
)
: (
<View className="h-full bg-lightGray items-center justify-center">
<Body3>{t( "Try-searching-for-a-location-name" )}</Body3>
</View>
)}
<View
style={getShadow( theme.colors.primary )}
className="absolute bottom-3 bg-white left-3 rounded-full"
>
<INatIconButton
icon="layers"
onPress={toggleMapLayer}
height={46}
width={46}
size={24}
/>
</View>
<View
style={getShadow( theme.colors.primary )}
className="absolute bottom-3 bg-white right-3 rounded-full"
>
<INatIconButton
icon="location-crosshairs"
onPress={returnToUserLocation}
height={46}
width={46}
size={24}
/>
</View>
</View>
<Footer
keysToUpdate={keysToUpdate}
goBackOnSave={goBackOnSave}

View File

@@ -0,0 +1,261 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React, {
useCallback,
useContext, useEffect,
useReducer, useRef
} from "react";
import { Dimensions, Platform } from "react-native";
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
import fetchUserLocation from "sharedHelpers/fetchUserLocation";
import LocationPicker from "./LocationPicker";
const { width } = Dimensions.get( "screen" );
const DELTA = 0.02;
const CROSSHAIRLENGTH = 254;
export const DESIRED_LOCATION_ACCURACY = 100;
export const REQUIRED_LOCATION_ACCURACY = 500000;
const estimatedAccuracy = longitudeDelta => longitudeDelta * 1000 * (
( CROSSHAIRLENGTH / width ) * 100 );
const initialState = {
accuracy: 0,
accuracyTest: "pass",
locationName: null,
mapType: "standard",
region: {
latitude: 0.0,
longitude: 0.0,
latitudeDelta: DELTA,
longitudeDelta: DELTA
},
loading: true,
hidePlaceResults: true
};
const reducer = ( state, action ) => {
console.log( action.type, "action type" );
switch ( action.type ) {
case "ESTIMATE_ACCURACY":
return {
...state,
positional_accuracy: estimatedAccuracy( state.region.longitudeDelta )
};
case "FETCH_CURRENT_LOCATION":
return {
...state,
loading: false,
region: {
...state.region,
latitude: action.userLocation?.latitude,
longitude: action.userLocation?.longitude
}
};
case "INITIALIZE_MAP":
return {
...state,
accuracy: action.currentObservation?.positional_accuracy,
locationName: action.currentObservation?.place_guess,
region: {
...state.region,
latitude: action.currentObservation?.latitude,
longitude: action.currentObservation?.longitude,
latitudeDelta: DELTA,
longitudeDelta: DELTA
}
};
case "RESET_LOCATION_PICKER":
return {
...action.initialState
};
case "SELECT_PLACE_RESULT":
return {
...state,
locationName: action.locationName,
region: action.region,
hidePlaceResults: true
};
case "SET_ACCURACY_TEST":
return {
...state,
accuracyTest: action.accuracyTest
};
case "SET_LOADING":
return {
...state,
loading: action.loading
};
case "SET_MAP_TYPE":
return {
...state,
mapType: action.mapType
};
case "UPDATE_LOCATION_NAME":
return {
...state,
locationName: action.locationName,
hidePlaceResults: false
};
case "UPDATE_REGION":
return {
...state,
locationName: action.locationName,
region: action.region,
accuracy: action.accuracy
};
default:
throw new Error( );
}
};
type Props = {
route: {
params: {
goBackOnSave: boolean
},
},
};
const LocationPickerContainer = ( { route }: Props ): Node => {
const mapView = useRef( );
const { currentObservation } = useContext( ObsEditContext );
const navigation = useNavigation( );
const { goBackOnSave } = route.params;
const [state, dispatch] = useReducer( reducer, initialState );
const {
accuracy,
accuracyTest,
loading,
locationName,
mapType,
region,
hidePlaceResults
} = state;
const showMap = region.latitude !== 0.0;
const keysToUpdate = {
latitude: region.latitude,
longitude: region.longitude,
positional_accuracy: accuracy,
place_guess: locationName
};
useEffect( ( ) => {
if ( accuracy < DESIRED_LOCATION_ACCURACY ) {
dispatch( { type: "SET_ACCURACY_TEST", accuracyTest: "pass" } );
} else if ( accuracy < REQUIRED_LOCATION_ACCURACY ) {
dispatch( { type: "SET_ACCURACY_TEST", accuracyTest: "acceptable" } );
} else {
dispatch( { type: "SET_ACCURACY_TEST", accuracyTest: "fail" } );
}
}, [accuracy] );
const updateRegion = async newRegion => {
const newAccuracy = estimatedAccuracy( newRegion.longitudeDelta );
// don't update region if map hasn't actually moved
// otherwise, it's jittery on Android
if ( newRegion.latitude.toFixed( 6 ) === region.latitude?.toFixed( 6 )
&& newRegion.longitude.toFixed( 6 ) === region.longitude?.toFixed( 6 )
&& newRegion.latitudeDelta.toFixed( 6 ) === region.latitudeDelta?.toFixed( 6 ) ) {
return;
}
const placeName = await fetchPlaceName( newRegion.latitude, newRegion.longitude );
dispatch( {
type: "UPDATE_REGION",
locationName: placeName || null,
region: newRegion,
accuracy: newAccuracy
} );
};
const toggleMapLayer = ( ) => {
if ( mapType === "standard" ) {
dispatch( { type: "SET_MAP_TYPE", mapType: "satellite" } );
} else {
dispatch( { type: "SET_MAP_TYPE", mapType: "standard" } );
}
};
const returnToUserLocation = async ( ) => {
dispatch( { type: "SET_LOADING", loading: false } );
const userLocation = await fetchUserLocation( );
dispatch( { type: "FETCH_CURRENT_LOCATION", userLocation } );
mapView.current?.getCamera().then( cam => {
if ( Platform.OS === "android" ) {
cam.zoom = 20;
} else {
cam.altitude = 100;
}
mapView.current?.animateCamera( cam );
} );
dispatch( { type: "ESTIMATE_ACCURACY" } );
};
const updateLocationName = useCallback( name => {
dispatch( { type: "UPDATE_LOCATION_NAME", locationName: name } );
}, [] );
// reset to initialState when exiting screen without saving
useEffect(
( ) => {
navigation.addListener( "focus", ( ) => {
if ( !currentObservation ) { return; }
dispatch( { type: "INITIALIZE_MAP", currentObservation } );
} );
navigation.addListener( "blur", ( ) => {
dispatch( { type: "RESET_LOCATION_PICKER", initialState } );
} );
},
[navigation, currentObservation]
);
const setMapReady = ( ) => dispatch( { type: "SET_LOADING", loading: false } );
const selectPlaceResult = place => {
const { coordinates } = place.point_geojson;
dispatch( {
type: "SELECT_PLACE_RESULT",
locationName: place.display_name,
region: {
...region,
latitude: coordinates[1],
longitude: coordinates[0]
}
} );
};
return (
<LocationPicker
showMap={showMap}
loading={loading}
accuracyTest={accuracyTest}
region={region}
mapView={mapView}
updateRegion={updateRegion}
locationName={locationName}
updateLocationName={updateLocationName}
accuracy={accuracy}
returnToUserLocation={returnToUserLocation}
keysToUpdate={keysToUpdate}
goBackOnSave={goBackOnSave}
toggleMapLayer={toggleMapLayer}
mapType={mapType}
setMapReady={setMapReady}
selectPlaceResult={selectPlaceResult}
hidePlaceResults={hidePlaceResults}
/>
);
};
export default LocationPickerContainer;

View File

@@ -8,24 +8,25 @@ import {
} from "components/SharedComponents";
import { Pressable, View } from "components/styledComponents";
import type { Node } from "react";
import React, { useState } from "react";
import React, { useRef } from "react";
import { Keyboard } from "react-native";
import { useTheme } from "react-native-paper";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
type Props = {
region: Object,
setRegion: Function,
locationName: string,
setLocationName: Function,
getShadow: Function
locationName: ?string,
updateLocationName: Function,
getShadow: Function,
selectPlaceResult: Function,
hidePlaceResults: boolean
};
const LocationSearch = ( {
region, setRegion, locationName, setLocationName, getShadow
locationName, updateLocationName, getShadow, selectPlaceResult, hidePlaceResults
}: Props ): Node => {
const [hideResults, setHideResults] = useState( false );
const theme = useTheme( );
const queryClient = useQueryClient( );
const locationInput = useRef( );
// this seems necessary for clearing the cache between searches
queryClient.invalidateQueries( ["fetchSearchResults"] );
@@ -45,32 +46,30 @@ const LocationSearch = ( {
<>
<SearchBar
handleTextChange={locationText => {
setLocationName( locationText );
setHideResults( false );
// only update location name when a user is typing,
// not when a user selects a location from the dropdown
if ( locationInput?.current?.isFocused( ) ) {
updateLocationName( locationText );
}
}}
value={locationName}
testID="LocationPicker.locationSearch"
containerClass="absolute top-[20px] right-[26px] left-[26px]"
hasShadow
input={locationInput}
/>
<View
className="absolute top-[65px] right-[26px] left-[26px] bg-white rounded-lg z-50"
className="absolute top-[65px] right-[26px] left-[26px] bg-white rounded-lg z-100"
style={getShadow( theme.colors.primary )}
>
{!hideResults && placeResults?.map( place => (
{!hidePlaceResults && 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]
} );
selectPlaceResult( place );
Keyboard.dismiss( );
}}
>
<Body3>{place.display_name}</Body3>

View File

@@ -30,26 +30,26 @@ const WarningText = ( { accuracyTest, getShadow }: Props ): Node => {
};
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"
} )}
<View
pointerEvents="none"
className={classnames( "p-4 rounded-xl", {
"bg-transparent": accuracyTest === "pass",
"bg-white": accuracyTest === "acceptable",
"bg-warningRed": accuracyTest === "fail"
} )}
style={getShadow( theme.colors.primary )}
>
<Body3
className={classnames(
"text-black",
"text-center",
{
"text-white": accuracyTest === "fail"
}
)}
>
<Body3
className={classnames(
"text-black",
"text-center",
{
"text-white": accuracyTest === "fail"
}
)}
>
{displayWarningText( )}
</Body3>
</View>
{displayWarningText( )}
</Body3>
</View>
);
};

View File

@@ -91,14 +91,19 @@ const MyObservations = ( {
// basing collapsible sticky header code off the example in this article
// https://medium.com/swlh/making-a-collapsible-sticky-header-animations-with-react-native-6ad7763875c3
const scrollY = useRef( new Animated.Value( 0 ) );
// On Android, the scroll view offset is a double (not an integer), and interpolation shouldn't be
// one-to-one, which causes a jittery header while slow scrolling (see issue #634).
// See here as well: https://stackoverflow.com/a/60898411/1233767
const scrollYClamped = diffClamp(
scrollY.current,
0,
heightAboveToolbar
heightAboveToolbar * 2
);
// Same as comment above (see here: https://stackoverflow.com/a/60898411/1233767)
const offsetForHeader = scrollYClamped.interpolate( {
inputRange: [0, heightAboveToolbar],
inputRange: [0, heightAboveToolbar * 2],
// $FlowIgnore
outputRange: [0, -heightAboveToolbar]
} );

View File

@@ -2,9 +2,9 @@
import ActivityHeader from "components/ObsDetails/ActivityHeader";
import AgreeWithIDSheet from "components/ObsDetails/Sheets/AgreeWithIDSheet";
import { DisplayTaxonName, Divider } from "components/SharedComponents";
import INatIcon from "components/SharedComponents/INatIcon";
import UserText from "components/SharedComponents/UserText";
import {
DisplayTaxonName, Divider, INatIcon, UserText
} from "components/SharedComponents";
import {
Pressable, View
} from "components/styledComponents";
@@ -28,21 +28,22 @@ type Props = {
onAgree: Function,
currentUserId?: Number,
observationUUID: string,
userAgreedId?: string
// userAgreedId?: string
}
const ActivityItem = ( {
item, navToTaxonDetails, toggleRefetch, refetchRemoteObservation, onAgree, currentUserId,
observationUUID, userAgreedId
observationUUID
}: Props ): Node => {
const { taxon, user } = item;
const isOnline = useIsConnected( );
const userId = currentUserId;
const [hideAgreeWithIdSheet, setHideAgreeWithIdSheet] = useState( true );
const showAgreeButton = taxon && user && user.id !== userId && taxon.rank_level <= 10;
const [showAgreeWithIdSheet, setShowAgreeWithIdSheet] = useState( false );
const [showCommentBox, setShowCommentBox] = useState( false );
const [comment, setComment] = useState( "" );
const showAgree = taxon && user && user.id !== userId && taxon.rank_level <= 10
&& userAgreedId !== taxon?.id;
// const showAgree = taxon && user && user.id !== userId && taxon.rank_level <= 10
// && userAgreedId !== taxon?.id;
const isCurrent = item.current !== undefined
? item.current
@@ -69,7 +70,7 @@ const ActivityItem = ( {
};
onAgree( agreeParams );
setHideAgreeWithIdSheet( true );
setShowAgreeWithIdSheet( false );
};
const openCommentBox = () => setShowCommentBox( true );
@@ -80,11 +81,11 @@ const ActivityItem = ( {
const agreeIdSheetDiscardChanges = () => {
setComment( "" );
setHideAgreeWithIdSheet( true );
setShowAgreeWithIdSheet( false );
};
const onIDAgreePressed = () => {
setHideAgreeWithIdSheet( false );
setShowAgreeWithIdSheet( true );
};
// const renderTaxonImage = () => {
@@ -122,11 +123,11 @@ const ActivityItem = ( {
layout="horizontal"
/>
</Pressable>
{ showAgree && (
{ showAgreeButton && (
<Pressable
testID="ActivityItem.AgreeIdButton"
accessibilityRole="button"
onPress={() => onIDAgreePressed( )}
onPress={onIDAgreePressed}
>
<INatIcon name="id-agree" size={33} />
</Pressable>
@@ -140,12 +141,12 @@ const ActivityItem = ( {
)}
<Divider />
<AgreeWithIDSheet
hide={hideAgreeWithIdSheet}
showAgreeWithIdSheet={showAgreeWithIdSheet}
comment={comment}
openCommentBox={openCommentBox}
taxon={taxon}
discardChanges={() => agreeIdSheetDiscardChanges( )}
handleClose={() => agreeIdSheetDiscardChanges( )}
discardChanges={agreeIdSheetDiscardChanges}
handleClose={agreeIdSheetDiscardChanges}
onAgree={onAgreePressed}
/>
<AddCommentModal

View File

@@ -77,15 +77,15 @@ const ActivityTab = ( {
createIdentificationMutation.mutate( { identification: agreeParams } );
};
const findRecentUserAgreedToID = () => {
const currentIds = observation?.identifications;
const userAgree = currentIds.filter( id => id.user?.id === userId );
return userAgree.length > 0
? userAgree[userAgree.length - 1].taxon.id
: undefined;
};
// const findRecentUserAgreedToID = () => {
// const currentIds = observation?.identifications;
// const userAgree = currentIds.filter( id => id.user?.id === userId );
// return userAgree.length > 0
// ? userAgree[userAgree.length - 1].taxon.id
// : undefined;
// };
const userAgreedToId = findRecentUserAgreedToID();
// const userAgreedToId = findRecentUserAgreedToID();
useEffect( ( ) => {
// set initial ids for activity tab
@@ -108,7 +108,7 @@ const ActivityTab = ( {
// https://github.com/inaturalist/inaturalist/blob/df6572008f60845b8ef5972a92a9afbde6f67829/app/webpack/observations/show/components/activity_item.jsx
const activitytemsList = activityItems.map( item => (
<ActivityItem
userAgreedId={userAgreedToId}
// userAgreedId={userAgreedToId}
key={item.uuid}
observationUUID={uuid}
item={item}

View File

@@ -19,7 +19,7 @@ import Taxon from "realmModels/Taxon";
handleClose: Function,
taxon: Object,
discardChanges: Function,
hide: boolean,
showAgreeWithIdSheet: boolean,
comment:string,
openCommentBox: Function
}
@@ -54,7 +54,7 @@ const AgreeWithIDSheet = ( {
handleClose,
discardChanges,
taxon,
hide,
showAgreeWithIdSheet,
comment,
openCommentBox
}: Props ): Node => {
@@ -70,7 +70,7 @@ const AgreeWithIDSheet = ( {
return (
<BottomSheet
hide={hide}
hidden={!showAgreeWithIdSheet}
handleClose={handleClose}
confirm={discardChanges}
headerText={t( "AGREE-WITH-ID" )}
@@ -81,7 +81,7 @@ const AgreeWithIDSheet = ( {
<View
className="mx-[26px] space-y-[11px] my-[15px]"
>
<List2>
<List2 className="text-black">
{t( "Agree-with-ID-description" )}
</List2>
{ comment && (
@@ -89,20 +89,20 @@ const AgreeWithIDSheet = ( {
className=" flex-row items-center bg-lightGray p-[15px] rounded"
>
<INatIcon name="add-comment-outline" size={22} />
<List2 className="ml-[7px]">
<List2 className="ml-[7px] text-black">
{comment}
</List2>
</View>
)}
{showTaxon( taxon )}
</View>
<View className="flex-row">
<View className="flex-row justify-evenly mx-3">
{comment
? (
<Button
text={t( "EDIT-COMMENT" )}
onPress={openCommentBox}
className="mx-3 grow"
className="mx-2 grow"
testID="ObsDetail.AgreeId.EditCommentButton"
disabled={!comment}
accessibilityHint={t( "Opens-add-comment-modal" )}
@@ -110,9 +110,9 @@ const AgreeWithIDSheet = ( {
)
: (
<Button
text={t( "COMMENT" )}
text={t( "ADD-COMMENT" )}
onPress={openCommentBox}
className="mx-3 grow"
className="mx-2 grow"
testID="ObsDetail.AgreeId.commentButton"
disabled={false}
accessibilityHint={t( "Opens-add-comment-modal" )}
@@ -122,10 +122,11 @@ const AgreeWithIDSheet = ( {
<Button
text={t( "AGREE" )}
onPress={onAgree}
className="mx-3 grow"
className="mx-2 grow"
testID="ObsDetail.AgreeId.cvSuggestionsButton"
accessibilityRole="link"
accessibilityHint={t( "Navigates-to-suggest-identification" )}
level={comment && "primary"}
/>
</View>
</BottomSheet>

View File

@@ -8,7 +8,6 @@ import React from "react";
import { useTheme } from "react-native-paper";
type Props = {
className?: string,
handleClose?: Function,
black?: boolean,
size?: number,
@@ -18,7 +17,7 @@ type Props = {
}
const CloseButton = ( {
className, handleClose, black, size, icon,
handleClose, black, size, icon,
width, height
}: Props ): Node => {
const navigation = useNavigation( );
@@ -28,7 +27,6 @@ const CloseButton = ( {
<INatIconButton
icon={icon || "close"}
size={size}
className={className}
color={black
? theme.colors.tertiary
: theme.colors.background}

View File

@@ -1,6 +1,6 @@
// @flow
import classNames from "classnames";
import { Body1, Body3, Body4 } from "components/SharedComponents";
import { Body1Bold, Body3, Body4 } from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
@@ -61,9 +61,9 @@ const DisplayTaxonName = ( {
if ( !taxon ) {
return (
<Body1 className={textClass()} numberOfLines={1}>
<Body1Bold className={textClass()} numberOfLines={1}>
{t( "unknown" )}
</Body1>
</Body1Bold>
);
}
@@ -88,7 +88,7 @@ const DisplayTaxonName = ( {
: "";
const text = piece + spaceChar;
const TextComponent = scientificNameFirst || !commonName
? Body1
? Body1Bold
: Body3;
return (
isItalics
@@ -110,7 +110,7 @@ const DisplayTaxonName = ( {
}
const TopTextComponent = !small
? Body1
? Body1Bold
: Body3;
const BottomTextComponent = !small
? Body3

View File

@@ -1,9 +1,10 @@
// @flow
import { useIsFocused } from "@react-navigation/native";
import { Body3 } from "components/SharedComponents";
import { Body3, Body3Bold } from "components/SharedComponents";
import { View } from "components/styledComponents";
import * as React from "react";
import { Platform } from "react-native";
import { useTheme } from "react-native-paper";
import Svg, { ForeignObject, Path } from "react-native-svg";
import { dropShadow } from "styles/global";
@@ -34,6 +35,10 @@ const PhotoCount = ( { count, size, shadow }: Props ): React.Node => {
photoCount = 99;
}
const TextComponent = Platform.OS === "ios"
? Body3
: Body3Bold;
return (
<View
style={[{ height: size, width: size }, shadow && dropShadow]}
@@ -59,10 +64,16 @@ const PhotoCount = ( { count, size, shadow }: Props ): React.Node => {
clipRule="evenodd"
fillRule="nonzero"
/>
<ForeignObject x="5%" y="26%" key={idx}>
<Body3 className="text-center w-[16px]">
<ForeignObject
x="5%"
y={Platform.OS === "ios"
? "26%"
: "20%"}
key={idx}
>
<TextComponent className="text-center w-[16px]">
{photoCount}
</Body3>
</TextComponent>
</ForeignObject>
</Svg>
</View>

View File

@@ -1,7 +1,8 @@
// @flow
import { INatIcon } from "components/SharedComponents";
import { View } from "components/styledComponents";
import * as React from "react";
import type { Node } from "react";
import React from "react";
import { Platform } from "react-native";
import { TextInput, useTheme } from "react-native-paper";
import { getShadowStyle } from "styles/global";
@@ -20,7 +21,8 @@ type Props = {
handleTextChange: Function,
value: string,
testID?: string,
hasShadow?: boolean
hasShadow?: boolean,
input?: any
}
// Ensure this component is placed outside of scroll views
@@ -30,13 +32,15 @@ const SearchBar = ( {
testID,
handleTextChange,
value,
hasShadow
}: Props ): React.Node => {
hasShadow,
input
}: Props ): Node => {
const theme = useTheme( );
return (
<View className={containerClass}>
<TextInput
ref={input}
accessibilityLabel="Search bar"
keyboardType="default"
mode="outlined"

View File

@@ -0,0 +1,13 @@
// @flow
import type { Node } from "react";
import React from "react";
import INatText from "./INatText";
const Body1Bold = ( props: any ): Node => (
// eslint-disable-next-line react/jsx-props-no-spreading
<INatText className="text-base font-medium text-darkGray" {...props} />
);
export default Body1Bold;

View File

@@ -0,0 +1,13 @@
// @flow
import type { Node } from "react";
import React from "react";
import INatText from "./INatText";
const Body3Bold = ( props: any ): Node => (
// eslint-disable-next-line react/jsx-props-no-spreading
<INatText className="text-sm font-medium text-darkGray" {...props} />
);
export default Body3Bold;

View File

@@ -31,8 +31,10 @@ export { default as StickyToolbar } from "./StickyToolbar";
export { default as Tabs } from "./Tabs/Tabs";
export { default as TaxonResult } from "./TaxonResult";
export { default as Body1 } from "./Typography/Body1";
export { default as Body1Bold } from "./Typography/Body1Bold";
export { default as Body2 } from "./Typography/Body2";
export { default as Body3 } from "./Typography/Body3";
export { default as Body3Bold } from "./Typography/Body3Bold";
export { default as Body4 } from "./Typography/Body4";
export { default as Heading1 } from "./Typography/Heading1";
export { default as Heading2 } from "./Typography/Heading2";

View File

@@ -1192,5 +1192,6 @@ SETTINGS = SETTINGS
LOG-OUT = LOG OUT
Log-in-to-iNaturalist = Log in to iNaturalist
Try-searching-for-a-location-name = Try searching for a location name to see the map
Scan-the-area-around-you-for-organisms = Scan the area around you for organisms.
Loading-iNaturalists-AR-Camera = Loading iNaturalists AR Camera
Loading-iNaturalists-AR-Camera = Loading iNaturalists AR Camera

View File

@@ -822,6 +822,7 @@
"SETTINGS": "SETTINGS",
"LOG-OUT": "LOG OUT",
"Log-in-to-iNaturalist": "Log in to iNaturalist",
"Try-searching-for-a-location-name": "Try searching for a location name to see the map",
"Scan-the-area-around-you-for-organisms": "Scan the area around you for organisms.",
"Loading-iNaturalists-AR-Camera": "Loading iNaturalists AR Camera"
}

View File

@@ -1192,5 +1192,6 @@ SETTINGS = SETTINGS
LOG-OUT = LOG OUT
Log-in-to-iNaturalist = Log in to iNaturalist
Try-searching-for-a-location-name = Try searching for a location name to see the map
Scan-the-area-around-you-for-organisms = Scan the area around you for organisms.
Loading-iNaturalists-AR-Camera = Loading iNaturalists AR Camera
Loading-iNaturalists-AR-Camera = Loading iNaturalists AR Camera

View File

@@ -4,7 +4,7 @@ import AddID from "components/AddID/AddID";
import CameraContainer from "components/Camera/CameraContainer";
import Explore from "components/Explore/Explore";
import Identify from "components/Identify/Identify";
import LocationPicker from "components/LocationPicker/LocationPicker";
import LocationPickerContainer from "components/LocationPicker/LocationPickerContainer";
import ForgotPassword from "components/LoginSignUp/ForgotPassword";
import LicensePhotos from "components/LoginSignUp/LicensePhotos";
import Login from "components/LoginSignUp/Login";
@@ -336,11 +336,8 @@ const BottomTabs = ( ) => {
/>
<Tab.Screen
name="LocationPicker"
component={LocationPicker}
options={{
...blankHeaderTitle,
...hideHeaderLeft
}}
component={LocationPickerContainer}
options={hideHeader}
/>
<Tab.Screen
name="MediaViewer"

View File

@@ -1,49 +1,20 @@
import { faker } from "@faker-js/faker";
import { fireEvent, screen, waitFor } from "@testing-library/react-native";
import { fireEvent, screen } 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 observations = [
factory( "RemoteObservation", {
// Oakland, CA latlng
latitude: 37.804855,
longitude: -122.272504
} )
];
const mockPlaceResult = factory( "RemotePlace", {
display_name: "New York",
@@ -62,6 +33,31 @@ jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
} )
} ) );
const mockSelectPlaceResult = jest.fn( );
const mockRegion = {
latitude: observations[0].latitude,
longitude: observations[0].longitude,
latitudeDelta: 0.2,
longitudeDelta: 0.2
};
const renderLocationPicker = region => renderComponent(
<ObsEditContext.Provider value={{
updateObservationKeys: jest.fn( )
}}
>
<LocationPicker
region={region}
locationName="Oakland, CA"
updateLocationName={location => jest.fn( location )}
hidePlaceResults={false}
selectPlaceResult={mockSelectPlaceResult}
mapType="standard"
loading={false}
/>
</ObsEditContext.Provider>
);
describe( "LocationPicker", () => {
beforeAll( async ( ) => {
await initI18next( );
@@ -70,67 +66,42 @@ describe( "LocationPicker", () => {
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( );
renderLocationPicker( mockRegion );
await screen.findByText( new RegExp( observations[0].latitude ) );
}
);
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( );
renderLocationPicker( mockRegion );
const input = screen.getByTestId( "LocationPicker.locationSearch" );
const initialLatitude = screen.getByText( new RegExp( observations[0].latitude ) );
expect( initialLatitude ).toBeTruthy( );
expect( input ).toBeVisible( );
await screen.findByText( new RegExp( observations[0].latitude ) );
fireEvent.changeText( input, "New" );
await waitFor( ( ) => {
expect( screen.getByText( mockPlaceResult.display_name ) ).toBeTruthy( );
} );
await screen.findByText( mockPlaceResult.display_name );
}
);
it(
"should move map to new coordinates when a user presses place result",
"should update map with new place results when a user taps a place in dropdown",
async ( ) => {
const observations = [
factory( "RemoteObservation", {
// Oakland, CA latlng
latitude: 37.804855,
longitude: -122.272504
} )
];
mockObsEditProviderWithObs( observations );
renderLocationPicker( );
renderLocationPicker( mockRegion );
const input = screen.getByTestId( "LocationPicker.locationSearch" );
const initialLatitude = screen.getByText( new RegExp( observations[0].latitude ) );
expect( initialLatitude ).toBeTruthy( );
await screen.findByText( new RegExp( observations[0].latitude ) );
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( );
const placeResult = screen.getByText( mockPlaceResult.display_name );
expect( placeResult ).toBeVisible( );
fireEvent.press( placeResult );
expect( mockSelectPlaceResult ).toHaveBeenCalledTimes( 1 );
renderLocationPicker( {
...mockRegion,
latitude: mockPlaceResult.point_geojson.coordinates[1],
longitude: mockPlaceResult.point_geojson.coordinates[0]
} );
await screen.findByText(
new RegExp( mockPlaceResult.point_geojson.coordinates[0] )
);
}
);
} );