diff --git a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetListItem.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetListItem.tsx index e380214162c..bba1c5b66c0 100644 --- a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetListItem.tsx +++ b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetListItem.tsx @@ -14,40 +14,10 @@ export type AssetListItemProps = { onPress: () => void; onFavouritePress: () => void; isFavourite?: boolean; - isFirst?: boolean; - isLast?: boolean; -}; - -type AssetItemStyleProps = { - isFirst: boolean; - isLast: boolean; }; export const ASSET_ITEM_HEIGHT = 68; -const itemStyle = prepareNativeStyle( - ({ colors, spacings, borders }, { isFirst, isLast }) => ({ - backgroundColor: colors.backgroundSurfaceElevation1, - paddingHorizontal: spacings.sp12, - extend: [ - { - condition: isFirst, - style: { - borderTopLeftRadius: borders.radii.r20, - borderTopRightRadius: borders.radii.r20, - }, - }, - { - condition: isLast, - style: { - borderBottomLeftRadius: borders.radii.r20, - borderBottomRightRadius: borders.radii.r20, - }, - }, - ], - }), -); - const vStackStyle = prepareNativeStyle(({ spacings }) => ({ height: ASSET_ITEM_HEIGHT, justifyContent: 'center', @@ -63,8 +33,6 @@ export const TradeableAssetListItem = ({ onPress, onFavouritePress, isFavourite = false, - isFirst = false, - isLast = false, }: AssetListItemProps) => { const { applyStyle } = useNativeStyles(); const { DisplaySymbolFormatter, FiatAmountFormatter, NetworkNameFormatter } = useFormatters(); @@ -72,12 +40,7 @@ export const TradeableAssetListItem = ({ const assetName = name ?? ; return ( - + diff --git a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheet.tsx b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheet.tsx index 1df690e695e..47324b382f1 100644 --- a/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheet.tsx +++ b/suite-native/module-trading/src/components/general/TradeableAssetsSheet/TradeableAssetsSheet.tsx @@ -1,10 +1,13 @@ -import { ReactNode, useMemo } from 'react'; +import { useMemo } from 'react'; -import { UnreachableCaseError } from '@suite-common/suite-utils'; import { TokenAddress } from '@suite-common/wallet-types'; -import { BottomSheetFlashList, Box, Text } from '@suite-native/atoms'; import { Translation } from '@suite-native/intl'; +import { + ItemRenderConfig, + SectionListData, + TradingBottomSheetSectionList, +} from '../TradingBottomSheetSectionList'; import { TradeAssetsListEmptyComponent } from './TradeAssetsListEmptyComponent'; import { ASSET_ITEM_HEIGHT, TradeableAssetListItem } from './TradeableAssetListItem'; import { TradeableAssetsSheetHeader } from './TradeableAssetsSheetHeader'; @@ -16,13 +19,9 @@ export type TradeableAssetsSheetProps = { onAssetSelect: (symbol: TradeableAsset) => void; }; -type ListInnerItemShape = - // [type, text, key] - | ['sectionHeader', ReactNode, string] - // [type, data, isFavourite] - | ['asset', TradeableAsset, { isFavourite?: boolean; isFirst?: boolean; isLast?: boolean }]; - -const SECTION_HEADER_HEIGHT = 48 as const; +type ListItemExtraData = { + isFavourite: boolean; +}; const mockFavourites: TradeableAsset[] = [ { symbol: 'btc' }, @@ -50,104 +49,32 @@ const mockAssets: TradeableAsset[] = [ const getMockFiatRate = () => Math.random() * 1000; const getMockPriceChange = () => Math.random() * 3 - 1; -const getEstimatedListHeight = (itemsCount: number) => - itemsCount * ASSET_ITEM_HEIGHT + 2 * SECTION_HEADER_HEIGHT; - -const transformToInnerFlatListData = ( - favourites: TradeableAsset[], - assetsData: TradeableAsset[], -): ListInnerItemShape[] => [ - [ - 'sectionHeader', - , - 'section_favourites', - ], - ...favourites.map( - (asset, index) => - [ - 'asset', - asset, - { - isFavourite: true, - isFirst: index === 0, - isLast: index === favourites.length - 1, - }, - ] as ListInnerItemShape, - ), - [ - 'sectionHeader', - , - 'section_all', - ], - ...assetsData.map( - (asset, index) => - [ - 'asset', - asset, - { - isFavourite: false, - isFirst: index === 0, - isLast: index === assetsData.length - 1, - }, - ] as ListInnerItemShape, - ), -]; - -const keyExtractor = (item: ListInnerItemShape) => { - switch (item[0]) { - case 'sectionHeader': - return item[2]; - - case 'asset': { - const [_, { symbol, contractAddress }, { isFavourite }] = item; - - return `asset_${symbol}_${contractAddress ?? ''}_${isFavourite ? 'favourite' : ''}`; - } - - default: - throw new UnreachableCaseError(item[0]); - } -}; - -const renderItem = (data: ListInnerItemShape, onAssetSelect: (asset: TradeableAsset) => void) => { - switch (data[0]) { - case 'sectionHeader': { - const text = data[1]; - - return ( - - - {text} - - - ); - } - - case 'asset': { - const [_, asset, { isFavourite, isFirst, isLast }] = data; - const toggleFavourite = () => { - // TODO: Implement - // eslint-disable-next-line no-console - console.log('Not implemented!'); - }; - - return ( - onAssetSelect(asset)} - onFavouritePress={toggleFavourite} - priceChange={getMockPriceChange()} - fiatRate={getMockFiatRate()} - isFavourite={isFavourite} - isFirst={isFirst} - isLast={isLast} - /> - ); - } +const keyExtractor = ( + { symbol, contractAddress }: TradeableAsset, + { isFavourite }: ListItemExtraData, +) => `asset_${symbol}_${contractAddress ?? ''}}_${isFavourite ? 'favourite' : 'all'}`; + +const renderItem = ( + asset: TradeableAsset, + { sectionData }: ItemRenderConfig, + onAssetSelect: (asset: TradeableAsset) => void, +) => { + const toggleFavourite = () => { + // TODO: Implement + // eslint-disable-next-line no-console + console.log('Not implemented!'); + }; - default: - throw new UnreachableCaseError(data[0]); - } + return ( + onAssetSelect(asset)} + onFavouritePress={toggleFavourite} + priceChange={getMockPriceChange()} + fiatRate={getMockFiatRate()} + isFavourite={sectionData.isFavourite} + /> + ); }; export const TradeableAssetsSheet = ({ @@ -162,24 +89,37 @@ export const TradeableAssetsSheet = ({ const favourites = mockFavourites; const assetsData = mockAssets; - const estimatedListHeight = getEstimatedListHeight(favourites.length + assetsData.length); - const data: ListInnerItemShape[] = useMemo( - () => transformToInnerFlatListData(favourites, assetsData), + const data = useMemo( + () => + [ + { + key: 'section_favourites', + label: , + data: favourites, + sectionData: { isFavourite: true }, + }, + { + key: 'section_all', + label: , + data: assetsData, + sectionData: { isFavourite: false }, + }, + ] as SectionListData, [favourites, assetsData], ); return ( - + isVisible={isVisible} onClose={onClose} ListEmptyComponent={} handleComponent={() => } data={data} keyExtractor={keyExtractor} - estimatedListHeight={estimatedListHeight} estimatedItemSize={ASSET_ITEM_HEIGHT} - renderItem={({ item }) => renderItem(item, onAssetSelectCallback)} + renderItem={(item, config) => renderItem(item, config, onAssetSelectCallback)} + noSingletonSectionHeader /> ); }; diff --git a/suite-native/module-trading/src/components/general/TradingBottomSheetSectionList.tsx b/suite-native/module-trading/src/components/general/TradingBottomSheetSectionList.tsx new file mode 100644 index 00000000000..79aaa1f3d0e --- /dev/null +++ b/suite-native/module-trading/src/components/general/TradingBottomSheetSectionList.tsx @@ -0,0 +1,182 @@ +import { ReactElement, ReactNode, useMemo } from 'react'; + +import { UnreachableCaseError } from '@suite-common/suite-utils'; +import { BottomSheetFlashList, BottomSheetFlashListProps, Box, Text } from '@suite-native/atoms'; +import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; + +export type ItemRenderConfig = { + isFirst?: boolean; + isLast?: boolean; + sectionData: U; +}; + +export type TradingBottomSheetSectionListProps = Omit< + BottomSheetFlashListProps, + | 'renderItem' + | 'keyExtractor' + | 'data' + | 'estimatedItemSize' + // computed automatically + | 'estimatedListHeight' + // not supported + | 'getItemType' + | 'overrideItemLayout' +> & { + data: SectionListData; + renderItem: (item: T, config: ItemRenderConfig) => ReactElement; + keyExtractor: (item: T, sectionData: U) => string; + estimatedItemSize: number; + noSingletonSectionHeader?: boolean; +}; + +export type SectionListData = { + key: string; + label: ReactNode; + sectionData: U; + data: T[]; +}[]; + +type ListInternalItemShape = + // [type, text, key] + | ['sectionHeader', ReactNode, string] + // [type, data, config] + | ['item', T, ItemRenderConfig]; + +const SECTION_HEADER_HEIGHT = 48 as const; + +const itemStyle = prepareNativeStyle>( + ({ colors, spacings, borders }, { isFirst, isLast }) => ({ + backgroundColor: colors.backgroundSurfaceElevation1, + paddingHorizontal: spacings.sp12, + extend: [ + { + condition: !!isFirst, + style: { + borderTopLeftRadius: borders.radii.r20, + borderTopRightRadius: borders.radii.r20, + }, + }, + { + condition: !!isLast, + style: { + borderBottomLeftRadius: borders.radii.r20, + borderBottomRightRadius: borders.radii.r20, + }, + }, + ], + }), +); + +const transformToInternalFlatListData = ( + inputData: SectionListData, + noSingletonSectionHeader: boolean | undefined, +): ListInternalItemShape[] => + inputData.reduce( + (acc, { key, label, data, sectionData }) => { + const itemsData = data.map( + (item, index): ListInternalItemShape => [ + 'item', + item, + { + isFirst: index === 0, + isLast: index === data.length - 1, + sectionData, + }, + ], + ); + + if (noSingletonSectionHeader && inputData.length === 1) { + return [...acc, ...itemsData]; + } + + return [...acc, ['sectionHeader', label, key], ...itemsData]; + }, + [] as ListInternalItemShape[], + ); + +const internalKeyExtractor = ( + item: ListInternalItemShape, + itemKeyExtractor: (item: T, sectionData: U) => string, +) => { + switch (item[0]) { + case 'sectionHeader': + return item[2]; + + case 'item': + return itemKeyExtractor(item[1], item[2].sectionData); + + default: + throw new UnreachableCaseError(item[0]); + } +}; + +const renderInternalItem = ( + item: ListInternalItemShape, + renderItem: (item: T, config: ItemRenderConfig) => ReactElement, + applyStyle: ReturnType['applyStyle'], +): ReactElement => { + switch (item[0]) { + case 'sectionHeader': + return ( + + + {item[1]} + + + ); + + case 'item': + return {renderItem(item[1], item[2])}; + + default: + throw new UnreachableCaseError(item[0]); + } +}; + +export const TradingBottomSheetSectionList = ({ + keyExtractor, + renderItem, + estimatedItemSize, + data, + noSingletonSectionHeader, + ...rest +}: TradingBottomSheetSectionListProps) => { + const { applyStyle } = useNativeStyles(); + + const sectionsCount = data.length; + + const itemsCount = useMemo( + () => + data.reduce( + (intermediateDataLength, { data: sectionData }) => + intermediateDataLength + sectionData.length, + 0, + ), + [data], + ); + + const estimatedListSize = useMemo( + () => + itemsCount * estimatedItemSize + + (sectionsCount === 1 && noSingletonSectionHeader + ? 0 + : SECTION_HEADER_HEIGHT * sectionsCount), + [itemsCount, estimatedItemSize, sectionsCount, noSingletonSectionHeader], + ); + + const internalData = useMemo( + () => transformToInternalFlatListData(data, noSingletonSectionHeader), + [data, noSingletonSectionHeader], + ); + + return ( + > + keyExtractor={item => internalKeyExtractor(item, keyExtractor)} + renderItem={({ item }) => renderInternalItem(item, renderItem, applyStyle)} + estimatedItemSize={estimatedItemSize} + estimatedListHeight={estimatedListSize} + data={internalData} + {...rest} + /> + ); +};