diff --git a/locales/base/translation.json b/locales/base/translation.json index 83e6d5a30ea..d4a252eca64 100644 --- a/locales/base/translation.json +++ b/locales/base/translation.json @@ -1837,5 +1837,18 @@ "notificationCenterSpotlight": { "message": "Introducing a new way to claim rewards, view alerts, and see updates in one place", "cta": "Got it" + }, + "tokenDetails": { + "yourBalance": "Your balance", + "learnMore": "Learn more about {{tokenName}}", + "priceUnavailable": "Price Unavailable", + "priceDeltaSuffix": "Today", + "actions": { + "send": "Send", + "swap": "Swap", + "add": "Add", + "withdraw": "Withdraw", + "more": "More" + } } } diff --git a/src/analytics/Events.tsx b/src/analytics/Events.tsx index c392e2c8acf..4a7b9e0627c 100644 --- a/src/analytics/Events.tsx +++ b/src/analytics/Events.tsx @@ -615,6 +615,8 @@ export enum AssetsEvents { view_dapp_positions = 'view_dapp_positions', tap_asset = 'tap_asset', tap_claim_rewards = 'tap_claim_rewards', + tap_token_details_action = 'tap_token_details_action', + tap_token_details_learn_more = 'tap_token_details_learn_more', } export enum NftEvents { diff --git a/src/analytics/Properties.tsx b/src/analytics/Properties.tsx index dad53c7c46b..40764a13aa8 100644 --- a/src/analytics/Properties.tsx +++ b/src/analytics/Properties.tsx @@ -68,6 +68,7 @@ import { NotificationReceiveState } from 'src/notifications/types' import { AdventureCardName } from 'src/onboarding/types' import { RecipientType } from 'src/recipients/recipient' import { Field } from 'src/swap/types' +import { TokenDetailsActionName } from 'src/tokens/types' import { NetworkId } from 'src/transactions/types' import { AnalyticsCurrency, CiCoCurrency, Currency } from 'src/utils/currencies' import { Awaited } from 'src/utils/typescript' @@ -1320,6 +1321,14 @@ interface TokenBottomSheetEventsProperties { } } +export interface TokenProperties { + symbol: string + address: string | null + balanceUsd: number + networkId: NetworkId + tokenId: string +} + interface AssetsEventsProperties { [AssetsEvents.show_asset_balance_info]: undefined [AssetsEvents.view_wallet_assets]: undefined @@ -1342,16 +1351,16 @@ interface AssetsEventsProperties { description: string balanceUsd: number } - | { + | ({ assetType: 'token' - address?: string - networkId: NetworkId - tokenId: string title: string // Example: 'cUSD' description: string - balanceUsd: number - } + } & TokenProperties) [AssetsEvents.tap_claim_rewards]: undefined + [AssetsEvents.tap_token_details_action]: { + action: TokenDetailsActionName + } & TokenProperties + [AssetsEvents.tap_token_details_learn_more]: TokenProperties } interface NftsEventsProperties { diff --git a/src/analytics/docs.ts b/src/analytics/docs.ts index 7b52444af68..7ba46fac85b 100644 --- a/src/analytics/docs.ts +++ b/src/analytics/docs.ts @@ -528,6 +528,8 @@ export const eventDocs: Record = { [AssetsEvents.view_dapp_positions]: `When a user taps on the "Dapp Positions" segmented control or tab`, [AssetsEvents.tap_asset]: `When a user taps on an asset`, [AssetsEvents.tap_claim_rewards]: `When a user taps on the "Claim Rewards" button`, + [AssetsEvents.tap_token_details_action]: `When a user taps one of the actions on the token details screen`, + [AssetsEvents.tap_token_details_learn_more]: `When a user taps the learn more link on the token details screen`, [NftEvents.nft_error_screen_open]: `When the high level error screen is mounted`, [NftEvents.nft_media_load]: `When attempting to load NFT media`, [NftEvents.nft_gallery_screen_open]: `When the gallery screen is mounted`, diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 62b109a1502..d0b3d89293d 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -3,7 +3,7 @@ import React, { ReactNode, useCallback } from 'react' import { ActivityIndicator, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native' import Touchable from 'src/components/Touchable' import colors, { Colors } from 'src/styles/colors' -import fontStyles from 'src/styles/fonts' +import fontStyles, { typeScale } from 'src/styles/fonts' import { vibrateInformative } from 'src/styles/hapticFeedback' const BUTTON_TAP_DEBOUNCE_TIME = 300 // milliseconds @@ -42,6 +42,7 @@ export interface ButtonProps { testID?: string touchableStyle?: StyleProp iconMargin?: number + fontStyle?: typeof typeScale } export default React.memo(function Button(props: ButtonProps) { @@ -60,6 +61,7 @@ export default React.memo(function Button(props: ButtonProps) { loadingColor, touchableStyle, iconMargin = 4, + fontStyle = fontStyles.regular600, } = props // Debounce onPress event so that it is called once on trigger and @@ -103,7 +105,7 @@ export default React.memo(function Button(props: ButtonProps) { maxFontSizeMultiplier={1} accessibilityLabel={accessibilityLabel} style={{ - ...fontStyles.regular600, + ...fontStyle, color: textColor, marginLeft: icon && iconPositionLeft ? iconMargin : 0, marginRight: icon && !iconPositionLeft ? iconMargin : 0, diff --git a/src/config.ts b/src/config.ts index 92346afa730..a6547877ece 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,9 +74,6 @@ export const WALLET_BALANCE_UPPER_BOUND = new BigNumber('1e10') export const TIME_UNTIL_TOKEN_INFO_BECOMES_STALE = 12 * ONE_HOUR_IN_MILLIS -// The amount of time -export const TIME_OF_SUPPORTED_UNSYNC_HISTORICAL_PRICES = ONE_HOUR_IN_MILLIS - export const DEFAULT_FORNO_URL = DEFAULT_TESTNET === 'mainnet' ? 'https://forno.celo.org/' diff --git a/src/icons/ArrowRightThick.tsx b/src/icons/ArrowRightThick.tsx new file mode 100644 index 00000000000..f92b31a6553 --- /dev/null +++ b/src/icons/ArrowRightThick.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' +import Svg, { Path } from 'react-native-svg' +import colors from 'src/styles/colors' + +interface Props { + color?: colors +} + +const ArrowRightThick = ({ color = colors.gray3 }: Props) => ( + + + +) + +export default React.memo(ArrowRightThick) diff --git a/src/icons/DataDown.tsx b/src/icons/DataDown.tsx new file mode 100644 index 00000000000..a3573c00896 --- /dev/null +++ b/src/icons/DataDown.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' +import Svg, { Path } from 'react-native-svg' +import colors from 'src/styles/colors' + +interface Props { + color?: colors +} + +const DataDown = ({ color = colors.warning }: Props) => ( + + + +) + +export default React.memo(DataDown) diff --git a/src/icons/DataUp.tsx b/src/icons/DataUp.tsx new file mode 100644 index 00000000000..6b23d35b466 --- /dev/null +++ b/src/icons/DataUp.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' +import Svg, { Path } from 'react-native-svg' +import colors from 'src/styles/colors' + +interface Props { + color?: colors +} + +const DataUp = ({ color = colors.greenUI }: Props) => ( + + + +) + +export default React.memo(DataUp) diff --git a/src/icons/quick-actions/More.tsx b/src/icons/quick-actions/More.tsx new file mode 100644 index 00000000000..7d38fcf43e9 --- /dev/null +++ b/src/icons/quick-actions/More.tsx @@ -0,0 +1,19 @@ +import * as React from 'react' +import Svg, { Path } from 'react-native-svg' +import Colors from 'src/styles/colors' + +interface Props { + color: Colors +} + +const QuickActionsMore = ({ color }: Props) => ( + + + +) + +export default React.memo(QuickActionsMore) diff --git a/src/navigator/Navigator.tsx b/src/navigator/Navigator.tsx index 2caf0be1ee1..41974e5f6e9 100644 --- a/src/navigator/Navigator.tsx +++ b/src/navigator/Navigator.tsx @@ -112,6 +112,7 @@ import SwapReviewScreen from 'src/swap/SwapReviewScreen' import SwapScreen from 'src/swap/SwapScreen' import AssetsScreen from 'src/tokens/Assets' import TokenBalancesScreen from 'src/tokens/TokenBalances' +import TokenDetailsScreen from 'src/tokens/TokenDetails' import TransactionDetailsScreen from 'src/transactions/feed/TransactionDetailsScreen' import Logger from 'src/utils/Logger' import { ExtractProps } from 'src/utils/typescript' @@ -554,6 +555,11 @@ const assetScreens = (Navigator: typeof Stack) => ( component={AssetsScreen} options={AssetsScreen.navigationOptions} /> + ) diff --git a/src/navigator/Screens.tsx b/src/navigator/Screens.tsx index c96a016533f..79aff12d19e 100644 --- a/src/navigator/Screens.tsx +++ b/src/navigator/Screens.tsx @@ -85,6 +85,7 @@ export enum Screens { SwapExecuteScreen = 'SwapExecuteScreen', SwapReviewScreen = 'SwapReviewScreen', TokenBalances = 'TokenBalances', + TokenDetails = 'TokenDetails', TransactionDetailsScreen = 'TransactionDetailsScreen', UpgradeScreen = 'UpgradeScreen', ValidateRecipientAccount = 'ValidateRecipientAccount', diff --git a/src/navigator/types.tsx b/src/navigator/types.tsx index 7479f73a854..80cedade432 100644 --- a/src/navigator/types.tsx +++ b/src/navigator/types.tsx @@ -265,6 +265,7 @@ export type StackParamList = { [Screens.SwapExecuteScreen]: undefined [Screens.SwapReviewScreen]: undefined [Screens.SwapScreenWithBack]: { fromTokenId: string } | undefined + [Screens.TokenDetails]: { tokenId: string } [Screens.TransactionDetailsScreen]: { transaction: TokenTransaction } diff --git a/src/statsig/constants.ts b/src/statsig/constants.ts index ccfa6165614..0f83d044d73 100644 --- a/src/statsig/constants.ts +++ b/src/statsig/constants.ts @@ -94,6 +94,8 @@ export const DynamicConfigs = { defaultValues: { showCico: [networkConfig.defaultNetworkId], showBalances: [networkConfig.defaultNetworkId], + showSend: [networkConfig.defaultNetworkId], + showSwap: [networkConfig.defaultNetworkId], showTransfers: [networkConfig.defaultNetworkId], }, }, diff --git a/src/styles/fonts.tsx b/src/styles/fonts.tsx index d143e90c9bb..0c4e7b20cf2 100644 --- a/src/styles/fonts.tsx +++ b/src/styles/fonts.tsx @@ -81,7 +81,7 @@ export const typeScale = StyleSheet.create({ lineHeight: 28, }, labelLarge: { - fontFamily: Inter.Bold, + fontFamily: Inter.Medium, fontSize: 18, lineHeight: 28, }, diff --git a/src/tokens/AssetItem.tsx b/src/tokens/AssetItem.tsx index c1a4c20dd5e..b473bd5c303 100644 --- a/src/tokens/AssetItem.tsx +++ b/src/tokens/AssetItem.tsx @@ -4,17 +4,16 @@ import { Image, StyleSheet, Text, View } from 'react-native' import { TouchableWithoutFeedback } from 'react-native-gesture-handler' import { AssetsEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import LegacyTokenDisplay from 'src/components/LegacyTokenDisplay' import PercentageIndicator from 'src/components/PercentageIndicator' import TokenDisplay from 'src/components/TokenDisplay' -import LegacyTokenDisplay from 'src/components/LegacyTokenDisplay' -import { TIME_OF_SUPPORTED_UNSYNC_HISTORICAL_PRICES } from 'src/config' import { Position } from 'src/positions/types' import Colors from 'src/styles/colors' import fontStyles from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import { TokenBalance } from 'src/tokens/slice' +import { isHistoricalPriceUpdated } from 'src/tokens/utils' import { Currency } from 'src/utils/currencies' -import { ONE_DAY_IN_MILLIS } from 'src/utils/time' export const PositionItem = ({ position }: { position: Position }) => { const balanceInDecimal = @@ -85,14 +84,6 @@ export const TokenBalanceItem = ({ token: TokenBalance showPriceChangeIndicatorInBalances: boolean }) => { - const isHistoricalPriceUpdated = () => { - return ( - token.historicalPricesUsd?.lastDay && - TIME_OF_SUPPORTED_UNSYNC_HISTORICAL_PRICES > - Math.abs(token.historicalPricesUsd.lastDay.at - (Date.now() - ONE_DAY_IN_MILLIS)) - ) - } - const onPress = () => { ValoraAnalytics.track(AssetsEvents.tap_asset, { assetType: 'token', @@ -130,7 +121,7 @@ export const TokenBalanceItem = ({ {showPriceChangeIndicatorInBalances && token.historicalPricesUsd && - isHistoricalPriceUpdated() && ( + isHistoricalPriceUpdated(token) && ( { + beforeEach(() => { + jest.clearAllMocks() + }) + it('renders tokens and collectibles tabs when positions is disabled', () => { jest.mocked(getFeatureGate).mockReturnValue(false) const store = createMockStore(storeWithPositions) @@ -198,6 +203,24 @@ describe('AssetsScreen', () => { expect(queryAllByTestId('PositionItem')).toHaveLength(0) }) + it('clicking a token navigates to the token details screen and fires analytics event', () => { + jest.mocked(getFeatureGate).mockReturnValue(false) + const store = createMockStore(storeWithPositions) + + const { getAllByTestId } = render( + + + + ) + + expect(getAllByTestId('TokenBalanceItem')).toHaveLength(2) + + fireEvent.press(getAllByTestId('TokenBalanceItem')[0]) + expect(navigate).toHaveBeenCalledTimes(1) + expect(navigate).toHaveBeenCalledWith(Screens.TokenDetails, { tokenId: mockCusdTokenId }) + expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1) + }) + it('hides claim rewards if feature gate is false', () => { jest .mocked(getFeatureGate) diff --git a/src/tokens/Assets.tsx b/src/tokens/Assets.tsx index 9ca2032956a..4fad8a2271b 100644 --- a/src/tokens/Assets.tsx +++ b/src/tokens/Assets.tsx @@ -52,7 +52,11 @@ import { useTotalTokenBalance, } from 'src/tokens/hooks' import { TokenBalance } from 'src/tokens/slice' -import { getSupportedNetworkIdsForTokenBalances, sortByUsdBalance } from 'src/tokens/utils' +import { + getSupportedNetworkIdsForTokenBalances, + getTokenAnalyticsProps, + sortByUsdBalance, +} from 'src/tokens/utils' const DEVICE_WIDTH_BREAKPOINT = 340 @@ -279,7 +283,20 @@ function AssetsScreen({ navigation, route }: Props) { if (assetIsPosition(item)) { return } - return + return ( + { + navigate(Screens.TokenDetails, { tokenId: item.tokenId }) + ValoraAnalytics.track(AssetsEvents.tap_asset, { + ...getTokenAnalyticsProps(item), + title: item.symbol, + description: item.name, + assetType: 'token', + }) + }} + /> + ) } const tabBarItems = useMemo(() => { diff --git a/src/tokens/TokenDetails.test.tsx b/src/tokens/TokenDetails.test.tsx new file mode 100644 index 00000000000..8ad8c322aba --- /dev/null +++ b/src/tokens/TokenDetails.test.tsx @@ -0,0 +1,299 @@ +import { fireEvent, render } from '@testing-library/react-native' +import React from 'react' +import { Provider } from 'react-redux' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import TokenDetailsScreen from 'src/tokens/TokenDetails' +import { ONE_DAY_IN_MILLIS } from 'src/utils/time' +import MockedNavigator from 'test/MockedNavigator' +import { createMockStore } from 'test/utils' +import { mockCeloTokenId, mockPoofTokenId, mockTokenBalances } from 'test/values' + +jest.mock('src/statsig', () => ({ + getDynamicConfigParams: jest.fn(() => { + return { + showCico: ['celo-alfajores'], + showSend: ['celo-alfajores'], + showSwap: ['celo-alfajores'], + } + }), +})) + +describe('TokenDetails', () => { + it('renders title, balance and token balance item', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: mockTokenBalances[mockPoofTokenId], + }, + }, + }) + + const { getByTestId, getByText, queryByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/TitleImage')).toBeTruthy() + expect(getByTestId('TokenDetails/Title')).toHaveTextContent('Poof Governance Token') + expect(getByTestId('TokenDetails/Balance')).toHaveTextContent('₱0.67') + expect(getByText('tokenDetails.yourBalance')).toBeTruthy() + expect(getByTestId('TokenBalanceItem')).toBeTruthy() + expect(queryByTestId('TokenDetails/LearnMore')).toBeFalsy() + }) + + it('renders learn more if token has infoUrl', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: { + ...mockTokenBalances[mockPoofTokenId], + infoUrl: 'https://poofToken', + }, + }, + }, + }) + + const { getByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/LearnMore')).toBeTruthy() + fireEvent.press(getByTestId('TokenDetails/LearnMore')) + expect(navigate).toHaveBeenCalledWith(Screens.WebViewScreen, { uri: 'https://poofToken' }) + }) + + it('renders price unavailable if token price is not present', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: { + ...mockTokenBalances[mockPoofTokenId], + priceUsd: undefined, + }, + }, + }, + }) + + const { queryByTestId, getByText } = render( + + + + ) + + expect(queryByTestId('TokenDetails/PriceDelta')).toBeFalsy() + expect(getByText('tokenDetails.priceUnavailable')).toBeTruthy() + }) + + it('renders no price info if historical price info is not available', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: mockTokenBalances[mockPoofTokenId], + }, + }, + }) + + const { queryByTestId, queryByText } = render( + + + + ) + + expect(queryByTestId('TokenDetails/PriceDelta')).toBeFalsy() + expect(queryByText('tokenDetails.priceUnavailable')).toBeFalsy() + }) + + it('renders no price info if historical price info is out of date', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: { + ...mockTokenBalances[mockPoofTokenId], + historicalPricesUsd: { + lastDay: { + at: Date.now() - 2 * ONE_DAY_IN_MILLIS, + price: 1, + }, + }, + }, + }, + }, + }) + + const { queryByTestId, queryByText } = render( + + + + ) + + expect(queryByTestId('TokenDetails/PriceDelta')).toBeFalsy() + expect(queryByText('tokenDetails.priceUnavailable')).toBeFalsy() + }) + + it('renders price delta if historical price is available and one day old', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: { + ...mockTokenBalances[mockPoofTokenId], + historicalPricesUsd: { + lastDay: { + at: Date.now() - ONE_DAY_IN_MILLIS, + price: 1, + }, + }, + }, + }, + }, + }) + + const { getByTestId, queryByText } = render( + + + + ) + + expect(getByTestId('TokenDetails/PriceDelta')).toBeTruthy() + expect(queryByText('tokenDetails.priceUnavailable')).toBeFalsy() + }) + + it('renders send action only if token has balance, is not swappable and not a CICO token', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: mockTokenBalances[mockPoofTokenId], + }, + }, + app: { + showSwapMenuInDrawerMenu: true, + }, + }) + + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/Action/Send')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Swap')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/Add')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/Withdraw')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/More')).toBeFalsy() + }) + + it('renders send and swap action only if token has balance, is swappable and not a CICO token', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: { ...mockTokenBalances[mockPoofTokenId], isSwappable: true }, + }, + }, + app: { + showSwapMenuInDrawerMenu: true, + }, + }) + + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/Action/Send')).toBeTruthy() + expect(getByTestId('TokenDetails/Action/Swap')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Add')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/Withdraw')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/More')).toBeFalsy() + }) + + it('renders send, swap and more if token has balance, is swappable and a CICO token', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockCeloTokenId]: { + ...mockTokenBalances[mockCeloTokenId], + balance: '10', + isSwappable: true, + }, + }, + }, + app: { + showSwapMenuInDrawerMenu: true, + }, + }) + + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/Action/Send')).toBeTruthy() + expect(getByTestId('TokenDetails/Action/Swap')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Add')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/Withdraw')).toBeFalsy() + expect(getByTestId('TokenDetails/Action/More')).toBeTruthy() + }) + + it('renders add only for CICO token with 0 balance', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockCeloTokenId]: { + ...mockTokenBalances[mockCeloTokenId], + isSwappable: true, + }, + }, + }, + app: { + showSwapMenuInDrawerMenu: true, + }, + }) + + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(queryByTestId('TokenDetails/Action/Send')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/Swap')).toBeFalsy() + expect(getByTestId('TokenDetails/Action/Add')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Withdraw')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/More')).toBeFalsy() + }) + + it('hides swap action and shows more action if token is swappable, has balance and CICO token but swapfeature gate is false', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockCeloTokenId]: { + ...mockTokenBalances[mockCeloTokenId], + balance: '10', + isSwappable: true, + }, + }, + }, + app: { + showSwapMenuInDrawerMenu: false, + }, + }) + + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/Action/Send')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Swap')).toBeFalsy() + expect(getByTestId('TokenDetails/Action/Add')).toBeTruthy() + expect(getByTestId('TokenDetails/Action/More')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Withdraw')).toBeFalsy() + }) +}) diff --git a/src/tokens/TokenDetails.tsx b/src/tokens/TokenDetails.tsx new file mode 100644 index 00000000000..1fe42376edc --- /dev/null +++ b/src/tokens/TokenDetails.tsx @@ -0,0 +1,341 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { ScrollView, StyleSheet, Text, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useSelector } from 'react-redux' +import { AssetsEvents } from 'src/analytics/Events' +import { TokenProperties } from 'src/analytics/Properties' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import Button, { BtnSizes } from 'src/components/Button' +import PercentageIndicator from 'src/components/PercentageIndicator' +import TokenDisplay from 'src/components/TokenDisplay' +import TokenIcon, { IconSize } from 'src/components/TokenIcon' +import Touchable from 'src/components/Touchable' +import { TOKEN_MIN_AMOUNT } from 'src/config' +import { CICOFlow } from 'src/fiatExchanges/utils' +import ArrowRightThick from 'src/icons/ArrowRightThick' +import DataDown from 'src/icons/DataDown' +import DataUp from 'src/icons/DataUp' +import QuickActionsAdd from 'src/icons/quick-actions/Add' +import QuickActionsMore from 'src/icons/quick-actions/More' +import QuickActionsSend from 'src/icons/quick-actions/Send' +import QuickActionsSwap from 'src/icons/quick-actions/Swap' +import QuickActionsWithdraw from 'src/icons/quick-actions/Withdraw' +import { headerWithBackButton } from 'src/navigator/Headers' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import { isAppSwapsEnabledSelector } from 'src/navigator/selectors' +import { StackParamList } from 'src/navigator/types' +import Colors from 'src/styles/colors' +import { typeScale } from 'src/styles/fonts' +import { Spacing } from 'src/styles/styles' +import { TokenBalanceItem } from 'src/tokens/TokenBalanceItem' +import { + useCashInTokens, + useCashOutTokens, + useSendableTokens, + useSwappableTokens, + useTokenInfo, +} from 'src/tokens/hooks' +import { TokenBalance } from 'src/tokens/slice' +import { TokenDetailsActionName } from 'src/tokens/types' +import { getTokenAnalyticsProps, isCicoToken, isHistoricalPriceUpdated } from 'src/tokens/utils' +import { Network } from 'src/transactions/types' + +type Props = NativeStackScreenProps + +const MAX_ACTION_BUTTONS = 3 + +export default function TokenDetailsScreen({ route }: Props) { + const { tokenId } = route.params + const { t } = useTranslation() + const token = useTokenInfo(tokenId) + + if (!token) { + throw new Error(`token with id ${tokenId} not found`) + } + + return ( + + + + + + {token.name} + + + + {!token.isStableCoin && } + + {t('tokenDetails.yourBalance')} + + {token.infoUrl && ( + + )} + + + ) +} + +TokenDetailsScreen.navigationOptions = { + ...headerWithBackButton, +} + +function PriceInfo({ token }: { token: TokenBalance }) { + const { t } = useTranslation() + if (!token.priceUsd) { + return ( + + {t('tokenDetails.priceUnavailable')} + + ) + } + + if (!token.historicalPricesUsd || !isHistoricalPriceUpdated(token)) { + return null + } + + return ( + + + + ) +} + +function Actions({ token }: { token: TokenBalance }) { + const { t } = useTranslation() + const sendableTokens = useSendableTokens() + const swappableTokens = useSwappableTokens() + const cashInTokens = useCashInTokens() + const cashOutTokens = useCashOutTokens() + const isSwapEnabled = useSelector(isAppSwapsEnabledSelector) + const showWithdraw = !!cashOutTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId) + + const onPressCicoAction = (flow: CICOFlow) => { + const tokenSymbol = token.symbol + // this should always be true given that we only show Add / Withdraw if a + // token is CiCoCurrency, but adding it here to ensure type safety + if (isCicoToken(tokenSymbol)) { + navigate(Screens.FiatExchangeAmount, { + currency: tokenSymbol, + flow, + network: Network.Celo, + }) + } + } + + const actions = [ + { + name: TokenDetailsActionName.Send, + text: t('tokenDetails.actions.send'), + iconComponent: QuickActionsSend, + onPress: () => { + // TODO: this should change to passing tokenId when #4242 is merged + navigate(Screens.Send, { defaultTokenOverride: token.address! }) + }, + visible: !!sendableTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId), + }, + { + name: TokenDetailsActionName.Swap, + text: t('tokenDetails.actions.swap'), + iconComponent: QuickActionsSwap, + onPress: () => { + navigate(Screens.SwapScreenWithBack, { fromTokenId: token.tokenId }) + }, + visible: + isSwapEnabled && + !!swappableTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId) && + token.balance.gt(TOKEN_MIN_AMOUNT), + }, + { + name: TokenDetailsActionName.Add, + text: t('tokenDetails.actions.add'), + iconComponent: QuickActionsAdd, + onPress: () => { + onPressCicoAction(CICOFlow.CashIn) + }, + visible: !!cashInTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId), + }, + { + name: TokenDetailsActionName.Withdraw, + text: t('tokenDetails.actions.withdraw'), + iconComponent: QuickActionsWithdraw, + onPress: () => { + onPressCicoAction(CICOFlow.CashOut) + }, + visible: showWithdraw, + }, + ].filter((action) => action.visible) + + const moreAction = { + name: TokenDetailsActionName.More, + text: t('tokenDetails.actions.more'), + iconComponent: QuickActionsMore, + onPress: () => { + // TODO(ACT-917): open bottom sheet + }, + } + + // if there are 4 actions or 3 actions and one of them is withdraw, show the + // More button. The withdraw condition exists to avoid the visual overflow, + // since the icon + withdraw text is bigger + const actionButtons = + actions.length > MAX_ACTION_BUTTONS || (actions.length === MAX_ACTION_BUTTONS && showWithdraw) + ? [...actions.slice(0, MAX_ACTION_BUTTONS - 1), moreAction] + : actions + + return ( + + {actionButtons.map((action) => ( +