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"