diff --git a/locales/base/translation.json b/locales/base/translation.json index 8d6252ba8b1..28512fbbf2c 100644 --- a/locales/base/translation.json +++ b/locales/base/translation.json @@ -1859,5 +1859,10 @@ "add": "Buy tokens using one of our trusted providers", "withdraw": "Transfer tokens to a bank account, mobile money, gift card etc." } + }, + "sendEnterAmountScreen": { + "title": "Enter Amount", + "selectToken": "Select a Token", + "networkFee": "{{networkName}} Network Fee" } } diff --git a/src/analytics/Properties.tsx b/src/analytics/Properties.tsx index 6014feb1efb..ee6c2356f8a 100644 --- a/src/analytics/Properties.tsx +++ b/src/analytics/Properties.tsx @@ -530,6 +530,8 @@ interface SendEventsProperties { underlyingTokenSymbol: string underlyingAmount: string | null amountInUsd: string | null + tokenId: string | null + networkId: string | null } [SendEvents.send_confirm_back]: undefined [SendEvents.send_confirm_send]: diff --git a/src/navigator/Navigator.tsx b/src/navigator/Navigator.tsx index 14c5d07e9cb..d7d739d631e 100644 --- a/src/navigator/Navigator.tsx +++ b/src/navigator/Navigator.tsx @@ -94,6 +94,7 @@ import { store } from 'src/redux/store' import Send from 'src/send/Send' import SendAmount from 'src/send/SendAmount' import SendConfirmation, { sendConfirmationScreenNavOptions } from 'src/send/SendConfirmation' +import SendEnterAmount from 'src/send/SendEnterAmount' import ValidateRecipientAccount, { validateRecipientAccountScreenNavOptions, } from 'src/send/ValidateRecipientAccount' @@ -254,6 +255,11 @@ const sendScreens = (Navigator: typeof Stack) => ( component={ReclaimPaymentConfirmationScreen} options={headerWithBackButton} /> + ) diff --git a/src/navigator/Screens.tsx b/src/navigator/Screens.tsx index bb3953447cc..38083956e9a 100644 --- a/src/navigator/Screens.tsx +++ b/src/navigator/Screens.tsx @@ -69,6 +69,7 @@ export enum Screens { SendAmount = 'SendAmount', SendConfirmation = 'SendConfirmation', SendConfirmationModal = 'SendConfirmationModal', + SendEnterAmount = 'SendEnterAmount', Settings = 'Settings', SetUpKeylessBackup = 'SetUpKeylessBackup', SignInWithEmail = 'SignInWithEmail', diff --git a/src/navigator/types.tsx b/src/navigator/types.tsx index 6ce465c7b55..b28327fdc5f 100644 --- a/src/navigator/types.tsx +++ b/src/navigator/types.tsx @@ -238,6 +238,13 @@ export type StackParamList = { } [Screens.SendConfirmation]: SendConfirmationParams [Screens.SendConfirmationModal]: SendConfirmationParams + [Screens.SendEnterAmount]: { + recipient: Recipient + isFromScan: boolean + origin: SendOrigin + forceTokenId?: boolean + defaultTokenIdOverride?: string + } [Screens.Settings]: { promptConfirmRemovalModal?: boolean } | undefined [Screens.SetUpKeylessBackup]: undefined [Screens.SignInWithEmail]: { diff --git a/src/redux/migrations.ts b/src/redux/migrations.ts index 07836232cf7..efe811b5270 100644 --- a/src/redux/migrations.ts +++ b/src/redux/migrations.ts @@ -1393,4 +1393,8 @@ export const migrations = { }), }, }), + 163: (state: any) => ({ + ...state, + send: { ..._.omit(state.send, 'lastUsedCurrency'), lastUsedTokenId: undefined }, + }), } diff --git a/src/redux/store.test.ts b/src/redux/store.test.ts index 33dcaecf4f9..11828e2a778 100644 --- a/src/redux/store.test.ts +++ b/src/redux/store.test.ts @@ -98,7 +98,7 @@ describe('store state', () => { { "_persist": { "rehydrated": true, - "version": 162, + "version": 163, }, "account": { "acceptedTerms": false, @@ -312,7 +312,7 @@ describe('store state', () => { "inviteRewardWeeklyLimit": 20, "inviteRewardsVersion": "none", "isSending": false, - "lastUsedCurrency": "cUSD", + "lastUsedTokenId": undefined, "recentPayments": [], "recentRecipients": [], "showSendToAddressWarning": true, diff --git a/src/redux/store.ts b/src/redux/store.ts index f3123180c2d..1bd810c50c6 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -23,7 +23,7 @@ const persistConfig: PersistConfig = { key: 'root', // default is -1, increment as we make migrations // See https://github.com/valora-inc/wallet/tree/main/WALLET.md#redux-state-migration - version: 162, + version: 163, keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems. storage: FSStorage(), blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup'], diff --git a/src/send/Send.test.tsx b/src/send/Send.test.tsx index 4ce345c6cda..b0594dd55d0 100644 --- a/src/send/Send.test.tsx +++ b/src/send/Send.test.tsx @@ -225,7 +225,7 @@ describe('Send', () => { ) fireEvent.press(getAllByTestId('RecipientItem')[0]) - expect(navigate).toHaveBeenCalledWith(Screens.SendAmount, { + expect(navigate).toHaveBeenCalledWith(Screens.SendEnterAmount, { isFromScan: false, origin: SendOrigin.AppSendFlow, recipient: expect.objectContaining(mockRecipient), diff --git a/src/send/Send.tsx b/src/send/Send.tsx index f01f121a825..c9372df0db2 100644 --- a/src/send/Send.tsx +++ b/src/send/Send.tsx @@ -128,9 +128,7 @@ function Send({ route }: Props) { } if (getFeatureGate(StatsigFeatureGates.USE_NEW_SEND_FLOW)) { - // TODO (ACT-945): Use New Send Flow - // Currently navigates to old send flow without bottom sheet - navigate(Screens.SendAmount, { + navigate(Screens.SendEnterAmount, { isFromScan: false, defaultTokenIdOverride, forceTokenId, diff --git a/src/send/SendAmount/useTransactionCallbacks.ts b/src/send/SendAmount/useTransactionCallbacks.ts index 502436c7912..bcdd9cb5178 100644 --- a/src/send/SendAmount/useTransactionCallbacks.ts +++ b/src/send/SendAmount/useTransactionCallbacks.ts @@ -87,6 +87,8 @@ function useTransactionCallbacks({ underlyingTokenSymbol: tokenInfo?.symbol ?? '', underlyingAmount: tokenAmount.toString(), amountInUsd: usdAmount?.toString() ?? null, + tokenId: tokenInfo?.tokenId ?? null, + networkId: tokenInfo?.networkId ?? null, } }, [ origin, diff --git a/src/send/SendEnterAmount.test.tsx b/src/send/SendEnterAmount.test.tsx new file mode 100644 index 00000000000..20e26547641 --- /dev/null +++ b/src/send/SendEnterAmount.test.tsx @@ -0,0 +1,238 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native' +import BigNumber from 'bignumber.js' +import React from 'react' +import { Provider } from 'react-redux' +import { SendEvents } from 'src/analytics/Events' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import { SendOrigin } from 'src/analytics/types' +import { useMaxSendAmount } from 'src/fees/hooks' +import { RecipientType } from 'src/recipients/recipient' +import SendEnterAmount from 'src/send/SendEnterAmount' +import { getSupportedNetworkIdsForSend } from 'src/tokens/utils' +import { NetworkId } from 'src/transactions/types' +import MockedNavigator from 'test/MockedNavigator' +import { createMockStore } from 'test/utils' +import { + mockCeloTokenId, + mockEthTokenId, + mockPoofAddress, + mockPoofTokenId, + mockTokenBalances, +} from 'test/values' + +jest.mock('src/tokens/utils', () => ({ + ...jest.requireActual('src/tokens/utils'), + getSupportedNetworkIdsForSend: jest.fn(), +})) + +jest.mock('src/fees/hooks') + +const mockStore = { + tokens: { + tokenBalances: { + ...mockTokenBalances, + [mockEthTokenId]: { + tokenId: mockEthTokenId, + balance: '0', + priceUsd: '5', + networkId: NetworkId['ethereum-sepolia'], + showZeroBalance: true, + isNative: true, + symbol: 'ETH', + priceFetchedAt: Date.now(), + name: 'Ether', + }, + }, + }, +} + +const params = { + origin: SendOrigin.AppSendFlow, + recipient: { + recipientType: RecipientType.Address, + address: '0x123', + }, + isFromScan: false, +} + +describe('SendEnterAmount', () => { + beforeEach(() => { + jest + .mocked(getSupportedNetworkIdsForSend) + .mockReturnValue([NetworkId['celo-alfajores'], NetworkId['ethereum-sepolia']]) + jest.clearAllMocks() + }) + + it('renders components with picker using token with highest balance if no override or last used token exists', () => { + const store = createMockStore(mockStore) + + const { getByTestId, getByText } = render( + + + + ) + + expect(getByTestId('SendEnterAmount/Input')).toBeTruthy() + expect(getByTestId('SendEnterAmount/LocalAmount')).toBeTruthy() + expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.00') + expect(getByTestId('SendEnterAmount/Max')).toBeTruthy() + expect(getByTestId('SendEnterAmount/TokenSelect')).toBeTruthy() + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF') + expect( + getByText('sendEnterAmountScreen.networkFee, {"networkName":"Celo Alfajores"}') + ).toBeTruthy() + }) + + it('renders components with picker using last used token', () => { + const store = createMockStore({ ...mockStore, send: { lastUsedTokenId: mockEthTokenId } }) + + const { getByTestId, getByText } = render( + + + + ) + + expect(getByTestId('SendEnterAmount/Input')).toBeTruthy() + expect(getByTestId('SendEnterAmount/LocalAmount')).toBeTruthy() + expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.00') + expect(getByTestId('SendEnterAmount/Max')).toBeTruthy() + expect(getByTestId('SendEnterAmount/TokenSelect')).toBeTruthy() + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('ETH') + expect( + getByText('sendEnterAmountScreen.networkFee, {"networkName":"Ethereum Sepolia"}') + ).toBeTruthy() + }) + + it('renders components with picker using token override', () => { + const store = createMockStore({ ...mockStore, send: { lastUsedTokenId: mockEthTokenId } }) + + const { getByTestId, getByText } = render( + + + + ) + + expect(getByTestId('SendEnterAmount/Input')).toBeTruthy() + expect(getByTestId('SendEnterAmount/LocalAmount')).toBeTruthy() + expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.00') + expect(getByTestId('SendEnterAmount/Max')).toBeTruthy() + expect(getByTestId('SendEnterAmount/TokenSelect')).toBeTruthy() + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('CELO') + expect( + getByText('sendEnterAmountScreen.networkFee, {"networkName":"Celo Alfajores"}') + ).toBeTruthy() + }) + + it('renders components with picker using token with highest balance if default override is not supported for sends', () => { + jest.mocked(getSupportedNetworkIdsForSend).mockReturnValue([NetworkId['celo-alfajores']]) + const store = createMockStore(mockStore) + + const { getByTestId, getByText } = render( + + + + ) + + expect(getByTestId('SendEnterAmount/Input')).toBeTruthy() + expect(getByTestId('SendEnterAmount/LocalAmount')).toBeTruthy() + expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.00') + expect(getByTestId('SendEnterAmount/Max')).toBeTruthy() + expect(getByTestId('SendEnterAmount/TokenSelect')).toBeTruthy() + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF') + expect( + getByText('sendEnterAmountScreen.networkFee, {"networkName":"Celo Alfajores"}') + ).toBeTruthy() + }) + + it('entering amount updates local amount', () => { + const store = createMockStore(mockStore) + + const { getByTestId } = render( + + + + ) + + fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '10') + expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱1.33') + }) + + it('only allows numeric input', () => { + const store = createMockStore(mockStore) + + const { getByTestId } = render( + + + + ) + + fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '10.5') + expect(getByTestId('SendEnterAmount/Input').props.value).toBe('10.5') + fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '10.5.1') + expect(getByTestId('SendEnterAmount/Input').props.value).toBe('10.5') + fireEvent.changeText(getByTestId('SendEnterAmount/Input'), 'abc') + expect(getByTestId('SendEnterAmount/Input').props.value).toBe('10.5') + }) + + it('selecting new token updates token and network info', async () => { + const store = createMockStore(mockStore) + + const { getByTestId, getByText } = render( + + + + ) + + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF') + expect( + getByText('sendEnterAmountScreen.networkFee, {"networkName":"Celo Alfajores"}') + ).toBeTruthy() + fireEvent.press(getByTestId('SendEnterAmount/TokenSelect')) + await waitFor(() => expect(getByText('Ether')).toBeTruthy()) + fireEvent.press(getByText('Ether')) + expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('ETH') + expect( + getByText('sendEnterAmountScreen.networkFee, {"networkName":"Ethereum Sepolia"}') + ).toBeTruthy() + expect(ValoraAnalytics.track).toHaveBeenCalledTimes(2) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.token_dropdown_opened, { + currentNetworkId: NetworkId['celo-alfajores'], + currentTokenAddress: mockPoofAddress, + currentTokenId: mockPoofTokenId, + }) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.token_selected, { + networkId: NetworkId['ethereum-sepolia'], + tokenAddress: undefined, + tokenId: mockEthTokenId, + origin: 'Send', + }) + // TODO(ACT-958): assert fees + }) + + it('pressing max fills in max available amount', () => { + jest.mocked(useMaxSendAmount).mockReturnValue(new BigNumber(5)) + const store = createMockStore(mockStore) + + const { getByTestId } = render( + + + + ) + + fireEvent.press(getByTestId('SendEnterAmount/Max')) + expect(getByTestId('SendEnterAmount/Input').props.value).toBe('5') + expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.67') + expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.max_pressed, { + networkId: NetworkId['celo-alfajores'], + tokenAddress: mockPoofAddress, + tokenId: mockPoofTokenId, + }) + }) +}) diff --git a/src/send/SendEnterAmount.tsx b/src/send/SendEnterAmount.tsx new file mode 100644 index 00000000000..7f501b5ae20 --- /dev/null +++ b/src/send/SendEnterAmount.tsx @@ -0,0 +1,359 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import BigNumber from 'bignumber.js' +import React, { useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Platform, TextInput as RNTextInput, StyleSheet, Text } from 'react-native' +import { View } from 'react-native-animatable' +import FastImage from 'react-native-fast-image' +import { SafeAreaView } from 'react-native-safe-area-context' +import { SendEvents } from 'src/analytics/Events' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import BackButton from 'src/components/BackButton' +import { BottomSheetRefType } from 'src/components/BottomSheet' +import Button, { BtnSizes } from 'src/components/Button' +import KeyboardAwareScrollView from 'src/components/KeyboardAwareScrollView' +import KeyboardSpacer from 'src/components/KeyboardSpacer' +import TextInput from 'src/components/TextInput' +import TokenBottomSheet, { + TokenBalanceItemOption, + TokenPickerOrigin, +} from 'src/components/TokenBottomSheet' +import TokenDisplay from 'src/components/TokenDisplay' +import Touchable from 'src/components/Touchable' +import CustomHeader from 'src/components/header/CustomHeader' +import { useMaxSendAmount } from 'src/fees/hooks' +import { FeeType } from 'src/fees/reducer' +import DownArrowIcon from 'src/icons/DownArrowIcon' +import { getLocalCurrencyCode, usdToLocalCurrencyRateSelector } from 'src/localCurrency/selectors' +import { Screens } from 'src/navigator/Screens' +import { StackParamList } from 'src/navigator/types' +import useSelector from 'src/redux/useSelector' +import { lastUsedTokenIdSelector } from 'src/send/selectors' +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 { useTokenToLocalAmount } from 'src/tokens/hooks' +import { tokensWithNonZeroBalanceAndShowZeroBalanceSelector } from 'src/tokens/selectors' +import { TokenBalance } from 'src/tokens/slice' +import { getSupportedNetworkIdsForSend } from 'src/tokens/utils' + +type Props = NativeStackScreenProps + +const TOKEN_SELECTOR_BORDER_RADIUS = 100 +const MAX_BORDER_RADIUS = 96 + +function SendEnterAmount({ route }: Props) { + const { t } = useTranslation() + const { defaultTokenIdOverride, origin, recipient, isFromScan } = route.params + const [amount, setAmount] = useState('') + const supportedNetworkIds = getSupportedNetworkIdsForSend() + const tokens = useSelector((state) => + tokensWithNonZeroBalanceAndShowZeroBalanceSelector(state, supportedNetworkIds) + ) + const lastUsedTokenId = useSelector(lastUsedTokenIdSelector) + + const defaultToken = useMemo(() => { + const defaultToken = tokens.find((token) => token.tokenId === defaultTokenIdOverride) + const lastUsedToken = tokens.find((token) => token.tokenId === lastUsedTokenId) + + return defaultToken ?? lastUsedToken ?? tokens[0] + }, [tokens, defaultTokenIdOverride, lastUsedTokenId]) + + // the startPosition and textInputRef 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 textInputRef = useRef(null) + const tokenBottomSheetRef = useRef(null) + + const [token, setToken] = useState(defaultToken) + const maxAmount = useMaxSendAmount(token.tokenId, FeeType.SEND) + + const localCurrencyCode = useSelector(getLocalCurrencyCode) + const localCurrencyExchangeRate = useSelector(usdToLocalCurrencyRateSelector) + const localAmount = useTokenToLocalAmount(new BigNumber(amount), token.tokenId) + + const onTokenPickerSelect = () => { + tokenBottomSheetRef.current?.snapToIndex(0) + ValoraAnalytics.track(SendEvents.token_dropdown_opened, { + currentTokenId: token.tokenId, + currentTokenAddress: token.address, + currentNetworkId: token.networkId, + }) + } + + const onSelectToken = (token: TokenBalance) => { + setToken(token) + tokenBottomSheetRef.current?.close() + // NOTE: analytics is already fired by the bottom sheet, don't need one here + } + + const onMaxAmountPress = () => { + setAmount(maxAmount.toString()) + textInputRef.current?.blur() + ValoraAnalytics.track(SendEvents.max_pressed, { + tokenId: token.tokenId, + tokenAddress: token.address, + networkId: token.networkId, + }) + } + + const onReviewPress = () => { + // TODO(ACT-943): navigate to send confirmation screen once validation is + // done + ValoraAnalytics.track(SendEvents.send_amount_continue, { + origin, + isScan: isFromScan, + recipientType: recipient.recipientType, + localCurrencyExchangeRate, + localCurrency: localCurrencyCode, + localCurrencyAmount: localAmount?.toString() ?? null, + underlyingTokenAddress: token.address, + underlyingTokenSymbol: token.symbol, + underlyingAmount: amount.toString(), + amountInUsd: null, + tokenId: token.tokenId, + networkId: token.networkId, + }) + } + + const handleSetStartPosition = (value?: number) => { + if (Platform.OS === 'android') { + setStartPosition(value) + } + } + + return ( + + } /> + + {t('sendEnterAmountScreen.title')} + + + { + handleSetStartPosition(undefined) + if (!value) { + setAmount('') + } else { + setAmount( + (prev) => value.match(/^(?:\d+[.,]?\d*|[.,]\d*|[.,])$/)?.join('') ?? prev + ) + } + }} + value={amount} + placeholder="0" + // hide input when loading to prevent the UI height from jumping + style={styles.input} + keyboardType="decimal-pad" + // Work around for RN issue with Samsung keyboards + // https://github.com/facebook/react-native/issues/22005 + autoCapitalize="words" + autoFocus={true} + // 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={[styles.inputText, Platform.select({ ios: { lineHeight: undefined } })]} + testID="SendEnterAmount/Input" + onBlur={() => { + handleSetStartPosition(0) + }} + onFocus={() => { + handleSetStartPosition(amount?.length ?? 0) + }} + onSelectionChange={() => { + handleSetStartPosition(undefined) + }} + selection={ + Platform.OS === 'android' && typeof startPosition === 'number' + ? { start: startPosition } + : undefined + } + /> + + <> + + {token.symbol} + + + + + + + + {t('max')} + + + + + + {t('sendEnterAmountScreen.networkFee', { networkName: NETWORK_NAMES[token.networkId] })} + + {/* TODO(ACT-958): Display estimated fees */} + + + ~ + + + + + + +