diff --git a/src/components/AddressSearch/CurrentLocationButton.js b/src/components/AddressSearch/CurrentLocationButton.js new file mode 100644 index 000000000000..893ec031ab7f --- /dev/null +++ b/src/components/AddressSearch/CurrentLocationButton.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {Text} from 'react-native'; +import colors from '../../styles/colors'; +import styles from '../../styles/styles'; +import Icon from '../Icon'; +import * as Expensicons from '../Icon/Expensicons'; +import PressableWithFeedback from '../Pressable/PressableWithFeedback'; +import getButtonState from '../../libs/getButtonState'; +import * as StyleUtils from '../../styles/StyleUtils'; +import useLocalize from '../../hooks/useLocalize'; + +const propTypes = { + /** Callback that runs when location button is clicked */ + onPress: PropTypes.func, + + /** Boolean to indicate if the button is clickable */ + isDisabled: PropTypes.bool, +}; + +const defaultProps = { + isDisabled: false, + onPress: () => {}, +}; + +function CurrentLocationButton({onPress, isDisabled}) { + const {translate} = useLocalize(); + + return ( + e.preventDefault()} + onTouchStart={(e) => e.preventDefault()} + > + + {translate('location.useCurrent')} + + ); +} + +CurrentLocationButton.displayName = 'CurrentLocationButton'; +CurrentLocationButton.propTypes = propTypes; +CurrentLocationButton.defaultProps = defaultProps; + +export default CurrentLocationButton; diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js index fe220d442674..3e676b811c16 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -1,7 +1,7 @@ import _ from 'underscore'; -import React, {useMemo, useRef, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import PropTypes from 'prop-types'; -import {LogBox, ScrollView, View, Text, ActivityIndicator} from 'react-native'; +import {Keyboard, LogBox, ScrollView, View, Text, ActivityIndicator} from 'react-native'; import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete'; import lodashGet from 'lodash/get'; import compose from '../../libs/compose'; @@ -11,12 +11,16 @@ import themeColors from '../../styles/themes/default'; import TextInput from '../TextInput'; import * as ApiUtils from '../../libs/ApiUtils'; import * as GooglePlacesUtils from '../../libs/GooglePlacesUtils'; +import getCurrentPosition from '../../libs/getCurrentPosition'; import CONST from '../../CONST'; import * as StyleUtils from '../../styles/StyleUtils'; -import resetDisplayListViewBorderOnBlur from './resetDisplayListViewBorderOnBlur'; +import isCurrentTargetInsideContainer from './isCurrentTargetInsideContainer'; import variables from '../../styles/variables'; +import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator'; +import LocationErrorMessage from '../LocationErrorMessage'; import {withNetwork} from '../OnyxProvider'; import networkPropTypes from '../networkPropTypes'; +import CurrentLocationButton from './CurrentLocationButton'; // The error that's being thrown below will be ignored until we fork the // react-native-google-places-autocomplete repo and replace the @@ -61,6 +65,9 @@ const propTypes = { /** Should address search be limited to results in the USA */ isLimitedToUSA: PropTypes.bool, + /** Shows a current location button in suggestion list */ + canUseCurrentLocation: PropTypes.bool, + /** A list of predefined places that can be shown when the user isn't searching for something */ predefinedPlaces: PropTypes.arrayOf( PropTypes.shape({ @@ -115,6 +122,7 @@ const defaultProps = { defaultValue: undefined, containerStyles: [], isLimitedToUSA: false, + canUseCurrentLocation: false, renamedInputKeys: { street: 'addressStreet', street2: 'addressStreet2', @@ -135,6 +143,11 @@ const defaultProps = { function AddressSearch(props) { const [displayListViewBorder, setDisplayListViewBorder] = useState(false); const [isTyping, setIsTyping] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const [searchValue, setSearchValue] = useState(props.value || props.defaultValue || ''); + const [locationErrorCode, setLocationErrorCode] = useState(null); + const [isFetchingCurrentLocation, setIsFetchingCurrentLocation] = useState(false); + const shouldTriggerGeolocationCallbacks = useRef(true); const containerRef = useRef(); const query = useMemo( () => ({ @@ -144,6 +157,7 @@ function AddressSearch(props) { }), [props.preferredLocale, props.resultTypes, props.isLimitedToUSA], ); + const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; const saveLocationDetails = (autocompleteData, details) => { const addressComponents = details.address_components; @@ -262,6 +276,72 @@ function AddressSearch(props) { props.onPress(values); }; + /** Gets the user's current location and registers success/error callbacks */ + const getCurrentLocation = () => { + if (isFetchingCurrentLocation) { + return; + } + + setIsTyping(false); + setIsFocused(false); + setDisplayListViewBorder(false); + setIsFetchingCurrentLocation(true); + + Keyboard.dismiss(); + + getCurrentPosition( + (successData) => { + if (!shouldTriggerGeolocationCallbacks.current) { + return; + } + + setIsFetchingCurrentLocation(false); + setLocationErrorCode(null); + + const location = { + lat: successData.coords.latitude, + lng: successData.coords.longitude, + address: CONST.YOUR_LOCATION_TEXT, + }; + props.onPress(location); + }, + (errorData) => { + if (!shouldTriggerGeolocationCallbacks.current) { + return; + } + + setIsFetchingCurrentLocation(false); + setLocationErrorCode(errorData.code); + }, + { + maximumAge: 0, // No cache, always get fresh location info + timeout: 5000, + }, + ); + }; + + const renderHeaderComponent = () => + props.predefinedPlaces.length > 0 && ( + <> + {/* This will show current location button in list if there are some recent destinations */} + {shouldShowCurrentLocationButton && ( + + )} + {!props.value && {props.translate('common.recentDestinations')}} + + ); + + // eslint-disable-next-line arrow-body-style + useEffect(() => { + return () => { + // If the component unmounts we don't want any of the callback for geolocation to run. + shouldTriggerGeolocationCallbacks.current = false; + }; + }, []); + return ( /* * The GooglePlacesAutocomplete component uses a VirtualizedList internally, @@ -269,119 +349,149 @@ function AddressSearch(props) { * To work around this, we wrap the GooglePlacesAutocomplete component with a horizontal ScrollView * that has scrolling disabled and would otherwise not be needed */ - - + - {props.translate('common.noResultsFound')} - ) - } - listLoaderComponent={ - - - - } - renderHeaderComponent={() => - !props.value && - props.predefinedPlaces && ( - {props.translate('common.recentDestinations')} - ) - } - onPress={(data, details) => { - saveLocationDetails(data, details); - setIsTyping(false); - - // After we select an option, we set displayListViewBorder to false to prevent UI flickering - setDisplayListViewBorder(false); - }} - query={query} - requestUrl={{ - useOnPlatform: 'all', - url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), - }} - textInputProps={{ - InputComp: TextInput, - ref: (node) => { - if (!props.innerRef) { - return; - } - - if (_.isFunction(props.innerRef)) { - props.innerRef(node); - return; - } - - // eslint-disable-next-line no-param-reassign - props.innerRef.current = node; - }, - label: props.label, - containerStyles: props.containerStyles, - errorText: props.errorText, - hint: displayListViewBorder ? undefined : props.hint, - value: props.value, - defaultValue: props.defaultValue, - inputID: props.inputID, - shouldSaveDraft: props.shouldSaveDraft, - onBlur: (event) => { - resetDisplayListViewBorderOnBlur(setDisplayListViewBorder, event, containerRef); - props.onBlur(); - }, - autoComplete: 'off', - onInputChange: (text) => { - setIsTyping(true); - if (props.inputID) { - props.onInputChange(text); - } else { - props.onInputChange({street: text}); - } - - // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering - if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) { - setDisplayListViewBorder(false); - } - }, - maxLength: props.maxInputLength, - spellCheck: false, - }} - styles={{ - textInputContainer: [styles.flexColumn], - listView: [StyleUtils.getGoogleListViewStyle(displayListViewBorder), styles.overflowAuto, styles.borderLeft, styles.borderRight], - row: [styles.pv4, styles.ph3, styles.overflowAuto], - description: [styles.googleSearchText], - separator: [styles.googleSearchSeparator], - }} - numberOfLines={2} - isRowScrollable={false} - listHoverColor={themeColors.border} - listUnderlayColor={themeColors.buttonPressedBG} - onLayout={(event) => { - // We use the height of the element to determine if we should hide the border of the listView dropdown - // to prevent a lingering border when there are no address suggestions. - setDisplayListViewBorder(event.nativeEvent.layout.height > variables.googleEmptyListViewHeight); - }} - /> - - + + {props.translate('common.noResultsFound')} + ) + } + listLoaderComponent={ + + + + } + renderHeaderComponent={renderHeaderComponent} + onPress={(data, details) => { + saveLocationDetails(data, details); + setIsTyping(false); + + // After we select an option, we set displayListViewBorder to false to prevent UI flickering + setDisplayListViewBorder(false); + setIsFocused(false); + + // Clear location error code after address is selected + setLocationErrorCode(null); + }} + query={query} + requestUrl={{ + useOnPlatform: 'all', + url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), + }} + textInputProps={{ + InputComp: TextInput, + ref: (node) => { + if (!props.innerRef) { + return; + } + + if (_.isFunction(props.innerRef)) { + props.innerRef(node); + return; + } + + // eslint-disable-next-line no-param-reassign + props.innerRef.current = node; + }, + label: props.label, + containerStyles: props.containerStyles, + errorText: props.errorText, + hint: + displayListViewBorder || (props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (props.canUseCurrentLocation && isTyping) + ? undefined + : props.hint, + value: props.value, + defaultValue: props.defaultValue, + inputID: props.inputID, + shouldSaveDraft: props.shouldSaveDraft, + onFocus: () => { + setIsFocused(true); + }, + onBlur: (event) => { + if (!isCurrentTargetInsideContainer(event, containerRef)) { + setDisplayListViewBorder(false); + setIsFocused(false); + setIsTyping(false); + } + props.onBlur(); + }, + autoComplete: 'off', + onInputChange: (text) => { + setSearchValue(text); + setIsTyping(true); + if (props.inputID) { + props.onInputChange(text); + } else { + props.onInputChange({street: text}); + } + + // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering + if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) { + setDisplayListViewBorder(false); + } + }, + maxLength: props.maxInputLength, + spellCheck: false, + }} + styles={{ + textInputContainer: [styles.flexColumn], + listView: [StyleUtils.getGoogleListViewStyle(displayListViewBorder), styles.overflowAuto, styles.borderLeft, styles.borderRight, !isFocused && {height: 0}], + row: [styles.pv4, styles.ph3, styles.overflowAuto], + description: [styles.googleSearchText], + separator: [styles.googleSearchSeparator], + }} + numberOfLines={2} + isRowScrollable={false} + listHoverColor={themeColors.border} + listUnderlayColor={themeColors.buttonPressedBG} + onLayout={(event) => { + // We use the height of the element to determine if we should hide the border of the listView dropdown + // to prevent a lingering border when there are no address suggestions. + setDisplayListViewBorder(event.nativeEvent.layout.height > variables.googleEmptyListViewHeight); + }} + inbetweenCompo={ + // We want to show the current location button even if there are no recent destinations + props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( + + + + ) : ( + <> + ) + } + /> + setLocationErrorCode(null)} + locationErrorCode={locationErrorCode} + /> + + + {isFetchingCurrentLocation && } + ); } diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.js b/src/components/AddressSearch/isCurrentTargetInsideContainer.js new file mode 100644 index 000000000000..18bfc10a8dcb --- /dev/null +++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.js @@ -0,0 +1,8 @@ +function isCurrentTargetInsideContainer(event, containerRef) { + // The related target check is required here + // because without it when we select an option, the onBlur will still trigger setting displayListViewBorder to false + // it will make the auto complete component re-render before onPress is called making selecting an option not working. + return containerRef.current && event.target && containerRef.current.contains(event.relatedTarget); +} + +export default isCurrentTargetInsideContainer; diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js new file mode 100644 index 000000000000..dbf0004b08d9 --- /dev/null +++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js @@ -0,0 +1,6 @@ +function isCurrentTargetInsideContainer() { + // The related target check is not required here because in native there is no race condition rendering like on the web + return false; +} + +export default isCurrentTargetInsideContainer; diff --git a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.js b/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.js deleted file mode 100644 index def4da13a9a2..000000000000 --- a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.js +++ /dev/null @@ -1,11 +0,0 @@ -function resetDisplayListViewBorderOnBlur(setDisplayListViewBorder, event, containerRef) { - // The related target check is required here - // because without it when we select an option, the onBlur will still trigger setting displayListViewBorder to false - // it will make the auto complete component re-render before onPress is called making selecting an option not working. - if (containerRef.current && event.target && containerRef.current.contains(event.relatedTarget)) { - return; - } - setDisplayListViewBorder(false); -} - -export default resetDisplayListViewBorderOnBlur; diff --git a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.native.js b/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.native.js deleted file mode 100644 index 7ae5a44cae71..000000000000 --- a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.native.js +++ /dev/null @@ -1,7 +0,0 @@ -function resetDisplayListViewBorderOnBlur(setDisplayListViewBorder) { - // The related target check is not required here because in native there is no race condition rendering like on the web - // onPress still called when cliking the option - setDisplayListViewBorder(false); -} - -export default resetDisplayListViewBorderOnBlur; diff --git a/src/components/UserCurrentLocationButton.js b/src/components/UserCurrentLocationButton.js deleted file mode 100644 index fa22eb602886..000000000000 --- a/src/components/UserCurrentLocationButton.js +++ /dev/null @@ -1,114 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useEffect, useRef, useState} from 'react'; -import {Text} from 'react-native'; -import getCurrentPosition from '../libs/getCurrentPosition'; -import styles from '../styles/styles'; -import Icon from './Icon'; -import * as Expensicons from './Icon/Expensicons'; -import LocationErrorMessage from './LocationErrorMessage'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import colors from '../styles/colors'; -import PressableWithFeedback from './Pressable/PressableWithFeedback'; - -const propTypes = { - /** Callback that runs when location data is fetched */ - onLocationFetched: PropTypes.func.isRequired, - - /** Callback that runs when fetching location has errors */ - onLocationError: PropTypes.func, - - /** Callback that runs when location button is clicked */ - onClick: PropTypes.func, - - /** Boolean to indicate if the button is clickable */ - isDisabled: PropTypes.bool, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - isDisabled: false, - onLocationError: () => {}, - onClick: () => {}, -}; - -function UserCurrentLocationButton({onLocationFetched, onLocationError, onClick, isDisabled, translate}) { - const isFetchingLocation = useRef(false); - const shouldTriggerCallbacks = useRef(true); - const [locationErrorCode, setLocationErrorCode] = useState(null); - - /** Gets the user's current location and registers success/error callbacks */ - const getUserLocation = () => { - if (isFetchingLocation.current) { - return; - } - - isFetchingLocation.current = true; - - onClick(); - - getCurrentPosition( - (successData) => { - isFetchingLocation.current = false; - if (!shouldTriggerCallbacks.current) { - return; - } - - setLocationErrorCode(null); - onLocationFetched(successData); - }, - (errorData) => { - isFetchingLocation.current = false; - if (!shouldTriggerCallbacks.current) { - return; - } - - setLocationErrorCode(errorData.code); - onLocationError(errorData); - }, - { - maximumAge: 0, // No cache, always get fresh location info - timeout: 5000, - }, - ); - }; - - // eslint-disable-next-line arrow-body-style - useEffect(() => { - return () => { - // If the component unmounts we don't want any of the callback for geolocation to run. - shouldTriggerCallbacks.current = false; - }; - }, []); - - return ( - <> - e.preventDefault()} - onTouchStart={(e) => e.preventDefault()} - > - - {translate('location.useCurrent')} - - setLocationErrorCode(null)} - locationErrorCode={locationErrorCode} - /> - - ); -} - -UserCurrentLocationButton.displayName = 'UserCurrentLocationButton'; -UserCurrentLocationButton.propTypes = propTypes; -UserCurrentLocationButton.defaultProps = defaultProps; - -// This components gets used inside
, we are using an HOC (withLocalize) as function components with -// hooks give hook errors when nested inside . -export default withLocalize(UserCurrentLocationButton); diff --git a/src/pages/iou/WaypointEditor.js b/src/pages/iou/WaypointEditor.js index 13bf1883804c..a123976b326e 100644 --- a/src/pages/iou/WaypointEditor.js +++ b/src/pages/iou/WaypointEditor.js @@ -1,7 +1,7 @@ import React, {useMemo, useRef, useState} from 'react'; import _ from 'underscore'; import lodashGet from 'lodash/get'; -import {Keyboard, View} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/native'; @@ -23,8 +23,6 @@ import * as Transaction from '../../libs/actions/Transaction'; import * as ValidationUtils from '../../libs/ValidationUtils'; import ROUTES from '../../ROUTES'; import transactionPropTypes from '../../components/transactionPropTypes'; -import UserCurrentLocationButton from '../../components/UserCurrentLocationButton'; -import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator'; import * as ErrorUtils from '../../libs/ErrorUtils'; const propTypes = { @@ -78,7 +76,6 @@ const defaultProps = { function WaypointEditor({route: {params: {iouType = '', transactionID = '', waypointIndex = '', threadReportID = 0}} = {}, transaction, recentWaypoints}) { const {windowWidth} = useWindowDimensions(); const [isDeleteStopModalOpen, setIsDeleteStopModalOpen] = useState(false); - const [isFetchingLocation, setIsFetchingLocation] = useState(false); const navigation = useNavigation(); const isFocused = navigation.isFocused(); const {translate} = useLocalize(); @@ -176,26 +173,6 @@ function WaypointEditor({route: {params: {iouType = '', transactionID = '', wayp Navigation.goBack(ROUTES.MONEY_REQUEST_DISTANCE_TAB.getRoute(iouType)); }; - /** - * Sets user current location as a waypoint - * @param {Object} geolocationData - * @param {Object} geolocationData.coords - * @param {Number} geolocationData.coords.latitude - * @param {Number} geolocationData.coords.longitude - * @param {Number} geolocationData.timestamp - */ - const selectWaypointFromCurrentLocation = (geolocationData) => { - setIsFetchingLocation(false); - - const waypoint = { - lat: geolocationData.coords.latitude, - lng: geolocationData.coords.longitude, - address: CONST.YOUR_LOCATION_TEXT, - }; - - selectWaypoint(waypoint); - }; - return ( (textInput.current = e)} hint={!isOffline ? 'distance.errors.selectSuggestedAddress' : ''} @@ -265,17 +243,6 @@ function WaypointEditor({route: {params: {iouType = '', transactionID = '', wayp resultTypes="" /> - { - Keyboard.dismiss(); - - setIsFetchingLocation(true); - }} - onLocationError={() => setIsFetchingLocation(false)} - onLocationFetched={selectWaypointFromCurrentLocation} - /> - {isFetchingLocation && }