diff --git a/src/earn/EarnEnterAmount.test.tsx b/src/earn/EarnEnterAmount.test.tsx index f8d38f6c645..73f720ccd30 100644 --- a/src/earn/EarnEnterAmount.test.tsx +++ b/src/earn/EarnEnterAmount.test.tsx @@ -8,6 +8,7 @@ import AppAnalytics from 'src/analytics/AppAnalytics' import { EarnEvents } from 'src/analytics/Events' import EarnEnterAmount from 'src/earn/EarnEnterAmount' import { usePrepareEnterAmountTransactionsCallback } from 'src/earn/hooks' +import { Status as EarnStatus } from 'src/earn/slice' import { CICOFlow } from 'src/fiatExchanges/types' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' @@ -35,7 +36,6 @@ import { mockTokenBalances, mockUSDCAddress, } from 'test/values' -import { Status as EarnStatus } from 'src/earn/slice' jest.mock('src/earn/hooks') jest.mock('react-native-localize') @@ -501,13 +501,6 @@ describe('EarnEnterAmount', () => { await waitFor(() => expect(getByText('earnFlow.enterAmount.continue')).not.toBeDisabled()) - expect(getByTestId('EarnEnterAmount/Withdraw/Crypto')).toBeTruthy() - expect(getByTestId('EarnEnterAmount/Withdraw/Crypto')).toHaveTextContent('11.00 USDC') - - expect(getByTestId('EarnEnterAmount/Withdraw/Fiat')).toBeTruthy() - expect(getByTestId('EarnEnterAmount/Withdraw/Fiat')).toBeTruthy() - expect(getByTestId('EarnEnterAmount/Withdraw/Fiat')).toHaveTextContent('₱14.63') - expect(getByTestId('EarnEnterAmount/Fees')).toBeTruthy() expect(getByTestId('EarnEnterAmount/Fees')).toHaveTextContent('₱0.012') @@ -725,9 +718,9 @@ describe('EarnEnterAmount', () => { fireEvent.press(within(getByTestId('EarnEnterAmount/AmountOptions')).getByText('maxSymbol')) expect(getByTestId('EarnEnterAmount/TokenAmountInput').props.value).toBe( - replaceSeparators('100000.42') + replaceSeparators('100,000.42') ) - expect(getByTestId('EarnEnterAmount/LocalAmountInput').props.value).toBe( + expect(getByTestId('EarnEnterAmount/ExchangeAmount')).toHaveTextContent( replaceSeparators('₱133,000.56') ) }) @@ -788,6 +781,7 @@ describe('EarnEnterAmount', () => { ) + fireEvent.changeText(getByTestId('EarnEnterAmount/TokenAmountInput'), '1') fireEvent.press(getByTestId('LabelWithInfo/FeeLabel')) expect(getByText('earnFlow.enterAmount.feeBottomSheet.feeDetails')).toBeVisible() expect(getByTestId('EstNetworkFee/Value')).toBeTruthy() @@ -813,6 +807,7 @@ describe('EarnEnterAmount', () => { ) + fireEvent.changeText(getByTestId('EarnEnterAmount/TokenAmountInput'), '1') fireEvent.press(getByTestId('LabelWithInfo/FeeLabel')) expect(getByText('earnFlow.enterAmount.feeBottomSheet.feeDetails')).toBeVisible() expect(getByTestId('EstNetworkFee/Value')).toBeTruthy() @@ -844,6 +839,7 @@ describe('EarnEnterAmount', () => { ) + fireEvent.changeText(getByTestId('EarnEnterAmount/TokenAmountInput'), '1') fireEvent.press(getByTestId('LabelWithInfo/SwapLabel')) expect(getByText('earnFlow.enterAmount.swapBottomSheet.swapDetails')).toBeVisible() expect(getByTestId('SwapTo')).toBeTruthy() diff --git a/src/earn/EarnEnterAmount.tsx b/src/earn/EarnEnterAmount.tsx index 10752637e62..bcb276970c8 100644 --- a/src/earn/EarnEnterAmount.tsx +++ b/src/earn/EarnEnterAmount.tsx @@ -3,7 +3,6 @@ import BigNumber from 'bignumber.js' import React, { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Keyboard, TextInput as RNTextInput, StyleSheet, Text, View } from 'react-native' -import { getNumberFormatSettings } from 'react-native-localize' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import AppAnalytics from 'src/analytics/AppAnalytics' import { EarnEvents, SendEvents } from 'src/analytics/Events' @@ -14,50 +13,46 @@ import InLineNotification, { NotificationVariant } from 'src/components/InLineNo import KeyboardAwareScrollView from 'src/components/KeyboardAwareScrollView' import { LabelWithInfo } from 'src/components/LabelWithInfo' import RowDivider from 'src/components/RowDivider' -import TokenBottomSheet, { TokenPickerOrigin } from 'src/components/TokenBottomSheet' +import TokenBottomSheet, { + TokenBottomSheetProps, + TokenPickerOrigin, +} from 'src/components/TokenBottomSheet' import TokenDisplay from 'src/components/TokenDisplay' -import TokenIcon, { IconSize } from 'src/components/TokenIcon' -import Touchable from 'src/components/Touchable' +import TokenEnterAmount, { + FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME_MS, + useEnterAmount, +} from 'src/components/TokenEnterAmount' import CustomHeader from 'src/components/header/CustomHeader' import EarnDepositBottomSheet from 'src/earn/EarnDepositBottomSheet' import { usePrepareEnterAmountTransactionsCallback } from 'src/earn/hooks' +import { depositStatusSelector } from 'src/earn/selectors' import { getSwapToAmountInDecimals } from 'src/earn/utils' import { CICOFlow } from 'src/fiatExchanges/types' import ArrowRightThick from 'src/icons/ArrowRightThick' -import DownArrowIcon from 'src/icons/DownArrowIcon' -import { LocalCurrencySymbol } from 'src/localCurrency/consts' -import { getLocalCurrencySymbol } from 'src/localCurrency/selectors' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { StackParamList } from 'src/navigator/types' import { hooksApiUrlSelector, positionsWithBalanceSelector } from 'src/positions/selectors' import { EarnPosition, Position } from 'src/positions/types' import { useSelector } from 'src/redux/hooks' -import { AmountInput } from 'src/send/EnterAmount' import EnterAmountOptions from 'src/send/EnterAmountOptions' -import { AmountEnteredIn } from 'src/send/types' import { NETWORK_NAMES } from 'src/shared/conts' import Colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import { SwapTransaction } from 'src/swap/types' -import { useLocalToTokenAmount, useTokenInfo, useTokenToLocalAmount } from 'src/tokens/hooks' +import { useTokenInfo } from 'src/tokens/hooks' import { feeCurrenciesSelector, swappableFromTokensByNetworkIdSelector } from 'src/tokens/selectors' import { TokenBalance } from 'src/tokens/slice' import Logger from 'src/utils/Logger' -import { parseInputAmount } from 'src/utils/parsing' import { getFeeCurrencyAndAmounts, PreparedTransactionsResult } from 'src/viem/prepareTransactions' import { walletAddressSelector } from 'src/web3/selectors' import { isAddress } from 'viem' -import { depositStatusSelector } from 'src/earn/selectors' type Props = NativeStackScreenProps const TAG = 'EarnEnterAmount' -const TOKEN_SELECTOR_BORDER_RADIUS = 100 -const FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME = 250 - function useTokens({ pool }: { pool: EarnPosition }) { const depositToken = useTokenInfo(pool.dataProps.depositTokenId) const withdrawToken = useTokenInfo(pool.dataProps.withdrawTokenId) @@ -93,7 +88,7 @@ function useTokens({ pool }: { pool: EarnPosition }) { } } -function EarnEnterAmount({ route }: Props) { +export default function EarnEnterAmount({ route }: Props) { const { t } = useTranslation() const insets = useSafeAreaInsets() @@ -117,25 +112,54 @@ function EarnEnterAmount({ route }: Props) { } }, [mode]) - const [inputToken, setInputToken] = useState(() => availableInputTokens[0]) - + /** + * Use different balance for the withdrawal flow. As described in this discussion + * (https://github.com/valora-inc/wallet/pull/6246#discussion_r1883426564) the intent of this + * is to abstract away the LP token from the user and just display the token they're depositing, + * so we need to convert the LP token balance to deposit and back to LP token when transacting." + */ + const [inputToken, setInputToken] = useState(() => ({ + ...availableInputTokens[0], + balance: isWithdrawal + ? withdrawToken.balance.multipliedBy(pool.pricePerShare[0]) + : availableInputTokens[0].balance, + })) + + const inputRef = useRef(null) + const tokenBottomSheetRef = useRef(null) const reviewBottomSheetRef = useRef(null) const feeDetailsBottomSheetRef = useRef(null) const swapDetailsBottomSheetRef = useRef(null) - const tokenBottomSheetRef = useRef(null) - const tokenAmountInputRef = useRef(null) - const localAmountInputRef = useRef(null) - const [tokenAmountInput, setTokenAmountInput] = useState('') - const [localAmountInput, setLocalAmountInput] = useState('') - const [enteredIn, setEnteredIn] = useState('token') const [selectedPercentage, setSelectedPercentage] = useState(null) - - // this should never be null, just adding a default to make TS happy - const localCurrencySymbol = useSelector(getLocalCurrencySymbol) ?? LocalCurrencySymbol.USD const hooksApiUrl = useSelector(hooksApiUrlSelector) + const walletAddress = useSelector(walletAddressSelector) - const onTokenPickerSelect = () => { + const { + prepareTransactionsResult: { prepareTransactionsResult, swapTransaction } = {}, + refreshPreparedTransactions, + clearPreparedTransactions, + prepareTransactionError, + isPreparingTransactions, + } = usePrepareEnterAmountTransactionsCallback(mode) + + const { + amount, + replaceAmount, + amountType, + processedAmounts, + handleAmountInputChange, + handleToggleAmountType, + handleSelectPercentageAmount, + } = useEnterAmount({ + token: inputToken, + inputRef, + onHandleAmountInputChange: () => { + setSelectedPercentage(null) + }, + }) + + const onOpenTokenPicker = () => { tokenBottomSheetRef.current?.snapToIndex(0) AppAnalytics.track(SendEvents.token_dropdown_opened, { currentTokenId: inputToken.tokenId, @@ -144,22 +168,19 @@ function EarnEnterAmount({ route }: Props) { }) } - const onSelectToken = (token: TokenBalance) => { - setInputToken(token) + const onSelectToken: TokenBottomSheetProps['onTokenSelected'] = (selectedToken) => { + // Use different balance for the withdrawal flow. + setInputToken({ + ...selectedToken, + balance: isWithdrawal + ? withdrawToken.balance.multipliedBy(pool.pricePerShare[0]) + : selectedToken.balance, + }) + replaceAmount('') tokenBottomSheetRef.current?.close() // NOTE: analytics is already fired by the bottom sheet, don't need one here } - const { - prepareTransactionsResult: { prepareTransactionsResult, swapTransaction } = {}, - refreshPreparedTransactions, - clearPreparedTransactions, - prepareTransactionError, - isPreparingTransactions, - } = usePrepareEnterAmountTransactionsCallback(mode) - - const walletAddress = useSelector(walletAddressSelector) - const handleRefreshPreparedTransactions = ( amount: BigNumber, token: TokenBalance, @@ -182,78 +203,19 @@ function EarnEnterAmount({ route }: Props) { }) } - const { decimalSeparator, groupingSeparator } = getNumberFormatSettings() - // only allow numbers, one decimal separator, and two decimal places - const localAmountRegex = new RegExp( - `^(\\d+([${decimalSeparator}])?\\d{0,2}|[${decimalSeparator}]\\d{0,2}|[${decimalSeparator}])$` - ) - // only allow numbers, one decimal separator - const tokenAmountRegex = new RegExp( - `^(?:\\d+[${decimalSeparator}]?\\d*|[${decimalSeparator}]\\d*|[${decimalSeparator}])$` - ) - const parsedTokenAmount = useMemo( - () => parseInputAmount(tokenAmountInput, decimalSeparator), - [tokenAmountInput] - ) - const parsedLocalAmount = useMemo( - () => - parseInputAmount( - localAmountInput.replaceAll(groupingSeparator, '').replace(localCurrencySymbol, ''), - decimalSeparator - ), - [localAmountInput] - ) - - const tokenToLocal = useTokenToLocalAmount(parsedTokenAmount, inputToken.tokenId) - const localToToken = useLocalToTokenAmount(parsedLocalAmount, inputToken.tokenId) - - const { tokenAmount } = useMemo(() => { - if (enteredIn === 'token') { - setLocalAmountInput( - tokenToLocal && tokenToLocal.gt(0) - ? `${localCurrencySymbol}${tokenToLocal.toFormat(2)}` // automatically adds grouping separators - : '' - ) - return { - tokenAmount: parsedTokenAmount, - localAmount: tokenToLocal, - } - } else { - setTokenAmountInput( - localToToken && localToToken.gt(0) - ? // no group separator for token amount, round to token.decimals and strip trailing zeros - localToToken - .toFormat(inputToken.decimals, { decimalSeparator }) - .replace(new RegExp(`[${decimalSeparator}]?0+$`), '') - : '' - ) - return { - tokenAmount: localToToken, - localAmount: parsedLocalAmount, - } - } - }, [tokenAmountInput, localAmountInput, enteredIn, inputToken]) - // This is for withdrawals as we want the user to be able to input the amounts in the deposit token const { transactionToken, transactionTokenAmount } = useMemo(() => { const transactionToken = isWithdrawal ? withdrawToken : inputToken const transactionTokenAmount = isWithdrawal - ? tokenAmount && tokenAmount.dividedBy(pool.pricePerShare[0]) - : tokenAmount + ? processedAmounts.token.bignum && + processedAmounts.token.bignum.dividedBy(pool.pricePerShare[0]) + : processedAmounts.token.bignum return { transactionToken, transactionTokenAmount, } - }, [inputToken, withdrawToken, tokenAmount, isWithdrawal, pool]) - - const balanceInInputToken = useMemo( - () => - isWithdrawal - ? transactionToken.balance.multipliedBy(pool.pricePerShare[0]) - : transactionToken.balance, - [transactionToken, isWithdrawal, pool] - ) + }, [inputToken, withdrawToken, processedAmounts.token.bignum, isWithdrawal, pool]) const feeCurrencies = useSelector((state) => feeCurrenciesSelector(state, transactionToken.networkId) @@ -263,10 +225,10 @@ function EarnEnterAmount({ route }: Props) { clearPreparedTransactions() if ( - !tokenAmount || + !processedAmounts.token.bignum || !transactionTokenAmount || - tokenAmount.isLessThanOrEqualTo(0) || - tokenAmount.isGreaterThan(balanceInInputToken) + processedAmounts.token.bignum.isLessThanOrEqualTo(0) || + processedAmounts.token.bignum.isGreaterThan(inputToken.balance) ) { return } @@ -276,20 +238,21 @@ function EarnEnterAmount({ route }: Props) { transactionToken, feeCurrencies ) - }, FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME) + }, FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME_MS) return () => clearTimeout(debouncedRefreshTransactions) - }, [tokenAmount, mode, transactionToken, feeCurrencies]) + }, [processedAmounts.token.bignum?.toString(), mode, transactionToken, inputToken, feeCurrencies]) const { estimatedFeeAmount, feeCurrency, maxFeeAmount } = getFeeCurrencyAndAmounts(prepareTransactionsResult) - const isAmountLessThanBalance = tokenAmount && tokenAmount.lte(balanceInInputToken) + const showLowerAmountError = + processedAmounts.token.bignum && processedAmounts.token.bignum.gt(inputToken.balance) const showNotEnoughBalanceForGasWarning = - isAmountLessThanBalance && + !showLowerAmountError && prepareTransactionsResult && prepareTransactionsResult.type === 'not-enough-balance-for-gas' const transactionIsPossible = - isAmountLessThanBalance && + !showLowerAmountError && prepareTransactionsResult && prepareTransactionsResult.type === 'possible' && prepareTransactionsResult.transactions.length > 0 @@ -308,51 +271,10 @@ function EarnEnterAmount({ route }: Props) { // Should disable if the user enters 0, has enough balance but the transaction // is not possible, does not have enough balance, or if transaction is already // submitted - !!tokenAmount?.isZero() || !transactionIsPossible || transactionSubmitted - - const onTokenAmountInputChange = (value: string) => { - setSelectedPercentage(null) - if (!value) { - setTokenAmountInput('') - setEnteredIn('token') - } else { - if (value.startsWith(decimalSeparator)) { - value = `0${value}` - } - if (value.match(tokenAmountRegex)) { - setTokenAmountInput(value) - setEnteredIn('token') - } - } - } - - const onLocalAmountInputChange = (value: string) => { - setSelectedPercentage(null) - // remove leading currency symbol and grouping separators - if (value.startsWith(localCurrencySymbol)) { - value = value.slice(1) - } - value = value.replaceAll(groupingSeparator, '') - if (!value) { - setLocalAmountInput('') - setEnteredIn('local') - } else { - if (value.startsWith(decimalSeparator)) { - value = `0${value}` - } - if (value.match(localAmountRegex)) { - // add back currency symbol and grouping separators - setLocalAmountInput( - `${localCurrencySymbol}${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, groupingSeparator) - ) - setEnteredIn('local') - } - } - } + !!processedAmounts.token.bignum?.isZero() || !transactionIsPossible || transactionSubmitted const onSelectPercentageAmount = (percentage: number) => { - setTokenAmountInput(balanceInInputToken.multipliedBy(percentage).toFormat({ decimalSeparator })) - setEnteredIn('token') + handleSelectPercentageAmount(percentage) setSelectedPercentage(percentage) AppAnalytics.track(SendEvents.send_percentage_selected, { @@ -365,33 +287,36 @@ function EarnEnterAmount({ route }: Props) { } const onPressContinue = () => { - if (!tokenAmount || !transactionToken) { + if (!processedAmounts.token.bignum || !transactionToken) { // should never happen return } AppAnalytics.track(EarnEvents.earn_enter_amount_continue_press, { // TokenAmount is always deposit token - amountInUsd: tokenAmount.multipliedBy(inputToken.priceUsd ?? 0).toFixed(2), - amountEnteredIn: enteredIn, + amountInUsd: processedAmounts.token.bignum.multipliedBy(inputToken.priceUsd ?? 0).toFixed(2), + amountEnteredIn: amountType, depositTokenId: pool.dataProps.depositTokenId, networkId: inputToken.networkId, providerId: pool.appId, poolId: pool.positionId, fromTokenId: inputToken.tokenId, - fromTokenAmount: tokenAmount.toString(), + fromTokenAmount: processedAmounts.token.bignum.toString(), mode, depositTokenAmount: isWithdrawal ? undefined : swapTransaction - ? getSwapToAmountInDecimals({ swapTransaction, fromAmount: tokenAmount }).toString() - : tokenAmount.toString(), + ? getSwapToAmountInDecimals({ + swapTransaction, + fromAmount: processedAmounts.token.bignum, + }).toString() + : processedAmounts.token.bignum.toString(), }) if (isWithdrawal) { navigate(Screens.EarnConfirmationScreen, { pool, mode, - inputAmount: tokenAmount.toString(), + inputAmount: processedAmounts.token.bignum.toString(), useMax: selectedPercentage === 1, }) } else { @@ -421,61 +346,37 @@ function EarnEnterAmount({ route }: Props) { ? t('earnFlow.enterAmount.titleWithdraw') : t('earnFlow.enterAmount.title')} - - - - - <> - - {inputToken.symbol} - {dropdownEnabled && } - - - - - - - - {tokenAmount && prepareTransactionsResult && !isWithdrawal && ( + + {processedAmounts.token.bignum && prepareTransactionsResult && !isWithdrawal && ( )} - {tokenAmount && isWithdrawal && ( + {isWithdrawal && ( )} @@ -513,7 +414,7 @@ function EarnEnterAmount({ route }: Props) { testID="EarnEnterAmount/NotEnoughForGasWarning" /> )} - {!isAmountLessThanBalance && ( + {showLowerAmountError && ( - {tokenAmount && ( + {processedAmounts.token.bignum && ( )} - {swapTransaction && tokenAmount && ( + {swapTransaction && processedAmounts.token.bignum && ( )} - {tokenAmount && prepareTransactionsResult?.type === 'possible' && ( + {processedAmounts.token.bignum && prepareTransactionsResult?.type === 'possible' && ( - - - - - - {'('} - - {')'} - - - {pool.dataProps.withdrawalIncludesClaim && rewardsPositions.map((position, index) => ( - + - inputStyle?: StyleProp - autoFocus?: boolean - placeholder?: string - testID?: string - editable?: boolean -}) { - // the startPosition and inputRef variables exist to ensure TextInput - // displays the start of the value for long values on Android - // https://github.com/facebook/react-native/issues/14845 - const [startPosition, setStartPosition] = useState(0) - - const handleSetStartPosition = (value?: number) => { - if (Platform.OS === 'android') { - setStartPosition(value) - } - } - - return ( - - { - handleSetStartPosition(undefined) - onInputChange(value) - }} - editable={editable} - value={inputValue || undefined} - placeholder={placeholder} - keyboardType="decimal-pad" - // Work around for RN issue with Samsung keyboards - // https://github.com/facebook/react-native/issues/22005 - autoCapitalize="words" - autoFocus={autoFocus} - // unset lineHeight to allow ellipsis on long inputs on iOS. For - // android, ellipses doesn't work and unsetting line height causes - // height changes when amount is entered - inputStyle={[inputStyle, Platform.select({ ios: { lineHeight: undefined } })]} - testID={testID} - onBlur={() => { - handleSetStartPosition(0) - }} - onFocus={() => { - handleSetStartPosition(inputValue?.length ?? 0) - }} - onSelectionChange={() => { - handleSetStartPosition(undefined) - }} - selection={ - Platform.OS === 'android' && typeof startPosition === 'number' - ? { start: startPosition } - : undefined - } - /> - - ) -}