From 1592cb463f7cb0977a8118c60d448e57443f6b6b Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Thu, 10 Dec 2020 11:25:00 -0300 Subject: [PATCH] Feature: Initial Swaps amount view (#1994) Co-authored-by: Esteban Mino --- app/components/Base/Keypad/rules/native.js | 52 +++ app/components/Base/Keypad/rules/usd.js | 64 ++++ app/components/Base/ListItem.js | 10 +- app/components/Base/ModalDragger.js | 32 ++ app/components/Base/ModalHandler.js | 8 +- app/components/Base/hooks/useModalHandler.js | 11 + .../FiatOrders/PaymentMethodApplePay/index.js | 67 +--- .../__snapshots__/index.test.js.snap | 25 +- app/components/UI/ReceiveRequest/index.js | 20 +- .../UI/Swaps/components/TokenIcon.js | 73 ++++ .../UI/Swaps/components/TokenIcon.test.js | 35 ++ .../UI/Swaps/components/TokenSelectButton.js | 53 +++ .../components/TokenSelectButton.test.js | 32 ++ .../UI/Swaps/components/TokenSelectModal.js | 242 ++++++++++++ .../__snapshots__/TokenIcon.test.js.snap | 167 +++++++++ .../TokenSelectButton.test.js.snap | 298 +++++++++++++++ app/components/UI/Swaps/index.js | 349 +++++++++++++++++- app/core/Engine.js | 11 +- app/reducers/engine/index.js | 4 + locales/en.json | 12 + locales/es.json | 12 + package.json | 2 + yarn.lock | 39 ++ 23 files changed, 1485 insertions(+), 133 deletions(-) create mode 100644 app/components/Base/Keypad/rules/native.js create mode 100644 app/components/Base/Keypad/rules/usd.js create mode 100644 app/components/Base/ModalDragger.js create mode 100644 app/components/Base/hooks/useModalHandler.js create mode 100644 app/components/UI/Swaps/components/TokenIcon.js create mode 100644 app/components/UI/Swaps/components/TokenIcon.test.js create mode 100644 app/components/UI/Swaps/components/TokenSelectButton.js create mode 100644 app/components/UI/Swaps/components/TokenSelectButton.test.js create mode 100644 app/components/UI/Swaps/components/TokenSelectModal.js create mode 100644 app/components/UI/Swaps/components/__snapshots__/TokenIcon.test.js.snap create mode 100644 app/components/UI/Swaps/components/__snapshots__/TokenSelectButton.test.js.snap diff --git a/app/components/Base/Keypad/rules/native.js b/app/components/Base/Keypad/rules/native.js new file mode 100644 index 00000000000..196457df146 --- /dev/null +++ b/app/components/Base/Keypad/rules/native.js @@ -0,0 +1,52 @@ +const hasOneDigit = /^\d$/; + +function handleInput(currentAmount, inputKey) { + switch (inputKey) { + case 'PERIOD': { + if (currentAmount === '0') { + return `${currentAmount}.`; + } + if (currentAmount.includes('.')) { + return currentAmount; + } + + return `${currentAmount}.`; + } + case 'BACK': { + if (currentAmount === '0') { + return currentAmount; + } + if (hasOneDigit.test(currentAmount)) { + return '0'; + } + + return currentAmount.slice(0, -1); + } + case '0': { + if (currentAmount === '0') { + return currentAmount; + } + return `${currentAmount}${inputKey}`; + } + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': { + if (currentAmount === '0') { + return inputKey; + } + + return `${currentAmount}${inputKey}`; + } + default: { + return currentAmount; + } + } +} + +export default handleInput; diff --git a/app/components/Base/Keypad/rules/usd.js b/app/components/Base/Keypad/rules/usd.js new file mode 100644 index 00000000000..0207522f308 --- /dev/null +++ b/app/components/Base/Keypad/rules/usd.js @@ -0,0 +1,64 @@ +const hasOneDigit = /^\d$/; +const hasTwoDecimals = /^\d+\.\d{2}$/; +const avoidZerosAsDecimals = false; +const hasZeroAsFirstDecimal = /^\d+\.0$/; + +function handleUSDInput(currentAmount, inputKey) { + switch (inputKey) { + case 'PERIOD': { + if (currentAmount === '0') { + return `${currentAmount}.`; + } + if (currentAmount.includes('.')) { + return currentAmount; + } + + return `${currentAmount}.`; + } + case 'BACK': { + if (currentAmount === '0') { + return currentAmount; + } + if (hasOneDigit.test(currentAmount)) { + return '0'; + } + + return currentAmount.slice(0, -1); + } + case '0': { + if (currentAmount === '0') { + return currentAmount; + } + if (hasTwoDecimals.test(currentAmount)) { + return currentAmount; + } + if (avoidZerosAsDecimals && hasZeroAsFirstDecimal.test(currentAmount)) { + return currentAmount; + } + return `${currentAmount}${inputKey}`; + } + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': { + if (currentAmount === '0') { + return inputKey; + } + if (hasTwoDecimals.test(currentAmount)) { + return currentAmount; + } + + return `${currentAmount}${inputKey}`; + } + default: { + return currentAmount; + } + } +} + +export default handleUSDInput; diff --git a/app/components/Base/ListItem.js b/app/components/Base/ListItem.js index a2294a45722..b05f342087e 100644 --- a/app/components/Base/ListItem.js +++ b/app/components/Base/ListItem.js @@ -1,14 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import { StyleSheet, View } from 'react-native'; -import Device from '../../util/Device'; +// import Device from '../../util/Device'; import { colors, fontStyles } from '../../styles/common'; import Text from './Text'; const styles = StyleSheet.create({ wrapper: { - padding: 15, - minHeight: Device.isIos() ? 95 : 100 + padding: 15 + // TODO(wachunei): check if this can be removed without breaking anything + // minHeight: Device.isIos() ? 55 : 100 }, date: { color: colors.fontSecondary, @@ -17,7 +18,8 @@ const styles = StyleSheet.create({ ...fontStyles.normal }, content: { - flexDirection: 'row' + flexDirection: 'row', + alignItems: 'center' }, actions: { flexDirection: 'row', diff --git a/app/components/Base/ModalDragger.js b/app/components/Base/ModalDragger.js new file mode 100644 index 00000000000..23767102f33 --- /dev/null +++ b/app/components/Base/ModalDragger.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { colors } from '../../styles/common'; +import Device from '../../util/Device'; + +const styles = StyleSheet.create({ + draggerWrapper: { + width: '100%', + height: 33, + alignItems: 'center', + justifyContent: 'center', + borderBottomWidth: StyleSheet.hairlineWidth, + borderColor: colors.grey100 + }, + dragger: { + width: 48, + height: 5, + borderRadius: 4, + backgroundColor: colors.grey400, + opacity: Device.isAndroid() ? 0.6 : 0.5 + } +}); + +function ModalDragger() { + return ( + + + + ); +} + +export default ModalDragger; diff --git a/app/components/Base/ModalHandler.js b/app/components/Base/ModalHandler.js index 417697a090c..1bc538a2450 100644 --- a/app/components/Base/ModalHandler.js +++ b/app/components/Base/ModalHandler.js @@ -1,11 +1,7 @@ -import { useState } from 'react'; +import useModalHandler from './hooks/useModalHandler'; function ModalHandler({ children }) { - const [isVisible, setVisible] = useState(false); - - const showModal = () => setVisible(true); - const hideModal = () => setVisible(true); - const toggleModal = () => setVisible(!isVisible); + const [isVisible, toggleModal, showModal, hideModal] = useModalHandler(false); if (typeof children === 'function') { return children({ isVisible, toggleModal, showModal, hideModal }); diff --git a/app/components/Base/hooks/useModalHandler.js b/app/components/Base/hooks/useModalHandler.js new file mode 100644 index 00000000000..c5371a91e46 --- /dev/null +++ b/app/components/Base/hooks/useModalHandler.js @@ -0,0 +1,11 @@ +import { useState } from 'react'; +function useModalHandler(initialState = false) { + const [isVisible, setVisible] = useState(initialState); + + const showModal = () => setVisible(true); + const hideModal = () => setVisible(true); + const toggleModal = () => setVisible(!isVisible); + + return [isVisible, toggleModal, showModal, hideModal]; +} +export default useModalHandler; diff --git a/app/components/UI/FiatOrders/PaymentMethodApplePay/index.js b/app/components/UI/FiatOrders/PaymentMethodApplePay/index.js index 000d145f241..a6de5665c11 100644 --- a/app/components/UI/FiatOrders/PaymentMethodApplePay/index.js +++ b/app/components/UI/FiatOrders/PaymentMethodApplePay/index.js @@ -9,6 +9,7 @@ import Logger from '../../../../util/Logger'; import { setLockTime } from '../../../../actions/settings'; import { strings } from '../../../../../locales/i18n'; import { getNotificationDetails } from '..'; +import handleUSDInput from '../../../Base/Keypad/rules/usd'; import { useWyreTerms, @@ -129,73 +130,9 @@ const quickAmounts = ['50', '100', '250']; const minAmount = 50; const maxAmount = 250; -const hasTwoDecimals = /^\d+\.\d{2}$/; const hasZeroAsFirstDecimal = /^\d+\.0$/; const hasZerosAsDecimals = /^\d+\.00$/; -const hasOneDigit = /^\d$/; const hasPeriodWithoutDecimal = /^\d+\.$/; -const avoidZerosAsDecimals = false; - -//* Handlers - -const handleNewAmountInput = (currentAmount, newInput) => { - switch (newInput) { - case 'PERIOD': { - if (currentAmount === '0') { - return `${currentAmount}.`; - } - if (currentAmount.includes('.')) { - // TODO: throw error for feedback? - return currentAmount; - } - - return `${currentAmount}.`; - } - case 'BACK': { - if (currentAmount === '0') { - return currentAmount; - } - if (hasOneDigit.test(currentAmount)) { - return '0'; - } - - return currentAmount.slice(0, -1); - } - case '0': { - if (currentAmount === '0') { - return currentAmount; - } - if (hasTwoDecimals.test(currentAmount)) { - return currentAmount; - } - if (avoidZerosAsDecimals && hasZeroAsFirstDecimal.test(currentAmount)) { - return currentAmount; - } - return `${currentAmount}${newInput}`; - } - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': { - if (currentAmount === '0') { - return newInput; - } - if (hasTwoDecimals.test(currentAmount)) { - return currentAmount; - } - - return `${currentAmount}${newInput}`; - } - default: { - return currentAmount; - } - } -}; function PaymentMethodApplePay({ lockTime, @@ -258,7 +195,7 @@ function PaymentMethodApplePay({ if (isOverMaximum && newInput !== 'BACK') { return; } - const newAmount = handleNewAmountInput(amount, newInput); + const newAmount = handleUSDInput(amount, newInput); if (newAmount === amount) { return; } diff --git a/app/components/UI/ReceiveRequest/__snapshots__/index.test.js.snap b/app/components/UI/ReceiveRequest/__snapshots__/index.test.js.snap index 4e1b39caa8e..d72e93c8774 100644 --- a/app/components/UI/ReceiveRequest/__snapshots__/index.test.js.snap +++ b/app/components/UI/ReceiveRequest/__snapshots__/index.test.js.snap @@ -10,30 +10,7 @@ exports[`ReceiveRequest should render correctly 1`] = ` } } > - - - + - - - + {strings('receive_request.title')} diff --git a/app/components/UI/Swaps/components/TokenIcon.js b/app/components/UI/Swaps/components/TokenIcon.js new file mode 100644 index 00000000000..238af4cec8d --- /dev/null +++ b/app/components/UI/Swaps/components/TokenIcon.js @@ -0,0 +1,73 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { StyleSheet, View } from 'react-native'; + +import RemoteImage from '../../../Base/RemoteImage'; +import Text from '../../../Base/Text'; +import { colors } from '../../../../styles/common'; + +// eslint-disable-next-line import/no-commonjs +const ethLogo = require('../../../../images/eth-logo.png'); + +const REGULAR_SIZE = 24; +const REGULAR_RADIUS = 12; +const MEDIUM_SIZE = 36; +const MEDIUM_RADIUS = 18; + +const styles = StyleSheet.create({ + icon: { + width: REGULAR_SIZE, + height: REGULAR_SIZE, + borderRadius: REGULAR_RADIUS + }, + iconMedium: { + width: MEDIUM_SIZE, + height: MEDIUM_SIZE, + borderRadius: MEDIUM_RADIUS + }, + emptyIcon: { + backgroundColor: colors.grey200, + alignItems: 'center', + justifyContent: 'center' + }, + tokenSymbol: { + fontSize: 16, + textAlign: 'center', + textAlignVertical: 'center' + }, + tokenSymbolMedium: { + fontSize: 22 + } +}); + +const EmptyIcon = ({ medium, ...props }) => ( + +); + +EmptyIcon.propTypes = { + medium: PropTypes.bool +}; + +function TokenIcon({ symbol, icon, medium }) { + if (symbol === 'ETH') { + return ; + } else if (icon) { + return ; + } else if (symbol) { + return ( + + {symbol[0].toUpperCase()} + + ); + } + + return ; +} + +TokenIcon.propTypes = { + symbol: PropTypes.string, + icon: PropTypes.string, + medium: PropTypes.bool +}; + +export default TokenIcon; diff --git a/app/components/UI/Swaps/components/TokenIcon.test.js b/app/components/UI/Swaps/components/TokenIcon.test.js new file mode 100644 index 00000000000..4937a760829 --- /dev/null +++ b/app/components/UI/Swaps/components/TokenIcon.test.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import TokenIcon from './TokenIcon'; + +describe('TokenIcon component', () => { + it('should Render correctly', () => { + const empty = shallow(); + expect(empty).toMatchSnapshot(); + const eth = shallow(); + expect(eth).toMatchSnapshot(); + const symbol = shallow(); + expect(symbol).toMatchSnapshot(); + const icon = shallow( + + ); + expect(icon).toMatchSnapshot(); + const emptyMedium = shallow(); + expect(emptyMedium).toMatchSnapshot(); + const ethMedium = shallow(); + expect(ethMedium).toMatchSnapshot(); + const symbolMedium = shallow(); + expect(symbolMedium).toMatchSnapshot(); + const iconMedium = shallow( + + ); + expect(iconMedium).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/Swaps/components/TokenSelectButton.js b/app/components/UI/Swaps/components/TokenSelectButton.js new file mode 100644 index 00000000000..34c1c73cd67 --- /dev/null +++ b/app/components/UI/Swaps/components/TokenSelectButton.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View, StyleSheet, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; +import { colors } from '../../../../styles/common'; + +import Text from '../../../Base/Text'; +import TokenIcon from './TokenIcon'; + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.grey000, + paddingVertical: 8, + paddingHorizontal: 10, + borderRadius: 100, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center' + }, + icon: { + marginRight: 8 + }, + caretDown: { + textAlign: 'right', + color: colors.grey500, + marginLeft: 10, + marginRight: 5 + } +}); + +function TokenSelectButton({ icon, symbol, onPress, disabled, label }) { + return ( + + + + + + {symbol || label} + + + + ); +} + +TokenSelectButton.propTypes = { + icon: PropTypes.string, + symbol: PropTypes.string, + label: PropTypes.string, + onPress: PropTypes.func, + disabled: PropTypes.bool +}; + +export default TokenSelectButton; diff --git a/app/components/UI/Swaps/components/TokenSelectButton.test.js b/app/components/UI/Swaps/components/TokenSelectButton.test.js new file mode 100644 index 00000000000..f232e57b86f --- /dev/null +++ b/app/components/UI/Swaps/components/TokenSelectButton.test.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import TokenSelectButton from './TokenSelectButton'; + +describe('TokenSelectButton component', () => { + it('should Render correctly', () => { + const dummyHandler = jest.fn(); + const empty = shallow(); + expect(empty).toMatchSnapshot(); + const eth = shallow(); + expect(eth).toMatchSnapshot(); + const symbol = shallow(); + expect(symbol).toMatchSnapshot(); + const icon = shallow( + + ); + expect(icon).toMatchSnapshot(); + const onPress = shallow( + + ); + expect(onPress).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/Swaps/components/TokenSelectModal.js b/app/components/UI/Swaps/components/TokenSelectModal.js new file mode 100644 index 00000000000..ef7b68cb39f --- /dev/null +++ b/app/components/UI/Swaps/components/TokenSelectModal.js @@ -0,0 +1,242 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { StyleSheet, TextInput, SafeAreaView, TouchableOpacity, View } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; +import Modal from 'react-native-modal'; +import Icon from 'react-native-vector-icons/Ionicons'; +import Fuse from 'fuse.js'; +import useKeyboard from '@rnhooks/keyboard'; +import { toChecksumAddress } from 'ethereumjs-util'; +import { connect } from 'react-redux'; + +import Device from '../../../../util/Device'; +import { balanceToFiat, hexToBN, renderFromTokenMinimalUnit, renderFromWei, weiToFiat } from '../../../../util/number'; +import { strings } from '../../../../../locales/i18n'; +import { colors, fontStyles } from '../../../../styles/common'; + +import Text from '../../../Base/Text'; +import ListItem from '../../../Base/ListItem'; +import ModalDragger from '../../../Base/ModalDragger'; +import TokenIcon from './TokenIcon'; + +const isAndroid = Device.isAndroid(); + +const styles = StyleSheet.create({ + modal: { + margin: 0, + justifyContent: 'flex-end' + }, + modalView: { + backgroundColor: colors.white, + borderTopLeftRadius: 10, + borderTopRightRadius: 10 + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: 30, + marginVertical: 10, + paddingVertical: Device.isAndroid() ? 0 : 10, + paddingHorizontal: 5, + borderRadius: 5, + borderWidth: 1, + borderColor: colors.grey100 + }, + searchIcon: { + marginHorizontal: 8 + }, + input: { + ...fontStyles.normal, + flex: 1 + }, + modalTitle: { + marginTop: Device.isIphone5() ? 10 : 15, + marginBottom: Device.isIphone5() ? 5 : 5 + }, + resultsView: { + height: Device.isSmallDevice() ? 200 : Device.isMediumDevice() ? 200 : 280, + marginTop: 10 + }, + resultsViewKeyboardOpen: { + height: Device.isSmallDevice() ? 120 : Device.isMediumDevice() ? 200 : 280 + }, + resultRow: { + borderTopWidth: StyleSheet.hairlineWidth, + borderColor: colors.grey100 + }, + emptyList: { + marginVertical: 10, + marginHorizontal: 30 + } +}); + +function TokenSelectModal({ + isVisible, + dismiss, + title, + tokens, + onItemPress, + exclude = [], + accounts, + selectedAddress, + currentCurrency, + conversionRate, + tokenExchangeRates, + balances +}) { + const searchInput = useRef(null); + const [isKeyboardVisible] = useKeyboard({ useWillShow: !isAndroid, useWillHide: !isAndroid }); + const [searchString, setSearchString] = useState(''); + + const filteredTokens = useMemo(() => tokens?.filter(token => !exclude.includes(token.symbol)), [tokens, exclude]); + const tokenFuse = useMemo( + () => + new Fuse(filteredTokens, { + shouldSort: true, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: ['symbol', 'address'] + }), + [filteredTokens] + ); + const tokenSearchResults = useMemo( + () => (searchString.length > 0 ? tokenFuse.search(searchString) : filteredTokens)?.slice(0, 5), + [searchString, tokenFuse, filteredTokens] + ); + + const renderItem = useCallback( + ({ item }) => { + const itemAddress = toChecksumAddress(item.address); + + let balance, balanceFiat; + if (item.symbol === 'ETH') { + balance = renderFromWei(accounts[selectedAddress] && accounts[selectedAddress].balance); + balanceFiat = weiToFiat(hexToBN(accounts[selectedAddress].balance), conversionRate, currentCurrency); + } else { + const exchangeRate = itemAddress in tokenExchangeRates ? tokenExchangeRates[itemAddress] : undefined; + balance = + itemAddress in balances ? renderFromTokenMinimalUnit(balances[itemAddress], item.decimals) : 0; + balanceFiat = balanceToFiat(balance, conversionRate, exchangeRate, currentCurrency); + } + + return ( + onItemPress(item)}> + + + + + + + {item.symbol} + + + {balance} + {balanceFiat && {balanceFiat}} + + + + + ); + }, + [balances, accounts, selectedAddress, conversionRate, currentCurrency, tokenExchangeRates, onItemPress] + ); + + const handleSearchPress = () => searchInput?.current?.focus(); + + const renderEmptyList = useMemo( + () => ( + + {strings('swaps.no_tokens_result', { searchString })} + + ), + [searchString] + ); + + return ( + setSearchString('')} + style={styles.modal} + > + + + + {title} + + + + + + item.address} + ListEmptyComponent={renderEmptyList} + /> + + + ); +} + +TokenSelectModal.propTypes = { + isVisible: PropTypes.bool, + dismiss: PropTypes.func, + title: PropTypes.string, + tokens: PropTypes.arrayOf(PropTypes.object), + onItemPress: PropTypes.func, + exclude: PropTypes.arrayOf(PropTypes.string), + /** + * ETH to current currency conversion rate + */ + conversionRate: PropTypes.number, + /** + * Map of accounts to information objects including balances + */ + accounts: PropTypes.object, + /** + * Currency code of the currently-active currency + */ + currentCurrency: PropTypes.string, + /** + * A string that represents the selected address + */ + selectedAddress: PropTypes.string, + /** + * An object containing token balances for current account and network in the format address => balance + */ + balances: PropTypes.object, + /** + * An object containing token exchange rates in the format address => exchangeRate + */ + tokenExchangeRates: PropTypes.object +}; + +const mapStateToProps = state => ({ + accounts: state.engine.backgroundState.AccountTrackerController.accounts, + conversionRate: state.engine.backgroundState.CurrencyRateController.conversionRate, + currentCurrency: state.engine.backgroundState.CurrencyRateController.currentCurrency, + selectedAddress: state.engine.backgroundState.PreferencesController.selectedAddress, + balances: state.engine.backgroundState.TokenBalancesController.contractBalances, + tokenExchangeRates: state.engine.backgroundState.TokenRatesController.contractExchangeRates +}); + +export default connect(mapStateToProps)(TokenSelectModal); diff --git a/app/components/UI/Swaps/components/__snapshots__/TokenIcon.test.js.snap b/app/components/UI/Swaps/components/__snapshots__/TokenIcon.test.js.snap new file mode 100644 index 00000000000..db91193ead9 --- /dev/null +++ b/app/components/UI/Swaps/components/__snapshots__/TokenIcon.test.js.snap @@ -0,0 +1,167 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TokenIcon component should Render correctly 1`] = ``; + +exports[`TokenIcon component should Render correctly 2`] = ` + +`; + +exports[`TokenIcon component should Render correctly 3`] = ` + + + C + + +`; + +exports[`TokenIcon component should Render correctly 4`] = ` + +`; + +exports[`TokenIcon component should Render correctly 5`] = ` + +`; + +exports[`TokenIcon component should Render correctly 6`] = ` + +`; + +exports[`TokenIcon component should Render correctly 7`] = ` + + + C + + +`; + +exports[`TokenIcon component should Render correctly 8`] = ` + +`; diff --git a/app/components/UI/Swaps/components/__snapshots__/TokenSelectButton.test.js.snap b/app/components/UI/Swaps/components/__snapshots__/TokenSelectButton.test.js.snap new file mode 100644 index 00000000000..8499038fd83 --- /dev/null +++ b/app/components/UI/Swaps/components/__snapshots__/TokenSelectButton.test.js.snap @@ -0,0 +1,298 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TokenSelectButton component should Render correctly 1`] = ` + + + + + + + Select a token + + + + +`; + +exports[`TokenSelectButton component should Render correctly 2`] = ` + + + + + + + ETH + + + + +`; + +exports[`TokenSelectButton component should Render correctly 3`] = ` + + + + + + + cDAI + + + + +`; + +exports[`TokenSelectButton component should Render correctly 4`] = ` + + + + + + + DAI + + + + +`; + +exports[`TokenSelectButton component should Render correctly 5`] = ` + + + + + + + DAI + + + + +`; diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js index 158981449de..700172d4de0 100644 --- a/app/components/UI/Swaps/index.js +++ b/app/components/UI/Swaps/index.js @@ -1,24 +1,347 @@ -import React from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { ActivityIndicator, StyleSheet, View, TouchableOpacity } from 'react-native'; import { connect } from 'react-redux'; -import Title from '../../Base/Title'; +import { NavigationContext } from 'react-navigation'; +import IonicIcon from 'react-native-vector-icons/Ionicons'; +import BigNumber from 'bignumber.js'; +import { toChecksumAddress } from 'ethereumjs-util'; +import Engine from '../../../core/Engine'; +import handleInput from '../../Base/Keypad/rules/native'; +import useModalHandler from '../../Base/hooks/useModalHandler'; +import Device from '../../../util/Device'; +import { renderFromTokenMinimalUnit, renderFromWei } from '../../../util/number'; +import { strings } from '../../../../locales/i18n'; +import { colors } from '../../../styles/common'; -import Heading from '../FiatOrders/components/Heading'; -import ScreenView from '../FiatOrders/components/ScreenView'; import { getSwapsAmountNavbar } from '../Navbar'; +import Text from '../../Base/Text'; +import Keypad from '../../Base/Keypad'; +import StyledButton from '../StyledButton'; +import ScreenView from '../FiatOrders/components/ScreenView'; +import TokenSelectButton from './components/TokenSelectButton'; +import TokenSelectModal from './components/TokenSelectModal'; + +const styles = StyleSheet.create({ + screen: { + flexGrow: 1, + justifyContent: 'space-between' + }, + content: { + flexGrow: 1, + justifyContent: 'center' + }, + keypad: { + flexGrow: 1, + justifyContent: 'space-around' + }, + tokenButtonContainer: { + flexDirection: 'row', + justifyContent: 'center', + margin: Device.isIphone5() ? 5 : 10 + }, + amountContainer: { + alignItems: 'center', + justifyContent: 'center', + marginHorizontal: 25 + }, + amount: { + textAlignVertical: 'center', + fontSize: Device.isIphone5() ? 30 : 40, + height: Device.isIphone5() ? 40 : 50 + }, + amountInvalid: { + color: colors.red + }, + horizontalRuleContainer: { + flexDirection: 'row', + paddingHorizontal: 30, + marginVertical: Device.isIphone5() ? 5 : 10, + alignItems: 'center' + }, + horizontalRule: { + flex: 1, + borderBottomWidth: StyleSheet.hairlineWidth, + height: 1, + borderBottomColor: colors.grey100 + }, + arrowDown: { + color: colors.blue, + fontSize: 25, + marginHorizontal: 15 + }, + buttonsContainer: { + marginTop: Device.isIphone5() ? 10 : 30, + marginBottom: 5, + paddingHorizontal: 30, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between' + }, + column: { + flex: 1 + }, + disabledSlippage: { + color: colors.grey300 + }, + ctaContainer: { + flexDirection: 'row', + justifyContent: 'flex-end' + }, + cta: { + paddingHorizontal: Device.isIphone5() ? 10 : 20 + } +}); + +function SwapsAmountView({ tokens, accounts, selectedAddress, balances }) { + const navigation = useContext(NavigationContext); + const initialSource = navigation.getParam('sourceToken', 'ETH'); + const [amount, setAmount] = useState('0'); + const amountBigNumber = useMemo(() => new BigNumber(amount), [amount]); + const [isInitialLoadingTokens, setInitialLoadingTokens] = useState(false); + const [, setLoadingTokens] = useState(false); + + const [sourceToken, setSourceToken] = useState(() => tokens?.find(token => token.symbol === initialSource)); + const [destinationToken, setDestinationToken] = useState(null); + + const [isSourceModalVisible, toggleSourceModal] = useModalHandler(false); + const [isDestinationModalVisible, toggleDestinationModal] = useModalHandler(false); + + const hasInvalidDecimals = useMemo(() => { + if (sourceToken) { + return amount.replace(/(\d+\.\d*[1-9]|\d+\.)(0+$)/g, '$1').split('.')[1]?.length > sourceToken.decimals; + } + return false; + }, [amount, sourceToken]); + + useEffect(() => { + (async () => { + const { SwapsController } = Engine.context; + try { + if (tokens === null) { + setInitialLoadingTokens(true); + } + setLoadingTokens(true); + await SwapsController.fetchTokenWithCache(); + setLoadingTokens(false); + setInitialLoadingTokens(false); + } catch (err) { + console.error(err); + } finally { + setLoadingTokens(false); + setInitialLoadingTokens(false); + } + })(); + }, [tokens]); + + useEffect(() => { + if (initialSource && tokens && !sourceToken) { + setSourceToken(tokens.find(token => token.symbol === initialSource)); + } + }, [tokens, initialSource, sourceToken]); + + const balance = useMemo(() => { + if (!sourceToken) { + return null; + } + if (sourceToken.symbol === 'ETH') { + return renderFromWei(accounts[selectedAddress] && accounts[selectedAddress].balance); + } + const tokenAddress = toChecksumAddress(sourceToken.address); + return tokenAddress in balances ? renderFromTokenMinimalUnit(balances[tokenAddress], sourceToken.decimals) : 0; + }, [accounts, balances, selectedAddress, sourceToken]); + + const hasEnoughBalance = useMemo(() => amountBigNumber.lte(new BigNumber(balance)), [amountBigNumber, balance]); + + /* Keypad Handlers */ + const handleKeypadPress = useCallback( + newInput => { + const newAmount = handleInput(amount, newInput); + if (newAmount === amount) { + return; + } + + setAmount(newAmount); + }, + [amount] + ); + const handleKeypadPress1 = useCallback(() => handleKeypadPress('1'), [handleKeypadPress]); + const handleKeypadPress2 = useCallback(() => handleKeypadPress('2'), [handleKeypadPress]); + const handleKeypadPress3 = useCallback(() => handleKeypadPress('3'), [handleKeypadPress]); + const handleKeypadPress4 = useCallback(() => handleKeypadPress('4'), [handleKeypadPress]); + const handleKeypadPress5 = useCallback(() => handleKeypadPress('5'), [handleKeypadPress]); + const handleKeypadPress6 = useCallback(() => handleKeypadPress('6'), [handleKeypadPress]); + const handleKeypadPress7 = useCallback(() => handleKeypadPress('7'), [handleKeypadPress]); + const handleKeypadPress8 = useCallback(() => handleKeypadPress('8'), [handleKeypadPress]); + const handleKeypadPress9 = useCallback(() => handleKeypadPress('9'), [handleKeypadPress]); + const handleKeypadPress0 = useCallback(() => handleKeypadPress('0'), [handleKeypadPress]); + const handleKeypadPressPeriod = useCallback(() => handleKeypadPress('PERIOD'), [handleKeypadPress]); + const handleKeypadPressBack = useCallback(() => handleKeypadPress('BACK'), [handleKeypadPress]); + + const handleSourceTokenPress = useCallback( + item => { + toggleSourceModal(); + setSourceToken(item); + }, + [toggleSourceModal] + ); + const handleDestinationTokenPress = useCallback( + item => { + toggleDestinationModal(); + setDestinationToken(item); + }, + [toggleDestinationModal] + ); -function SwapsAmountView() { - // const navigation = useContext(NavigationContext); return ( - - - - Swaps here - - + + + + {isInitialLoadingTokens ? ( + + ) : ( + + )} + + + + + + {amount} + + {sourceToken && (hasInvalidDecimals || !hasEnoughBalance) ? ( + + {!hasEnoughBalance + ? strings('swaps.not_enough', { symbol: sourceToken.symbol }) + : strings('swaps.allows_up_to_decimals', { + symbol: sourceToken.symbol, + decimals: sourceToken.decimals + // eslint-disable-next-line no-mixed-spaces-and-tabs + })} + + ) : ( + + {sourceToken && balance !== null + ? strings('swaps.available_to_swap', { asset: `${balance} ${sourceToken.symbol}` }) + : ''} + + )} + + + + + + + + {isInitialLoadingTokens ? ( + + ) : ( + + )} + + + + + + + 1 + 2 + 3 + + + 4 + 5 + 6 + + + 7 + 8 + 9 + + + . + 0 + + + + + + + + {strings('swaps.max_slippage', { slippage: '1%' })} + + + + + + + {strings('swaps.get_quotes')} + + + + + ); } SwapsAmountView.navigationOptions = ({ navigation }) => getSwapsAmountNavbar(navigation); -export default connect()(SwapsAmountView); +SwapsAmountView.propTypes = { + tokens: PropTypes.arrayOf(PropTypes.object), + /** + * Map of accounts to information objects including balances + */ + accounts: PropTypes.object, + /** + * A string that represents the selected address + */ + selectedAddress: PropTypes.string, + /** + * An object containing token balances for current account and network in the format address => balance + */ + balances: PropTypes.object +}; + +const mapStateToProps = state => ({ + tokens: state.engine.backgroundState.SwapsController.tokens, + accounts: state.engine.backgroundState.AccountTrackerController.accounts, + selectedAddress: state.engine.backgroundState.PreferencesController.selectedAddress, + balances: state.engine.backgroundState.TokenBalancesController.contractBalances +}); + +export default connect(mapStateToProps)(SwapsAmountView); diff --git a/app/core/Engine.js b/app/core/Engine.js index 380c1fb53bb..94ae98b9649 100644 --- a/app/core/Engine.js +++ b/app/core/Engine.js @@ -18,6 +18,8 @@ import { TypedMessageManager } from '@metamask/controllers'; +import { SwapsController } from '@estebanmino/controllers'; + import AsyncStorage from '@react-native-community/async-storage'; import Encryptor from './Encryptor'; @@ -113,7 +115,8 @@ class Engine { new TokenBalancesController(), new TokenRatesController(), new TransactionController(), - new TypedMessageManager() + new TypedMessageManager(), + new SwapsController() ], initialState ); @@ -425,7 +428,8 @@ export default { TokenBalancesController, TokenRatesController, TransactionController, - TypedMessageManager + TypedMessageManager, + SwapsController } = instance.datamodel.state; return { @@ -443,7 +447,8 @@ export default { TokenBalancesController, TokenRatesController, TransactionController, - TypedMessageManager + TypedMessageManager, + SwapsController }; }, get datamodel() { diff --git a/app/reducers/engine/index.js b/app/reducers/engine/index.js index 849ff9ce0e3..f8a8cc3e01b 100644 --- a/app/reducers/engine/index.js +++ b/app/reducers/engine/index.js @@ -78,6 +78,10 @@ function initalizeEngine(state = {}) { Engine.context.TypedMessageManager.subscribe(() => { store.dispatch({ type: 'UPDATE_BG_STATE', key: 'TypedMessageManager' }); }); + + Engine.context.SwapsController.subscribe(() => { + store.dispatch({ type: 'UPDATE_BG_STATE', key: 'SwapsController' }); + }); } const engineReducer = (state = initialState, action) => { diff --git a/locales/en.json b/locales/en.json index fe14363981e..10738b7522d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1373,6 +1373,18 @@ "amount": "Amount", "total_amount": "Total amount" }, + "swaps": { + "convert_from": "Convert from", + "convert_to": "Convert to", + "select_a_token": "Select a token", + "search_token": "Search for a token", + "no_tokens_result": "No tokens match “{{searchString}}”", + "available_to_swap": "{{asset}} available to swap", + "not_enough": "Not enough {{symbol}} to complete this swap", + "max_slippage": "Max slippage {{slippage}}", + "allows_up_to_decimals": "{{symbol}} allows up to {{decimals}} decimals", + "get_quotes": "Get quotes" + }, "protect_wallet_modal": { "title": "Protect your wallet", "top_button": "Protect wallet", diff --git a/locales/es.json b/locales/es.json index 427b96b1cf6..3018b563db5 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1247,6 +1247,18 @@ "amount": "Cantidad", "total_amount": "Cantidad total" }, + "swaps": { + "convert_from": "Convertir desde", + "convert_to": "Convertir a", + "select_a_token": "Selecciona un token", + "search_token": "Buscar un token", + "no_tokens_result": "No se encontraron tokens para “{{searchString}}”", + "available_to_swap": "{{asset}} disponible para convertir", + "not_enough": "Insuficiente {{symbol}} para esta conversión", + "max_slippage": "Deslizamiento máx. {{slippage}}", + "allows_up_to_decimals": "{{symbol}} soporta hasta {{decimals}} decimales", + "get_quotes": "Cotizar" + }, "protect_wallet_modal": { "title": "Protege tu billetera", "top_button": "Proteger billetera", diff --git a/package.json b/package.json index aa031c30045..2db1684e1b9 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "react-native-level-fs/**/bl": "^0.9.5" }, "dependencies": { + "@estebanmino/controllers": "3.2.4", "@exodus/react-native-payments": "https://github.com/wachunei/react-native-payments.git#package-json-hack", "@metamask/contract-metadata": "^1.19.0", "@metamask/controllers": "3.2.0", @@ -80,6 +81,7 @@ "@react-native-community/cookies": "^4.0.1", "@react-native-community/netinfo": "4.1.5", "@react-native-community/viewpager": "^3.3.0", + "@rnhooks/keyboard": "^0.0.3", "@sentry/integrations": "5.13.0", "@sentry/react-native": "1.3.3", "@tradle/react-native-http": "2.0.1", diff --git a/yarn.lock b/yarn.lock index 897eda576e1..f107450ad51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -735,6 +735,35 @@ dependencies: "@types/hammerjs" "^2.0.36" +"@estebanmino/controllers@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@estebanmino/controllers/-/controllers-3.2.4.tgz#c6e4ed2fb130179553f4efdcfd7d7e485e0f55b2" + integrity sha512-gBhELpaUlpVZ0eoVjRl8bMedBX9ndRngM1pP6ChzCPupahZ6pInabNkdIG5w2XDW9c9Z3LW2CxrhePOE1jWmJQ== + dependencies: + await-semaphore "^0.1.3" + bignumber.js "^9.0.1" + eth-contract-metadata "^1.11.0" + eth-ens-namehash "^2.0.8" + eth-json-rpc-infura "^5.1.0" + eth-keyring-controller "^5.6.1" + eth-method-registry "1.1.0" + eth-phishing-detect "^1.1.13" + eth-query "^2.1.2" + eth-rpc-errors "^3.0.0" + eth-sig-util "^2.3.0" + ethereumjs-util "^6.1.0" + ethereumjs-wallet "0.6.0" + ethjs-query "^0.3.8" + human-standard-collectible-abi "^1.0.2" + human-standard-token-abi "^2.0.0" + isomorphic-fetch "^3.0.0" + jsonschema "^1.2.4" + percentile "^1.2.1" + single-call-balance-checker-abi "^1.0.0" + uuid "^3.3.2" + web3 "^0.20.7" + web3-provider-engine "^16.0.1" + "@ethersproject/abi@^5.0.5": version "5.0.5" resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.0.5.tgz#6e7bbf9d014791334233ba18da85331327354aa1" @@ -1563,6 +1592,11 @@ hoist-non-react-statics "^3.3.2" react-native-safe-area-view "^0.14.9" +"@rnhooks/keyboard@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@rnhooks/keyboard/-/keyboard-0.0.3.tgz#e17a62a9f1e4f25efdf0afa4359b82e3dbea6523" + integrity sha512-tBaDWQkcLgeEQCol/6NkB8JyRkvS7L3//mkkOSNOoeLc74Fttz8kiLUsSj9cBwSyFCrWP2K04Tn8zNgWfdFQYg== + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -2692,6 +2726,11 @@ bignumber.js@^7.2.1: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-7.2.1.tgz#80c048759d826800807c4bfd521e50edbba57a5f" integrity sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ== +bignumber.js@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5" + integrity sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA== + "bignumber.js@git+https://github.com/frozeman/bignumber.js-nolookahead.git": version "2.0.7" resolved "git+https://github.com/frozeman/bignumber.js-nolookahead.git#57692b3ecfc98bbdd6b3a516cb2353652ea49934"