diff --git a/src/navigator/types.tsx b/src/navigator/types.tsx
index 3ba14475470..2816b84a12c 100644
--- a/src/navigator/types.tsx
+++ b/src/navigator/types.tsx
@@ -13,7 +13,7 @@ import { Screens } from 'src/navigator/Screens'
import { Nft } from 'src/nfts/types'
import { Recipient } from 'src/recipients/recipient'
import { QrCode, TransactionDataInput } from 'src/send/types'
-import { AssetTabType } from 'src/tokens/Assets'
+import { AssetTabType } from 'src/tokens/types'
import { NetworkId, TokenTransaction } from 'src/transactions/types'
import { Currency } from 'src/utils/currencies'
import { SerializableTransactionRequest } from 'src/viem/preparedTransactionSerialization'
diff --git a/src/tokens/AssetList.test.tsx b/src/tokens/AssetList.test.tsx
new file mode 100644
index 00000000000..e57f7768cff
--- /dev/null
+++ b/src/tokens/AssetList.test.tsx
@@ -0,0 +1,237 @@
+import { fireEvent, render } from '@testing-library/react-native'
+import * as React from 'react'
+import { Provider } from 'react-redux'
+import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
+import { navigate } from 'src/navigator/NavigationService'
+import { Screens } from 'src/navigator/Screens'
+import { fetchNfts } from 'src/nfts/slice'
+import { getFeatureGate } from 'src/statsig'
+import AssetList from 'src/tokens/AssetList'
+import { AssetTabType } from 'src/tokens/types'
+import { NetworkId } from 'src/transactions/types'
+import { createMockStore } from 'test/utils'
+import {
+ mockEthTokenId,
+ mockNftAllFields,
+ mockNftMinimumFields,
+ mockNftNullMetadata,
+ mockPoofTokenId,
+ mockPositions,
+ mockTokenBalances,
+} from 'test/values'
+
+jest.mock('src/statsig', () => {
+ return {
+ getFeatureGate: jest.fn(),
+ getDynamicConfigParams: jest.fn(() => ({
+ showBalances: ['celo-alfajores', 'ethereum-sepolia'],
+ })),
+ }
+})
+
+const storeWithAssets = {
+ tokens: {
+ tokenBalances: {
+ ...mockTokenBalances,
+ [mockEthTokenId]: {
+ tokenId: mockEthTokenId,
+ balance: '0',
+ priceUsd: '5',
+ networkId: NetworkId['ethereum-sepolia'],
+ showZeroBalance: true,
+ isNative: true,
+ symbol: 'ETH',
+ },
+ ['token1']: {
+ tokenId: 'token1',
+ networkId: NetworkId['celo-alfajores'],
+ balance: '10',
+ symbol: 'TK1',
+ },
+ ['token2']: {
+ tokenId: 'token2',
+ networkId: NetworkId['celo-alfajores'],
+ balance: '0',
+ symbol: 'TK2',
+ },
+ ['token3']: {
+ tokenId: 'token3',
+ networkId: NetworkId['ethereum-sepolia'],
+ balance: '20',
+ symbol: 'TK3',
+ },
+ },
+ },
+ positions: {
+ positions: mockPositions,
+ },
+ nfts: {
+ nfts: [
+ { ...mockNftAllFields, networkId: NetworkId['celo-alfajores'] },
+ { ...mockNftMinimumFields, networkId: NetworkId['ethereum-sepolia'] },
+ { ...mockNftNullMetadata, networkId: NetworkId['celo-alfajores'] },
+ ],
+ nftsLoading: false,
+ nftsError: null,
+ },
+}
+
+describe('AssetList', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ jest.mocked(getFeatureGate).mockReturnValue(true)
+ })
+
+ it('renders tokens in the expected order', () => {
+ const store = createMockStore(storeWithAssets)
+
+ const { getAllByTestId, queryAllByTestId } = render(
+
+
+
+ )
+
+ expect(getAllByTestId('TokenBalanceItem')).toHaveLength(6)
+ expect(queryAllByTestId('PositionItem')).toHaveLength(0)
+ expect(queryAllByTestId('NftItem')).toHaveLength(0)
+ ;['POOF', 'TK3', 'TK1', 'CELO', 'ETH', 'cUSD'].map((symbol, index) => {
+ expect(getAllByTestId('TokenBalanceItem')[index]).toHaveTextContent(symbol)
+ })
+ })
+
+ it('renders collectibles', () => {
+ const store = createMockStore(storeWithAssets)
+
+ const { queryAllByTestId } = render(
+
+
+
+ )
+
+ expect(queryAllByTestId('TokenBalanceItem')).toHaveLength(0)
+ expect(queryAllByTestId('PositionItem')).toHaveLength(0)
+ expect(queryAllByTestId('NftItem')).toHaveLength(2)
+ })
+
+ it('renders collectibles error', () => {
+ const store = createMockStore({
+ nfts: {
+ nftsLoading: false,
+ nfts: [],
+ nftsError: 'Error fetching nfts',
+ },
+ })
+
+ const { getByTestId } = render(
+
+
+
+ )
+
+ expect(getByTestId('Assets/NftsLoadError')).toBeTruthy()
+ })
+
+ it('renders no collectables text', () => {
+ const store = createMockStore({
+ nfts: {
+ nftsLoading: false,
+ nfts: [],
+ nftsError: null,
+ },
+ })
+
+ const { getByText } = render(
+
+
+
+ )
+
+ expect(getByText('nftGallery.noNfts')).toBeTruthy()
+ })
+
+ it('renders dapp positions', () => {
+ const store = createMockStore(storeWithAssets)
+
+ const { getAllByTestId, queryAllByTestId } = render(
+
+
+
+ )
+
+ expect(getAllByTestId('PositionItem')).toHaveLength(3)
+ expect(queryAllByTestId('TokenBalanceItem')).toHaveLength(0)
+ expect(queryAllByTestId('NftItem')).toHaveLength(0)
+ })
+
+ it('clicking a token navigates to the token details screen and fires analytics event', () => {
+ const store = createMockStore(storeWithAssets)
+
+ const { getAllByTestId } = render(
+
+
+
+ )
+
+ expect(getAllByTestId('TokenBalanceItem')).toHaveLength(6)
+
+ fireEvent.press(getAllByTestId('TokenBalanceItem')[0])
+ expect(navigate).toHaveBeenCalledTimes(1)
+ expect(navigate).toHaveBeenCalledWith(Screens.TokenDetails, { tokenId: mockPoofTokenId })
+ expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1)
+ })
+
+ it('clicking an NFT navigates to the nfts info screen', async () => {
+ const store = createMockStore(storeWithAssets)
+
+ const { getAllByTestId } = render(
+
+
+
+ )
+
+ expect(getAllByTestId('NftItem')).toHaveLength(2)
+
+ fireEvent.press(getAllByTestId('NftGallery/NftImage')[0])
+ fireEvent.press(getAllByTestId('NftGallery/NftImage')[1])
+ expect(navigate).toHaveBeenCalledTimes(2)
+ expect(navigate).toHaveBeenCalledWith(Screens.NftsInfoCarousel, {
+ nfts: [{ ...mockNftAllFields, networkId: NetworkId['celo-alfajores'] }],
+ networkId: NetworkId['celo-alfajores'],
+ })
+ expect(navigate).toHaveBeenCalledWith(Screens.NftsInfoCarousel, {
+ nfts: [{ ...mockNftMinimumFields, networkId: NetworkId['ethereum-sepolia'] }],
+ networkId: NetworkId['ethereum-sepolia'],
+ })
+ })
+
+ it('dispatches action to fetch nfts on load', () => {
+ const store = createMockStore(storeWithAssets)
+
+ render(
+
+
+
+ )
+ expect(store.getActions()).toEqual([fetchNfts()])
+ })
+})
diff --git a/src/tokens/AssetList.tsx b/src/tokens/AssetList.tsx
new file mode 100644
index 00000000000..0b6a7a7a2aa
--- /dev/null
+++ b/src/tokens/AssetList.tsx
@@ -0,0 +1,337 @@
+import React, { useEffect, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ SectionList,
+ SectionListData,
+ SectionListProps,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native'
+import Animated from 'react-native-reanimated'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { AssetsEvents } from 'src/analytics/Events'
+import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
+import Touchable from 'src/components/Touchable'
+import ImageErrorIcon from 'src/icons/ImageErrorIcon'
+import { navigate } from 'src/navigator/NavigationService'
+import { Screens } from 'src/navigator/Screens'
+import NftMedia from 'src/nfts/NftMedia'
+import NftsLoadError from 'src/nfts/NftsLoadError'
+import {
+ nftsErrorSelector,
+ nftsLoadingSelector,
+ nftsWithMetadataSelector,
+} from 'src/nfts/selectors'
+import { fetchNfts } from 'src/nfts/slice'
+import { NftOrigin, NftWithNetworkId } from 'src/nfts/types'
+import { positionsSelector } from 'src/positions/selectors'
+import { Position } from 'src/positions/types'
+import { useDispatch, useSelector } from 'src/redux/hooks'
+import Colors from 'src/styles/colors'
+import { typeScale } from 'src/styles/fonts'
+import { Spacing } from 'src/styles/styles'
+import variables from 'src/styles/variables'
+import { PositionItem } from 'src/tokens/PositionItem'
+import { TokenBalanceItem } from 'src/tokens/TokenBalanceItem'
+import { sortedTokensWithBalanceOrShowZeroBalanceSelector } from 'src/tokens/selectors'
+import { TokenBalance } from 'src/tokens/slice'
+import { AssetTabType } from 'src/tokens/types'
+import { getSupportedNetworkIdsForTokenBalances, getTokenAnalyticsProps } from 'src/tokens/utils'
+
+interface SectionData {
+ appName?: string
+}
+
+const AnimatedSectionList =
+ Animated.createAnimatedComponent<
+ SectionListProps
+ >(SectionList)
+
+const assetIsPosition = (asset: Position | TokenBalance | NftWithNetworkId[]): asset is Position =>
+ 'type' in asset && (asset.type === 'app-token' || asset.type === 'contract-position')
+
+/**
+ * Helper function to group an array into chunks of size n
+ * Used with Nfts to group them for use in the section list
+ */
+const groupArrayByN = (arr: any[], n: number) => {
+ return arr.reduce((result, item, index) => {
+ if (index % n === 0) {
+ result.push([item])
+ } else {
+ result[Math.floor(index / n)].push(item)
+ }
+ return result
+ }, [])
+}
+
+const NUM_OF_NFTS_PER_ROW = 2
+
+const nftImageSize =
+ (variables.width - Spacing.Thick24 * 2 - Spacing.Regular16 * (NUM_OF_NFTS_PER_ROW - 1)) /
+ NUM_OF_NFTS_PER_ROW
+
+export default function AssetList({
+ activeTab,
+ listHeaderHeight,
+ handleScroll,
+}: {
+ activeTab: AssetTabType
+ listHeaderHeight: number
+ handleScroll: (event: NativeSyntheticEvent) => void
+}) {
+ const dispatch = useDispatch()
+ const { t } = useTranslation()
+ const insets = useSafeAreaInsets()
+
+ const supportedNetworkIds = getSupportedNetworkIdsForTokenBalances()
+ const tokens = useSelector((state) =>
+ sortedTokensWithBalanceOrShowZeroBalanceSelector(state, supportedNetworkIds)
+ )
+
+ const positions = useSelector(positionsSelector)
+ const positionSections = useMemo(() => {
+ const positionsByDapp = new Map()
+ positions.forEach((position) => {
+ if (positionsByDapp.has(position.appName)) {
+ positionsByDapp.get(position.appName)?.push(position)
+ } else {
+ positionsByDapp.set(position.appName, [position])
+ }
+ })
+
+ const sections: SectionListData[] =
+ []
+ positionsByDapp.forEach((positions, appName) => {
+ sections.push({
+ data: positions,
+ appName,
+ })
+ })
+ return sections
+ }, [positions])
+
+ // NFT Selectors
+ const nftsError = useSelector(nftsErrorSelector)
+ const nftsLoading = useSelector(nftsLoadingSelector)
+ const nfts = useSelector(nftsWithMetadataSelector)
+ // Group nfts for use in the section list
+ const nftsGrouped = groupArrayByN(nfts, NUM_OF_NFTS_PER_ROW)
+
+ useEffect(() => {
+ dispatch(fetchNfts())
+ }, [])
+
+ const sections =
+ activeTab === AssetTabType.Tokens
+ ? [{ data: tokens }]
+ : activeTab === AssetTabType.Positions
+ ? positionSections
+ : nfts.length
+ ? [{ data: nftsGrouped }]
+ : []
+
+ const renderSectionHeader = ({
+ section,
+ }: {
+ section: SectionListData
+ }) => {
+ if (section.appName) {
+ return (
+
+
+ {section.appName.toLocaleUpperCase()}
+
+
+ )
+ }
+ return null
+ }
+
+ const keyExtractor = (item: TokenBalance | Position | NftWithNetworkId[], index: number) => {
+ if (assetIsPosition(item)) {
+ // Ideally we wouldn't need the index here, but we need to differentiate
+ // between positions with the same address (e.g. Uniswap V3 pool NFTs)
+ // We may want to consider adding a unique identifier to the position type.
+ return `${activeTab}-${item.appId}-${item.network}-${item.address}-${index}`
+ } else if ('balance' in item) {
+ return `${activeTab}-${item.tokenId}`
+ } else {
+ return `${activeTab}-${item[0]!.networkId}-${item[0]!.contractAddress}-${item[0]!.tokenId}`
+ }
+ }
+
+ const NftItem = ({ item }: { item: NftWithNetworkId }) => {
+ return (
+
+
+ navigate(Screens.NftsInfoCarousel, { nfts: [item], networkId: item.networkId })
+ }
+ style={styles.nftsTouchableIcon}
+ >
+
+
+ {item.metadata?.name && (
+
+ {item.metadata.name}
+
+ )}
+
+ }
+ origin={NftOrigin.Assets}
+ borderRadius={Spacing.Regular16}
+ mediaType="image"
+ />
+
+
+ )
+ }
+
+ const NftGroup = ({ item }: { item: NftWithNetworkId[] }) => {
+ return (
+
+ {item.map((nft, index) => (
+
+ ))}
+
+ )
+ }
+
+ const renderAssetItem = ({
+ item,
+ index,
+ }: {
+ item: TokenBalance | Position | NftWithNetworkId[]
+ index: number
+ }) => {
+ if (assetIsPosition(item)) {
+ return
+ } else if ('balance' in item) {
+ return (
+ {
+ navigate(Screens.TokenDetails, { tokenId: item.tokenId })
+ ValoraAnalytics.track(AssetsEvents.tap_asset, {
+ ...getTokenAnalyticsProps(item),
+ title: item.symbol,
+ description: item.name,
+ assetType: 'token',
+ })
+ }}
+ />
+ )
+ } else {
+ return
+ }
+ }
+
+ const renderEmptyState = () => {
+ switch (activeTab) {
+ case AssetTabType.Tokens:
+ case AssetTabType.Positions:
+ return null
+ case AssetTabType.Collectibles:
+ if (nftsError) return
+ else if (nftsLoading) return null
+ else
+ return (
+
+ {t('nftGallery.noNfts')}
+
+ )
+ }
+ }
+
+ return (
+ 0 ? 1 : 0,
+ },
+ activeTab === AssetTabType.Collectibles &&
+ !nftsError &&
+ nfts.length > 0 &&
+ styles.nftsContentContainer,
+ ]}
+ // ensure header is above the scrollbar on ios overscroll
+ scrollIndicatorInsets={{ top: listHeaderHeight }}
+ // @ts-ignore can't get the SectionList to accept a union type :(
+ sections={sections}
+ renderItem={renderAssetItem}
+ renderSectionHeader={renderSectionHeader}
+ keyExtractor={keyExtractor}
+ onScroll={handleScroll}
+ scrollEventThrottle={16}
+ ItemSeparatorComponent={() =>
+ activeTab === AssetTabType.Collectibles ? (
+
+ ) : null
+ }
+ ListHeaderComponent={}
+ ListEmptyComponent={renderEmptyState}
+ />
+ )
+}
+
+const styles = StyleSheet.create({
+ positionSectionHeaderContainer: {
+ padding: Spacing.Thick24,
+ paddingTop: Spacing.Regular16,
+ },
+ positionSectionHeaderText: {
+ ...typeScale.labelXXSmall,
+ color: Colors.gray5,
+ },
+ nftsPairingContainer: {
+ flexDirection: 'row',
+ gap: Spacing.Regular16,
+ },
+ nftsContentContainer: {
+ alignItems: 'flex-start',
+ paddingHorizontal: Spacing.Thick24,
+ },
+ nftsErrorView: {
+ width: nftImageSize,
+ height: nftImageSize,
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: Colors.gray2,
+ borderRadius: Spacing.Regular16,
+ },
+ nftsNoMetadataText: {
+ ...typeScale.labelSmall,
+ textAlign: 'center',
+ },
+ nftsTouchableContainer: {
+ overflow: 'hidden',
+ borderRadius: Spacing.Regular16,
+ },
+ nftsTouchableIcon: {
+ borderRadius: Spacing.Regular16,
+ },
+ noNftsText: {
+ ...typeScale.bodySmall,
+ color: Colors.gray3,
+ textAlign: 'center',
+ },
+ noNftsTextContainer: {
+ paddingHorizontal: Spacing.Thick24,
+ },
+})
diff --git a/src/tokens/AssetTabBar.test.tsx b/src/tokens/AssetTabBar.test.tsx
new file mode 100644
index 00000000000..bf0651ea0d2
--- /dev/null
+++ b/src/tokens/AssetTabBar.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render } from '@testing-library/react-native'
+import React from 'react'
+import { AssetsEvents } from 'src/analytics/Events'
+import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
+import Colors from 'src/styles/colors'
+import AssetTabBar from 'src/tokens/AssetTabBar'
+import { AssetTabType } from 'src/tokens/types'
+
+describe('AssetTabBar', () => {
+ const onChange = jest.fn()
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('renders all items if positions is enabled', () => {
+ const { getAllByTestId } = render(
+
+ )
+
+ const tabItems = getAllByTestId('Assets/TabBarItem')
+ expect(tabItems).toHaveLength(3)
+ expect(tabItems[0]).toHaveTextContent('tokens')
+ expect(tabItems[0].children[0]).toHaveStyle({ color: Colors.black })
+ expect(tabItems[1]).toHaveTextContent('collectibles')
+ expect(tabItems[1].children[0]).toHaveStyle({ color: Colors.gray4 })
+ expect(tabItems[2]).toHaveTextContent('dappPositions')
+ expect(tabItems[2].children[0]).toHaveStyle({ color: Colors.gray4 })
+ })
+
+ it('does not render positions if disabled', () => {
+ const { getAllByTestId } = render(
+
+ )
+
+ const tabItems = getAllByTestId('Assets/TabBarItem')
+ expect(tabItems).toHaveLength(2)
+ expect(tabItems[0]).toHaveTextContent('tokens')
+ expect(tabItems[0].children[0]).toHaveStyle({ color: Colors.gray4 })
+ expect(tabItems[1]).toHaveTextContent('collectibles')
+ expect(tabItems[1].children[0]).toHaveStyle({ color: Colors.black })
+ })
+
+ it.each([
+ { tab: AssetTabType.Tokens, event: AssetsEvents.view_wallet_assets },
+ { tab: AssetTabType.Collectibles, event: AssetsEvents.view_collectibles },
+ { tab: AssetTabType.Positions, event: AssetsEvents.view_dapp_positions },
+ ])('selecting tab $tab fires analytics events and invokes on change', ({ tab, event }) => {
+ const { getAllByTestId } = render(
+
+ )
+
+ fireEvent.press(getAllByTestId('Assets/TabBarItem')[tab])
+ expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1)
+ expect(ValoraAnalytics.track).toHaveBeenCalledWith(event)
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith(tab)
+ })
+})
diff --git a/src/tokens/AssetTabBar.tsx b/src/tokens/AssetTabBar.tsx
new file mode 100644
index 00000000000..f66af465977
--- /dev/null
+++ b/src/tokens/AssetTabBar.tsx
@@ -0,0 +1,87 @@
+import React, { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { StyleSheet, Text, View } from 'react-native'
+import { AssetsEvents } from 'src/analytics/Events'
+import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
+import Touchable from 'src/components/Touchable'
+import Colors from 'src/styles/colors'
+import { typeScale } from 'src/styles/fonts'
+import { vibrateInformative } from 'src/styles/hapticFeedback'
+import { Spacing } from 'src/styles/styles'
+import variables from 'src/styles/variables'
+import { AssetTabType } from 'src/tokens/types'
+
+const DEVICE_WIDTH_BREAKPOINT = 340
+
+export default function TabBar({
+ activeTab,
+ onChange,
+ displayPositions,
+}: {
+ activeTab: AssetTabType
+ onChange: (selectedTab: AssetTabType) => void
+ displayPositions: boolean
+}) {
+ const { t } = useTranslation()
+
+ const items = useMemo(() => {
+ const items = [t('assets.tabBar.tokens'), t('assets.tabBar.collectibles')]
+ if (displayPositions) {
+ items.push(t('assets.tabBar.dappPositions'))
+ }
+ return items
+ }, [t, displayPositions])
+
+ const handleSelectOption = (index: AssetTabType) => () => {
+ ValoraAnalytics.track(
+ [
+ AssetsEvents.view_wallet_assets,
+ AssetsEvents.view_collectibles,
+ AssetsEvents.view_dapp_positions,
+ ][index]
+ )
+ onChange(index)
+ vibrateInformative()
+ }
+
+ // On a smaller device, if there are more than two tabs, use smaller gaps
+ // between tabs
+ const gap =
+ items.length > 2 && variables.width < DEVICE_WIDTH_BREAKPOINT
+ ? Spacing.Smallest8
+ : Spacing.Regular16
+
+ return (
+
+ {items.map((value, index) => (
+
+
+ {value}
+
+
+ ))}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ },
+ touchable: {
+ flexShrink: 1,
+ },
+ item: {
+ ...typeScale.bodyMedium,
+ color: Colors.gray4,
+ },
+ itemSelected: {
+ ...typeScale.labelMedium,
+ color: Colors.black,
+ },
+})
diff --git a/src/tokens/Assets.test.tsx b/src/tokens/Assets.test.tsx
index 99c65d8faf4..fc84566dd52 100644
--- a/src/tokens/Assets.test.tsx
+++ b/src/tokens/Assets.test.tsx
@@ -1,11 +1,9 @@
import { fireEvent, render } from '@testing-library/react-native'
import * as React from 'react'
import { Provider } from 'react-redux'
-import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import { navigate } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
-import { fetchNfts } from 'src/nfts/slice'
-import { getDynamicConfigParams, getFeatureGate } from 'src/statsig'
+import { getFeatureGate } from 'src/statsig'
import { StatsigFeatureGates } from 'src/statsig/types'
import AssetsScreen from 'src/tokens/Assets'
import { NetworkId } from 'src/transactions/types'
@@ -21,7 +19,6 @@ import {
mockNftNullMetadata,
mockPositions,
mockShortcuts,
- mockTokenBalances,
} from 'test/values'
jest.mock('src/statsig', () => {
@@ -33,8 +30,6 @@ jest.mock('src/statsig', () => {
}
})
-const ethTokenId = 'ethereum-sepolia:native'
-
const storeWithTokenBalances = {
tokens: {
tokenBalances: {
@@ -203,44 +198,6 @@ describe('AssetsScreen', () => {
expect(queryAllByTestId('NftItem')).toHaveLength(2)
})
- it('renders collectibles error', () => {
- const store = createMockStore({
- nfts: {
- nftsLoading: false,
- nfts: [],
- nftsError: 'Error fetching nfts',
- },
- })
-
- const { getByText, getByTestId } = render(
-
-
-
- )
-
- fireEvent.press(getByText('assets.tabBar.collectibles'))
- expect(getByTestId('Assets/NftsLoadError')).toBeTruthy()
- })
-
- it('renders no collectables text', () => {
- const store = createMockStore({
- nfts: {
- nftsLoading: false,
- nfts: [],
- nftsError: null,
- },
- })
-
- const { getByText } = render(
-
-
-
- )
-
- fireEvent.press(getByText('assets.tabBar.collectibles'))
- expect(getByText('nftGallery.noNfts')).toBeTruthy()
- })
-
it('renders dapp positions on selecting the tab', () => {
jest.mocked(getFeatureGate).mockReturnValue(true)
const store = createMockStore(storeWithPositions)
@@ -265,51 +222,6 @@ 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('clicking an NFT navigates to the nfts info screen', async () => {
- jest.mocked(getFeatureGate).mockReturnValue(false)
- const store = createMockStore(storeWithNfts)
-
- const { getAllByTestId, getByText } = render(
-
-
-
- )
-
- fireEvent.press(getByText('assets.tabBar.collectibles'))
-
- expect(getAllByTestId('NftItem')).toHaveLength(2)
-
- fireEvent.press(getAllByTestId('NftGallery/NftImage')[0])
- fireEvent.press(getAllByTestId('NftGallery/NftImage')[1])
- expect(navigate).toHaveBeenCalledTimes(2)
- expect(navigate).toHaveBeenCalledWith(Screens.NftsInfoCarousel, {
- nfts: [{ ...mockNftAllFields, networkId: NetworkId['celo-alfajores'] }],
- networkId: NetworkId['celo-alfajores'],
- })
- expect(navigate).toHaveBeenCalledWith(Screens.NftsInfoCarousel, {
- nfts: [{ ...mockNftMinimumFields, networkId: NetworkId['ethereum-sepolia'] }],
- networkId: NetworkId['ethereum-sepolia'],
- })
- })
-
it('hides claim rewards if feature gate is false', () => {
jest
.mocked(getFeatureGate)
@@ -404,68 +316,4 @@ describe('AssetsScreen', () => {
expect(navigate).toHaveBeenCalledWith(Screens.TokenImport)
})
-
- it('displays tokens with balance and ones marked with showZeroBalance in the expected order', () => {
- jest.mocked(getDynamicConfigParams).mockReturnValueOnce({
- showBalances: [NetworkId['celo-alfajores'], NetworkId['ethereum-sepolia']],
- })
- const store = createMockStore({
- tokens: {
- tokenBalances: {
- ...mockTokenBalances,
- [ethTokenId]: {
- tokenId: ethTokenId,
- balance: '0',
- priceUsd: '5',
- networkId: NetworkId['ethereum-sepolia'],
- showZeroBalance: true,
- isNative: true,
- symbol: 'ETH',
- },
- ['token1']: {
- tokenId: 'token1',
- networkId: NetworkId['celo-alfajores'],
- balance: '10',
- symbol: 'TK1',
- },
- ['token2']: {
- tokenId: 'token2',
- networkId: NetworkId['celo-alfajores'],
- balance: '0',
- symbol: 'TK2',
- },
- ['token3']: {
- tokenId: 'token3',
- networkId: NetworkId['ethereum-sepolia'],
- balance: '20',
- symbol: 'TK3',
- },
- },
- },
- })
-
- const { getAllByTestId } = render(
-
-
-
- )
-
- expect(getAllByTestId('TokenBalanceItem')).toHaveLength(6)
- ;['POOF', 'TK3', 'TK1', 'CELO', 'ETH', 'cUSD'].map((symbol, index) => {
- expect(getAllByTestId('TokenBalanceItem')[index]).toHaveTextContent(symbol)
- })
- })
-
- it('dispatches action to fetch nfts on load', () => {
- const store = createMockStore(storeWithPositions)
-
- const { getByTestId } = render(
-
-
-
- )
-
- expect(getByTestId('AssetsTokenBalance')).toBeTruthy()
- expect(store.getActions()).toEqual([fetchNfts()])
- })
})
diff --git a/src/tokens/Assets.tsx b/src/tokens/Assets.tsx
index db5ac6298d5..dece772dca7 100644
--- a/src/tokens/Assets.tsx
+++ b/src/tokens/Assets.tsx
@@ -1,16 +1,8 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack'
import BigNumber from 'bignumber.js'
-import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
+import React, { useLayoutEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import {
- LayoutChangeEvent,
- SectionList,
- SectionListData,
- SectionListProps,
- StyleSheet,
- Text,
- View,
-} from 'react-native'
+import { LayoutChangeEvent, StyleSheet, View } from 'react-native'
import Animated, {
interpolateColor,
useAnimatedScrollHandler,
@@ -22,8 +14,6 @@ import { AssetsEvents } from 'src/analytics/Events'
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 ImageErrorIcon from 'src/icons/ImageErrorIcon'
import { useDollarsToLocalAmount } from 'src/localCurrency/hooks'
import { getLocalCurrencySymbol } from 'src/localCurrency/selectors'
import { headerWithBackButton } from 'src/navigator/Headers'
@@ -32,76 +22,24 @@ import { Screens } from 'src/navigator/Screens'
import useScrollAwareHeader from 'src/navigator/ScrollAwareHeader'
import { TopBarTextButton } from 'src/navigator/TopBarButton'
import { StackParamList } from 'src/navigator/types'
-import NftMedia from 'src/nfts/NftMedia'
-import NftsLoadError from 'src/nfts/NftsLoadError'
-import {
- nftsErrorSelector,
- nftsLoadingSelector,
- nftsWithMetadataSelector,
-} from 'src/nfts/selectors'
-import { fetchNfts } from 'src/nfts/slice'
-import { NftOrigin, NftWithNetworkId } from 'src/nfts/types'
import {
positionsSelector,
positionsWithClaimableRewardsSelector,
totalPositionsBalanceUsdSelector,
} from 'src/positions/selectors'
-import { Position } from 'src/positions/types'
-import { useDispatch, useSelector } from 'src/redux/hooks'
+import { useSelector } from 'src/redux/hooks'
import { getFeatureGate } from 'src/statsig'
import { StatsigFeatureGates } from 'src/statsig/types'
import Colors from 'src/styles/colors'
import { typeScale } from 'src/styles/fonts'
-import { vibrateInformative } from 'src/styles/hapticFeedback'
import { Shadow, Spacing, getShadowStyle } from 'src/styles/styles'
-import variables from 'src/styles/variables'
-import { PositionItem } from 'src/tokens/AssetItem'
-import { TokenBalanceItem } from 'src/tokens/TokenBalanceItem'
+import AssetList from 'src/tokens/AssetList'
+import AssetTabBar from 'src/tokens/AssetTabBar'
import { useTokenPricesAreStale, useTotalTokenBalance } from 'src/tokens/hooks'
-import { sortedTokensWithBalanceOrShowZeroBalanceSelector } from 'src/tokens/selectors'
-import { TokenBalance } from 'src/tokens/slice'
-import { getSupportedNetworkIdsForTokenBalances, getTokenAnalyticsProps } from 'src/tokens/utils'
-
-const DEVICE_WIDTH_BREAKPOINT = 340
-const NUM_OF_NFTS_PER_ROW = 2
-
-const nftImageSize =
- (variables.width - Spacing.Thick24 * 2 - Spacing.Regular16 * (NUM_OF_NFTS_PER_ROW - 1)) /
- NUM_OF_NFTS_PER_ROW
+import { AssetTabType } from 'src/tokens/types'
+import { getSupportedNetworkIdsForTokenBalances } from 'src/tokens/utils'
type Props = NativeStackScreenProps
-interface SectionData {
- appName?: string
-}
-
-const AnimatedSectionList =
- Animated.createAnimatedComponent<
- SectionListProps
- >(SectionList)
-
-const assetIsPosition = (asset: Position | TokenBalance | NftWithNetworkId[]): asset is Position =>
- 'type' in asset && (asset.type === 'app-token' || asset.type === 'contract-position')
-
-/**
- * Helper function to group an array into chunks of size n
- * Used with Nfts to group them for use in the section list
- */
-const groupArrayByN = (arr: any[], n: number) => {
- return arr.reduce((result, item, index) => {
- if (index % n === 0) {
- result.push([item])
- } else {
- result[Math.floor(index / n)].push(item)
- }
- return result
- }, [])
-}
-
-export enum AssetTabType {
- Tokens = 0,
- Collectibles = 1,
- Positions = 2,
-}
// offset relative to the bottom of the non sticky header component, where the
// screen header opacity animation starts
@@ -111,14 +49,10 @@ const HEADER_OPACITY_ANIMATION_DISTANCE = 20
function AssetsScreen({ navigation, route }: Props) {
const { t } = useTranslation()
- const dispatch = useDispatch()
const activeTab = route.params?.activeTab ?? AssetTabType.Tokens
const supportedNetworkIds = getSupportedNetworkIdsForTokenBalances()
- const tokens = useSelector((state) =>
- sortedTokensWithBalanceOrShowZeroBalanceSelector(state, supportedNetworkIds)
- )
const localCurrencySymbol = useSelector(getLocalCurrencySymbol)
const totalTokenBalanceLocal = useTotalTokenBalance() ?? new BigNumber(0)
@@ -143,17 +77,6 @@ function AssetsScreen({ navigation, route }: Props) {
const totalPositionsBalanceLocal = useDollarsToLocalAmount(totalPositionsBalanceUsd)
const totalBalanceLocal = totalTokenBalanceLocal?.plus(totalPositionsBalanceLocal ?? 0)
- // NFT Selectors
- const nftsError = useSelector(nftsErrorSelector)
- const nftsLoading = useSelector(nftsLoadingSelector)
- const nfts = useSelector(nftsWithMetadataSelector)
- // Group nfts for use in the section list
- const nftsGrouped = groupArrayByN(nfts, NUM_OF_NFTS_PER_ROW)
-
- useEffect(() => {
- dispatch(fetchNfts())
- }, [])
-
const [nonStickyHeaderHeight, setNonStickyHeaderHeight] = useState(0)
const [listHeaderHeight, setListHeaderHeight] = useState(0)
const [listFooterHeight, setListFooterHeight] = useState(0)
@@ -271,178 +194,10 @@ function AssetsScreen({ navigation, route }: Props) {
setListFooterHeight(event.nativeEvent.layout.height)
}
- const handleChangeActiveView = (_: string, index: number) => {
- navigation.setParams({ activeTab: index })
- ValoraAnalytics.track(
- [
- AssetsEvents.view_wallet_assets,
- AssetsEvents.view_collectibles,
- AssetsEvents.view_dapp_positions,
- ][index]
- )
- }
-
- const positionSections = useMemo(() => {
- const positionsByDapp = new Map()
- positions.forEach((position) => {
- if (positionsByDapp.has(position.appName)) {
- positionsByDapp.get(position.appName)?.push(position)
- } else {
- positionsByDapp.set(position.appName, [position])
- }
- })
-
- const sections: SectionListData[] =
- []
- positionsByDapp.forEach((positions, appName) => {
- sections.push({
- data: positions,
- appName,
- })
- })
- return sections
- }, [positions])
-
- const sections =
- activeTab === AssetTabType.Tokens
- ? [{ data: tokens }]
- : activeTab === AssetTabType.Positions
- ? positionSections
- : nfts.length
- ? [{ data: nftsGrouped }]
- : []
-
- const renderSectionHeader = ({
- section,
- }: {
- section: SectionListData
- }) => {
- if (section.appName) {
- return (
-
-
- {section.appName.toLocaleUpperCase()}
-
-
- )
- }
- return null
- }
-
- const keyExtractor = (item: TokenBalance | Position | NftWithNetworkId[], index: number) => {
- if (assetIsPosition(item)) {
- // Ideally we wouldn't need the index here, but we need to differentiate
- // between positions with the same address (e.g. Uniswap V3 pool NFTs)
- // We may want to consider adding a unique identifier to the position type.
- return `${activeTab}-${item.appId}-${item.network}-${item.address}-${index}`
- } else if ('balance' in item) {
- return `${activeTab}-${item.tokenId}`
- } else {
- return `${activeTab}-${item[0]!.networkId}-${item[0]!.contractAddress}-${item[0]!.tokenId}`
- }
- }
-
- const NftItem = ({ item }: { item: NftWithNetworkId }) => {
- return (
-
-
- navigate(Screens.NftsInfoCarousel, { nfts: [item], networkId: item.networkId })
- }
- style={styles.nftsTouchableIcon}
- >
-
-
- {item.metadata?.name && (
-
- {item.metadata.name}
-
- )}
-
- }
- origin={NftOrigin.Assets}
- borderRadius={Spacing.Regular16}
- mediaType="image"
- />
-
-
- )
- }
-
- const NftGroup = ({ item }: { item: NftWithNetworkId[] }) => {
- return (
-
- {item.map((nft, index) => (
-
- ))}
-
- )
- }
-
- const renderAssetItem = ({
- item,
- index,
- }: {
- item: TokenBalance | Position | NftWithNetworkId[]
- index: number
- }) => {
- if (assetIsPosition(item)) {
- return
- } else if ('balance' in item) {
- return (
- {
- navigate(Screens.TokenDetails, { tokenId: item.tokenId })
- ValoraAnalytics.track(AssetsEvents.tap_asset, {
- ...getTokenAnalyticsProps(item),
- title: item.symbol,
- description: item.name,
- assetType: 'token',
- })
- }}
- />
- )
- } else {
- return
- }
- }
-
- const renderEmptyState = () => {
- switch (activeTab) {
- case AssetTabType.Tokens:
- case AssetTabType.Positions:
- return null
- case AssetTabType.Collectibles:
- if (nftsError) return
- else if (nftsLoading) return null
- else
- return (
-
- {t('nftGallery.noNfts')}
-
- )
- }
+ const handleChangeActiveView = (selectedTab: AssetTabType) => {
+ navigation.setParams({ activeTab: selectedTab })
}
- const tabBarItems = useMemo(() => {
- const items = [t('assets.tabBar.tokens'), t('assets.tabBar.collectibles')]
- if (displayPositions) {
- items.push(t('assets.tabBar.dappPositions'))
- }
- return items
- }, [t, displayPositions])
-
return (
<>
-
+
- 0 ? 1 : 0,
- },
- activeTab === AssetTabType.Collectibles &&
- !nftsError &&
- nfts.length > 0 &&
- styles.nftsContentContainer,
- ]}
- // ensure header is above the scrollbar on ios overscroll
- scrollIndicatorInsets={{ top: listHeaderHeight }}
- // @ts-ignore can't get the SectionList to accept a union type :(
- sections={sections}
- renderItem={renderAssetItem}
- renderSectionHeader={renderSectionHeader}
- keyExtractor={keyExtractor}
- onScroll={handleScroll}
- scrollEventThrottle={16}
- ItemSeparatorComponent={() =>
- activeTab === AssetTabType.Collectibles ? (
-
- ) : null
- }
- ListHeaderComponent={}
- ListEmptyComponent={renderEmptyState}
+
{showClaimRewards && (
void
-}) {
- const handleSelectOption = (item: string, index: number) => () => {
- onChange(item, index)
- vibrateInformative()
- }
-
- // On a smaller device, if there are more than two tabs, use smaller gaps
- // between tabs
- const gap =
- items.length > 2 && variables.width < DEVICE_WIDTH_BREAKPOINT
- ? Spacing.Smallest8
- : Spacing.Regular16
-
- return (
-
- {items.map((value, index) => (
-
-
- {value}
-
-
- ))}
-
- )
-}
-
const styles = StyleSheet.create({
listHeaderContainer: {
...getShadowStyle(Shadow.SoftLight),
@@ -566,14 +260,6 @@ const styles = StyleSheet.create({
width: '100%',
zIndex: 1,
},
- positionSectionHeaderContainer: {
- padding: Spacing.Thick24,
- paddingTop: Spacing.Regular16,
- },
- positionSectionHeaderText: {
- ...typeScale.labelXXSmall,
- color: Colors.gray5,
- },
nonStickyHeaderContainer: {
zIndex: 1,
paddingBottom: Spacing.Thick24,
@@ -587,60 +273,10 @@ const styles = StyleSheet.create({
paddingHorizontal: Spacing.Thick24 - 10,
paddingTop: Spacing.Regular16,
},
- tabBarContainer: {
- flexDirection: 'row',
- },
- tabBarTouchable: {
- flexShrink: 1,
- },
- tabBarItem: {
- ...typeScale.bodyMedium,
- color: Colors.gray4,
- },
- tabBarItemSelected: {
- ...typeScale.labelMedium,
- color: Colors.black,
- },
topBarTextButton: {
...typeScale.bodyMedium,
paddingRight: Spacing.Smallest8,
},
- nftsPairingContainer: {
- flexDirection: 'row',
- gap: Spacing.Regular16,
- },
- nftsContentContainer: {
- alignItems: 'flex-start',
- paddingHorizontal: Spacing.Thick24,
- },
- nftsErrorView: {
- width: nftImageSize,
- height: nftImageSize,
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: Colors.gray2,
- borderRadius: Spacing.Regular16,
- },
- nftsNoMetadataText: {
- ...typeScale.labelSmall,
- textAlign: 'center',
- },
- nftsTouchableContainer: {
- overflow: 'hidden',
- borderRadius: Spacing.Regular16,
- },
- nftsTouchableIcon: {
- borderRadius: Spacing.Regular16,
- },
- noNftsText: {
- ...typeScale.bodySmall,
- color: Colors.gray3,
- textAlign: 'center',
- },
- noNftsTextContainer: {
- paddingHorizontal: Spacing.Thick24,
- },
})
export default AssetsScreen
diff --git a/src/tokens/AssetItem.test.tsx b/src/tokens/PositionItem.test.tsx
similarity index 66%
rename from src/tokens/AssetItem.test.tsx
rename to src/tokens/PositionItem.test.tsx
index efff1477df1..52d9ba20e18 100644
--- a/src/tokens/AssetItem.test.tsx
+++ b/src/tokens/PositionItem.test.tsx
@@ -1,14 +1,12 @@
import { fireEvent, render } from '@testing-library/react-native'
-import BigNumber from 'bignumber.js'
import React from 'react'
import { Provider } from 'react-redux'
import { AssetsEvents } from 'src/analytics/Events'
import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import { AppTokenPosition } from 'src/positions/types'
-import { PositionItem, TokenBalanceItem } from 'src/tokens/AssetItem'
-import { NetworkId } from 'src/transactions/types'
+import { PositionItem } from 'src/tokens/PositionItem'
import { createMockStore } from 'test/utils'
-import { mockCusdAddress, mockCusdTokenId, mockPositions } from 'test/values'
+import { mockPositions } from 'test/values'
beforeEach(() => {
jest.clearAllMocks()
@@ -77,40 +75,3 @@ describe('PositionItem', () => {
expect(getByText('11.90')).toBeTruthy()
})
})
-
-describe('TokenBalanceItem', () => {
- const mockTokenInfo = {
- balance: new BigNumber('10'),
- tokenId: mockCusdTokenId,
- priceUsd: new BigNumber('1'),
- networkId: NetworkId['celo-alfajores'],
- lastKnownPriceUsd: new BigNumber('1'),
- symbol: 'cUSD',
- address: mockCusdAddress,
- isFeeCurrency: true,
- canTransferWithComment: true,
- priceFetchedAt: Date.now(),
- decimals: 18,
- name: 'Celo Dollar',
- imageUrl: '',
- }
-
- it('tracks data about the asset when tapped', () => {
- const { getByText } = render(
-
-
-
- )
-
- fireEvent.press(getByText('cUSD'))
-
- expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1)
- expect(ValoraAnalytics.track).toHaveBeenCalledWith(AssetsEvents.tap_asset, {
- address: mockCusdAddress,
- assetType: 'token',
- balanceUsd: 10,
- description: 'Celo Dollar',
- title: 'cUSD',
- })
- })
-})
diff --git a/src/tokens/AssetItem.tsx b/src/tokens/PositionItem.tsx
similarity index 57%
rename from src/tokens/AssetItem.tsx
rename to src/tokens/PositionItem.tsx
index b473bd5c303..97e7aaaae81 100644
--- a/src/tokens/AssetItem.tsx
+++ b/src/tokens/PositionItem.tsx
@@ -5,14 +5,10 @@ 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 { 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'
export const PositionItem = ({ position }: { position: Position }) => {
@@ -77,70 +73,6 @@ export const PositionItem = ({ position }: { position: Position }) => {
)
}
-export const TokenBalanceItem = ({
- token,
- showPriceChangeIndicatorInBalances,
-}: {
- token: TokenBalance
- showPriceChangeIndicatorInBalances: boolean
-}) => {
- const onPress = () => {
- ValoraAnalytics.track(AssetsEvents.tap_asset, {
- assetType: 'token',
- address: token.address,
- title: token.symbol,
- description: token.name,
- balanceUsd: token.balance.multipliedBy(token.priceUsd ?? 0).toNumber(),
- })
- }
-
- return (
-
-
-
-
- {token.symbol}
- {token.name}
-
-
-
-
- {token.priceUsd?.gt(0) && (
-
- {showPriceChangeIndicatorInBalances &&
- token.historicalPricesUsd &&
- isHistoricalPriceUpdated(token) && (
-
- )}
-
-
- )}
-
-
- )
-}
-
const styles = StyleSheet.create({
tokenImg: {
width: 32,
@@ -148,23 +80,12 @@ const styles = StyleSheet.create({
borderRadius: 20,
marginRight: Spacing.Regular16,
},
- container: {
- flexDirection: 'row',
- paddingHorizontal: Spacing.Thick24,
- paddingBottom: Spacing.Large32,
- justifyContent: 'space-between',
- },
positionsContainer: {
flexDirection: 'row',
paddingHorizontal: Spacing.Thick24,
paddingBottom: Spacing.Thick24,
justifyContent: 'space-between',
},
- tokenContainer: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- justifyContent: 'flex-end',
- },
tokenLabels: {
flexShrink: 1,
flexDirection: 'column',
diff --git a/src/tokens/types.ts b/src/tokens/types.ts
index c641010adb8..0186fbd4694 100644
--- a/src/tokens/types.ts
+++ b/src/tokens/types.ts
@@ -16,3 +16,9 @@ export interface TokenDetailsAction {
onPress: () => void
visible: boolean
}
+
+export enum AssetTabType {
+ Tokens = 0,
+ Collectibles = 1,
+ Positions = 2,
+}