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 */}
+
+
+ ~
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ safeAreaContainer: {
+ flex: 1,
+ },
+ contentContainer: {
+ padding: Spacing.Thick24,
+ },
+ title: {
+ ...typeScale.titleSmall,
+ },
+ inputContainer: {
+ marginTop: Spacing.Large32,
+ backgroundColor: '#FAFAFA',
+ borderWidth: 1,
+ borderRadius: 16,
+ borderColor: Colors.gray2,
+ flex: 1,
+ },
+ inputRow: {
+ marginLeft: Spacing.Regular16,
+ paddingRight: Spacing.Regular16,
+ paddingBottom: Spacing.Regular16,
+ paddingTop: Spacing.Smallest8,
+ borderBottomColor: Colors.gray2,
+ borderBottomWidth: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ localAmountRow: {
+ margin: Spacing.Regular16,
+ marginTop: Spacing.Thick24,
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ input: {
+ flex: 1,
+ marginRight: Spacing.Smallest8,
+ },
+ inputText: {
+ ...typeScale.titleMedium,
+ },
+ tokenSelectButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: Colors.light,
+ borderWidth: 1,
+ borderColor: Colors.gray2,
+ borderRadius: TOKEN_SELECTOR_BORDER_RADIUS,
+ paddingHorizontal: Spacing.Smallest8,
+ paddingVertical: Spacing.Tiny4,
+ },
+ tokenName: {
+ ...typeScale.labelSmall,
+ paddingLeft: Spacing.Tiny4,
+ paddingRight: Spacing.Smallest8,
+ },
+ tokenImage: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ },
+ localAmount: {
+ ...typeScale.labelMedium,
+ flex: 1,
+ },
+ maxTouchable: {
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ backgroundColor: Colors.gray2,
+ borderWidth: 1,
+ borderColor: Colors.gray2,
+ borderRadius: MAX_BORDER_RADIUS,
+ },
+ maxText: {
+ ...typeScale.labelSmall,
+ },
+ feeContainer: {
+ flexDirection: 'row',
+ marginTop: 18,
+ },
+ feeLabel: {
+ flex: 1,
+ ...typeScale.bodyXSmall,
+ color: Colors.gray4,
+ paddingLeft: 2,
+ },
+ feeAmountContainer: {
+ alignItems: 'flex-end',
+ paddingRight: 2,
+ },
+ feeInCryptoContainer: {
+ flexDirection: 'row',
+ },
+ feeInCrypto: {
+ color: Colors.gray4,
+ ...typeScale.labelXSmall,
+ },
+ feeInFiat: {
+ color: Colors.gray4,
+ ...typeScale.bodyXSmall,
+ },
+ reviewButton: {
+ padding: Spacing.Thick24,
+ },
+ reviewButtonText: {
+ ...typeScale.semiBoldMedium,
+ },
+})
+
+export default SendEnterAmount
diff --git a/src/send/reducers.ts b/src/send/reducers.ts
index 6bb22a054dd..9f7f9956c08 100644
--- a/src/send/reducers.ts
+++ b/src/send/reducers.ts
@@ -3,7 +3,6 @@ import { REMOTE_CONFIG_VALUES_DEFAULTS } from 'src/firebase/remoteConfigValuesDe
import { Recipient, areRecipientsEquivalent } from 'src/recipients/recipient'
import { REHYDRATE, RehydrateAction, getRehydratePayload } from 'src/redux/persist-helper'
import { ActionTypes, Actions } from 'src/send/actions'
-import { Currency } from 'src/utils/currencies'
import { timeDeltaInHours } from 'src/utils/time'
// Sets the limit of recent recipients we want to store
@@ -23,8 +22,8 @@ export interface State {
inviteRewardsVersion: string
inviteRewardCusd: number
inviteRewardWeeklyLimit: number
- lastUsedCurrency: Currency
showSendToAddressWarning: boolean
+ lastUsedTokenId?: string
}
const initialState = {
@@ -34,7 +33,6 @@ const initialState = {
inviteRewardsVersion: REMOTE_CONFIG_VALUES_DEFAULTS.inviteRewardsVersion,
inviteRewardCusd: REMOTE_CONFIG_VALUES_DEFAULTS.inviteRewardCusd,
inviteRewardWeeklyLimit: REMOTE_CONFIG_VALUES_DEFAULTS.inviteRewardWeeklyLimit,
- lastUsedCurrency: Currency.Dollar,
showSendToAddressWarning: true,
}
@@ -67,6 +65,8 @@ export const sendReducer = (
...state,
isSending: false,
recentPayments: [...paymentsLast24Hours, latestPayment],
+ // TODO(satish): set lastUsedToken once this is available in the send flow (after
+ // #4306 is merged)
}
case Actions.SEND_PAYMENT_FAILURE:
return {
@@ -80,11 +80,6 @@ export const sendReducer = (
inviteRewardCusd: action.configValues.inviteRewardCusd,
inviteRewardWeeklyLimit: action.configValues.inviteRewardWeeklyLimit,
}
- case Actions.UPDATE_LAST_USED_CURRENCY:
- return {
- ...state,
- lastUsedCurrency: action.currency,
- }
case Actions.SET_SHOW_WARNING:
return {
...state,
diff --git a/src/send/selectors.ts b/src/send/selectors.ts
index fee064d9a4d..e3fbdac7888 100644
--- a/src/send/selectors.ts
+++ b/src/send/selectors.ts
@@ -9,7 +9,7 @@ export const getRecentPayments = (state: RootState) => {
return state.send.recentPayments
}
-export const lastUsedCurrencySelector = (state: RootState) => state.send.lastUsedCurrency
+export const lastUsedTokenIdSelector = (state: RootState) => state.send.lastUsedTokenId
export const isSendingSelector = (state: RootState) => {
return state.send.isSending
diff --git a/src/styles/fonts.tsx b/src/styles/fonts.tsx
index 0c4e7b20cf2..2d92c5d93a6 100644
--- a/src/styles/fonts.tsx
+++ b/src/styles/fonts.tsx
@@ -80,6 +80,16 @@ export const typeScale = StyleSheet.create({
fontSize: 20,
lineHeight: 28,
},
+ semiBoldLarge: {
+ fontFamily: Inter.SemiBold,
+ fontSize: 18,
+ lineHeight: 28,
+ },
+ semiBoldMedium: {
+ fontFamily: Inter.SemiBold,
+ fontSize: 16,
+ lineHeight: 24,
+ },
labelLarge: {
fontFamily: Inter.Medium,
fontSize: 18,
diff --git a/src/tokens/Assets.tsx b/src/tokens/Assets.tsx
index ddede7f1675..3570fa8b300 100644
--- a/src/tokens/Assets.tsx
+++ b/src/tokens/Assets.tsx
@@ -23,7 +23,6 @@ import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import Button, { BtnSizes, BtnTypes } from 'src/components/Button'
import { AssetsTokenBalance } from 'src/components/TokenBalance'
import Touchable from 'src/components/Touchable'
-import { TOKEN_MIN_AMOUNT } from 'src/config'
import ImageErrorIcon from 'src/icons/ImageErrorIcon'
import { useDollarsToLocalAmount } from 'src/localCurrency/hooks'
import { getLocalCurrencySymbol } from 'src/localCurrency/selectors'
@@ -57,13 +56,9 @@ import variables from 'src/styles/variables'
import { PositionItem } from 'src/tokens/AssetItem'
import { TokenBalanceItem } from 'src/tokens/TokenBalanceItem'
import { useTokenPricesAreStale, useTotalTokenBalance } from 'src/tokens/hooks'
-import { tokensListSelector } from 'src/tokens/selectors'
+import { tokensWithNonZeroBalanceAndShowZeroBalanceSelector } from 'src/tokens/selectors'
import { TokenBalance } from 'src/tokens/slice'
-import {
- getSupportedNetworkIdsForTokenBalances,
- getTokenAnalyticsProps,
- usdBalance,
-} from 'src/tokens/utils'
+import { getSupportedNetworkIdsForTokenBalances, getTokenAnalyticsProps } from 'src/tokens/utils'
const DEVICE_WIDTH_BREAKPOINT = 340
const NUM_OF_NFTS_PER_ROW = 2
@@ -118,34 +113,8 @@ function AssetsScreen({ navigation, route }: Props) {
const activeTab = route.params?.activeTab ?? AssetTabType.Tokens
const supportedNetworkIds = getSupportedNetworkIdsForTokenBalances()
- const allTokens = useSelector((state) => tokensListSelector(state, supportedNetworkIds))
- const tokens = useMemo(
- () =>
- allTokens
- .filter((tokenInfo) => tokenInfo.balance.gt(TOKEN_MIN_AMOUNT) || tokenInfo.showZeroBalance)
- .sort((token1, token2) => {
- // Sorts by usd balance, then token balance, then zero balance natives by
- // network id, then zero balance non natives by network id
- const usdBalanceCompare = usdBalance(token2).comparedTo(usdBalance(token1))
- if (usdBalanceCompare) {
- return usdBalanceCompare
- }
-
- const balanceCompare = token2.balance.comparedTo(token1.balance)
- if (balanceCompare) {
- return balanceCompare
- }
-
- if (token1.isNative && !token2.isNative) {
- return -1
- }
- if (!token1.isNative && token2.isNative) {
- return 1
- }
-
- return token1.networkId.localeCompare(token2.networkId)
- }),
- [allTokens]
+ const tokens = useSelector((state) =>
+ tokensWithNonZeroBalanceAndShowZeroBalanceSelector(state, supportedNetworkIds)
)
const localCurrencySymbol = useSelector(getLocalCurrencySymbol)
diff --git a/src/tokens/selectors.test.ts b/src/tokens/selectors.test.ts
index 9ac561c1cc8..1549148570e 100644
--- a/src/tokens/selectors.test.ts
+++ b/src/tokens/selectors.test.ts
@@ -8,11 +8,13 @@ import {
tokensByUsdBalanceSelector,
tokensListSelector,
tokensListWithAddressSelector,
+ tokensWithNonZeroBalanceAndShowZeroBalanceSelector,
tokensWithUsdValueSelector,
totalTokenBalanceSelector,
} from 'src/tokens/selectors'
import { NetworkId } from 'src/transactions/types'
import { ONE_DAY_IN_MILLIS } from 'src/utils/time'
+import { mockEthTokenId } from 'test/values'
const mockDate = 1588200517518
@@ -49,6 +51,7 @@ const state: any = {
symbol: 'cUSD',
priceFetchedAt: mockDate,
isSwappable: true,
+ showZeroBalance: true,
},
['celo-alfajores:0xeur']: {
tokenId: 'celo-alfajores:0xeur',
@@ -118,6 +121,16 @@ const state: any = {
priceUsd: '500',
priceFetchedAt: mockDate - 2 * ONE_DAY_IN_MILLIS,
},
+ [mockEthTokenId]: {
+ name: 'Ether',
+ tokenId: mockEthTokenId,
+ networkId: NetworkId['ethereum-sepolia'],
+ balance: '0',
+ priceUsd: '500',
+ priceFetchedAt: mockDate - 2 * ONE_DAY_IN_MILLIS,
+ showZeroBalance: true,
+ isNative: true,
+ },
},
},
localCurrency: {
@@ -169,7 +182,7 @@ describe(tokensListSelector, () => {
NetworkId['celo-alfajores'],
NetworkId['ethereum-sepolia'],
])
- expect(tokens.length).toEqual(7)
+ expect(tokens.length).toEqual(8)
expect(tokens.find((t) => t.tokenId === 'celo-alfajores:0xusd')?.symbol).toEqual('cUSD')
expect(tokens.find((t) => t.tokenId === 'celo-alfajores:0xeur')?.symbol).toEqual('cEUR')
expect(tokens.find((t) => t.tokenId === 'celo-alfajores:0x4')?.symbol).toEqual('TT')
@@ -177,6 +190,7 @@ describe(tokensListSelector, () => {
expect(tokens.find((t) => t.tokenId === 'celo-alfajores:0x5')?.name).toEqual('0x5 token')
expect(tokens.find((t) => t.tokenId === 'celo-alfajores:0x6')?.name).toEqual('0x6 token')
expect(tokens.find((t) => t.tokenId === 'ethereum-sepolia:0x7')?.name).toEqual('0x7 token')
+ expect(tokens.find((t) => t.tokenId === mockEthTokenId)?.name).toEqual('Ether')
})
})
})
@@ -232,6 +246,7 @@ describe('tokensByUsdBalanceSelector', () => {
"networkId": "celo-alfajores",
"priceFetchedAt": 1588200517518,
"priceUsd": "1",
+ "showZeroBalance": true,
"symbol": "cUSD",
"tokenId": "celo-alfajores:0xusd",
},
@@ -366,6 +381,7 @@ describe(totalTokenBalanceSelector, () => {
"networkId": "celo-alfajores",
"priceFetchedAt": 1588200517518,
"priceUsd": "1",
+ "showZeroBalance": true,
"symbol": "cUSD",
"tokenId": "celo-alfajores:0xusd",
},
@@ -374,3 +390,36 @@ describe(totalTokenBalanceSelector, () => {
})
})
})
+
+describe('tokensWithNonZeroBalanceAndShowZeroBalanceSelector', () => {
+ it('returns expected tokens in the correct order', () => {
+ const tokens = tokensWithNonZeroBalanceAndShowZeroBalanceSelector(state, [
+ NetworkId['celo-alfajores'],
+ NetworkId['ethereum-sepolia'],
+ ])
+
+ expect(tokens.map((token) => token.tokenId)).toEqual([
+ 'celo-alfajores:0x1',
+ 'celo-alfajores:0xeur',
+ 'celo-alfajores:0x4',
+ 'celo-alfajores:0x5',
+ 'celo-alfajores:0x6',
+ 'ethereum-sepolia:0x7',
+ 'ethereum-sepolia:native',
+ 'celo-alfajores:0xusd',
+ ])
+ })
+ it('avoids unnecessary recomputation', () => {
+ const prevComputations = tokensWithNonZeroBalanceAndShowZeroBalanceSelector.recomputations()
+ const tokens = tokensWithNonZeroBalanceAndShowZeroBalanceSelector(state, [
+ NetworkId['celo-alfajores'],
+ ])
+ const tokens2 = tokensWithNonZeroBalanceAndShowZeroBalanceSelector(state, [
+ NetworkId['celo-alfajores'],
+ ])
+ expect(tokens).toEqual(tokens2)
+ expect(tokensWithNonZeroBalanceAndShowZeroBalanceSelector.recomputations()).toEqual(
+ prevComputations + 1
+ )
+ })
+})
diff --git a/src/tokens/selectors.ts b/src/tokens/selectors.ts
index 7b69002bea5..71f547d5f9b 100644
--- a/src/tokens/selectors.ts
+++ b/src/tokens/selectors.ts
@@ -23,6 +23,7 @@ import {
isCicoToken,
sortByUsdBalance,
sortFirstStableThenCeloThenOthersByUsdBalance,
+ usdBalance,
} from './utils'
type TokenBalanceWithPriceUsd = TokenBalance & {
@@ -378,5 +379,34 @@ export const cashOutTokensByNetworkIdSelector = createSelector(
)
)
+export const tokensWithNonZeroBalanceAndShowZeroBalanceSelector = createSelector(
+ (state: RootState, networkIds: NetworkId[]) => tokensListSelector(state, networkIds),
+ (tokens) =>
+ tokens
+ .filter((tokenInfo) => tokenInfo.balance.gt(TOKEN_MIN_AMOUNT) || tokenInfo.showZeroBalance)
+ .sort((token1, token2) => {
+ // Sorts by usd balance, then token balance, then zero balance natives by
+ // network id, then zero balance non natives by network id
+ const usdBalanceCompare = usdBalance(token2).comparedTo(usdBalance(token1))
+ if (usdBalanceCompare) {
+ return usdBalanceCompare
+ }
+
+ const balanceCompare = token2.balance.comparedTo(token1.balance)
+ if (balanceCompare) {
+ return balanceCompare
+ }
+
+ if (token1.isNative && !token2.isNative) {
+ return -1
+ }
+ if (!token1.isNative && token2.isNative) {
+ return 1
+ }
+
+ return token1.networkId.localeCompare(token2.networkId)
+ })
+)
+
export const visualizeNFTsEnabledInHomeAssetsPageSelector = (state: RootState) =>
state.app.visualizeNFTsEnabledInHomeAssetsPage
diff --git a/test/RootStateSchema.json b/test/RootStateSchema.json
index a6bd8da798e..3f8615824c7 100644
--- a/test/RootStateSchema.json
+++ b/test/RootStateSchema.json
@@ -919,14 +919,6 @@
],
"type": "string"
},
- "Currency": {
- "enum": [
- "cEUR",
- "cGLD",
- "cUSD"
- ],
- "type": "string"
- },
"Dapp": {
"additionalProperties": false,
"properties": {
@@ -2523,6 +2515,7 @@
"SendAmount",
"SendConfirmation",
"SendConfirmationModal",
+ "SendEnterAmount",
"SetUpKeylessBackup",
"Settings",
"SignInWithEmail",
@@ -3980,8 +3973,8 @@
"isSending": {
"type": "boolean"
},
- "lastUsedCurrency": {
- "$ref": "#/definitions/Currency"
+ "lastUsedTokenId": {
+ "type": "string"
},
"recentPayments": {
"items": {
@@ -4004,7 +3997,6 @@
"inviteRewardWeeklyLimit",
"inviteRewardsVersion",
"isSending",
- "lastUsedCurrency",
"recentPayments",
"recentRecipients",
"showSendToAddressWarning"
diff --git a/test/schemas.ts b/test/schemas.ts
index 1d8e5a807a3..79e2e6b58f5 100644
--- a/test/schemas.ts
+++ b/test/schemas.ts
@@ -2743,6 +2743,18 @@ export const v162Schema = {
},
}
+export const v163Schema = {
+ ...v161Schema,
+ _persist: {
+ ...v161Schema._persist,
+ version: 163,
+ },
+ send: {
+ ..._.omit(v161Schema.send, 'lastUsedCurrency'),
+ lastUsedTokenId: undefined,
+ },
+}
+
export function getLatestSchema(): Partial {
- return v162Schema as Partial
+ return v163Schema as Partial
}