diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index 6b0fa946f4..bd038744a8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -7,10 +7,16 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -20,13 +26,16 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH32 import com.tangem.core.ui.components.SpacerW2 import com.tangem.core.ui.components.appbar.ExpandableSearchView +import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.currency.icon.CurrencyIcon -import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.tokenlist.PortfolioListItem import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem import com.tangem.core.ui.components.tokenlist.TokenListItem @@ -38,12 +47,18 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenBalanceData import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.models.TokenToSelectState +import com.tangem.feature.swap.models.isEmptyState +import com.tangem.feature.swap.models.isNotFoundState +import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R +import com.tangem.feature.swap.ui.market.swapMarketsListItems +import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.persistentListOf + +private const val LOAD_MORE_BUFFER = 25 @Composable internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) { @@ -56,17 +71,9 @@ internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () content = { padding -> val modifier = Modifier.padding(padding) when { - state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && - state.tokensListData.tokensList.isEmpty() && state.isAfterSearch -> { - TokensNotFound(modifier) - } - state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && - state.tokensListData.tokensList.isEmpty() && !state.isAfterSearch -> { - EmptyTokensList(modifier) - } - else -> { - ListOfTokens(state = state, modifier = modifier) - } + state.isNotFoundState -> TokensNotFound(modifier) + state.isEmptyState -> EmptyTokensList(modifier) + else -> ListOfTokens(state = state, modifier = modifier) } }, topBar = { @@ -135,19 +142,24 @@ private fun TokensNotFound(modifier: Modifier = Modifier) { @Composable private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) { val screenBackgroundColor = TangemTheme.colors.background.secondary + val lazyListState = rememberLazyListState() + LazyColumn( modifier = modifier .background(color = screenBackgroundColor) .fillMaxSize() .imePadding(), horizontalAlignment = Alignment.CenterHorizontally, + state = lazyListState, ) { when (val list = state.tokensListData) { - is TokenListUMData.AccountList -> list.tokensList.forEach { item -> - portfolioTokensList( - portfolio = item, - isBalanceHidden = state.isBalanceHidden, - ) + is TokenListUMData.AccountList -> { + list.tokensList.forEach { item -> + portfolioTokensList( + portfolio = item, + isBalanceHidden = state.isBalanceHidden, + ) + } } is TokenListUMData.TokenList -> { tokensList( @@ -158,13 +170,56 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = TokenListUMData.EmptyList -> Unit } - tokensToSelectItems(state.availableTokens, state.onTokenSelected) + if (state.availableTokens.isNotEmpty()) { + tokensToSelectItems(state.availableTokens, state.onTokenSelected) + } + if (state.unavailableTokens.isNotEmpty()) { + item { SpacerH12() } + tokensToSelectItems(state.unavailableTokens, state.onTokenSelected) + } - item { SpacerH12() } + if (state.availableTokens.isNotEmpty() || state.unavailableTokens.isNotEmpty()) { + item { SpacerH32() } + } else { + item { SpacerH12() } + } - tokensToSelectItems(state.unavailableTokens, state.onTokenSelected) + state.marketsState?.let(::swapMarketsListItems) + } - item { SpacerH12() } + (state.marketsState as? SwapMarketState.Content)?.let { marketState -> + VisibleItemsTracker( + lazyListState = lazyListState, + marketState = marketState, + ) + + InfiniteListHandler( + listState = lazyListState, + buffer = LOAD_MORE_BUFFER, + triggerLoadMoreCheckOnItemsCountChange = true, + onLoadMore = remember(state) { + { + state.marketsState.loadMore() + true + } + }, + ) + } +} + +@Composable +private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) { + val visibleItems by remember { + derivedStateOf { + lazyListState.layoutInfo.visibleItemsInfo + .mapNotNull { itemInfo -> + marketState.items.find { it.getComposeKey() == itemInfo.key }?.id + } + } + } + + LaunchedEffect(visibleItems) { + marketState.visibleIdsChanged(visibleItems) } } @@ -387,50 +442,45 @@ private fun TokenItem( } } -private val token = TokenToSelectState.TokenToSelect( - tokenIcon = CurrencyIconState.CoinIcon( - url = "", - fallbackResId = 0, - isGrayscale = false, - shouldShowCustomBadge = false, - ), - id = "", - name = "Optimistic Ethereum (ETH)", - symbol = "USDC", - addedTokenBalanceData = TokenBalanceData( - amount = "15 000 $", - amountEquivalent = "15 000 USDT", - isBalanceHidden = false, - ), -) - -private val title = TokenToSelectState.Title( - title = stringReference("MY TOKENS"), -) - -@Preview -@Composable -private fun TokenScreenPreview() { - TangemThemePreview { - SwapSelectTokenScreen( - state = SwapSelectTokenStateHolder( - availableTokens = listOf(title, token, token, token).toImmutableList(), - unavailableTokens = listOf(title, token, token, token).toImmutableList(), - tokensListData = TokenListUMData.EmptyList, - isAfterSearch = false, - isBalanceHidden = false, - onSearchEntered = {}, - onTokenSelected = {}, - ), - onBack = {}, - ) - } +private class SwapSelectTokenScreenPreviewProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + // Content state with tokens and markets + SwapSelectTokenPreviewProvider().provideSwapSelectTokenState(), + // Empty state + SwapSelectTokenStateHolder( + availableTokens = persistentListOf(), + unavailableTokens = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, + marketsState = null, + isAfterSearch = false, + isBalanceHidden = false, + onSearchEntered = {}, + onTokenSelected = {}, + ), + // Not found state + SwapSelectTokenStateHolder( + availableTokens = persistentListOf(), + unavailableTokens = persistentListOf(), + tokensListData = TokenListUMData.EmptyList, + marketsState = null, + isAfterSearch = true, + isBalanceHidden = false, + onSearchEntered = {}, + onTokenSelected = {}, + ), + ) } @Preview @Composable -private fun EmptyTokensListPreview() { +private fun TokenScreenPreview( + @PreviewParameter(SwapSelectTokenScreenPreviewProvider::class) + state: SwapSelectTokenStateHolder, +) { TangemThemePreview { - EmptyTokensList() + SwapSelectTokenScreen( + state = state, + onBack = {}, + ) } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt new file mode 100644 index 0000000000..0e7fb5e9bb --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt @@ -0,0 +1,94 @@ +package com.tangem.feature.swap.ui.market + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.tangem.common.ui.markets.MarketsListItem +import com.tangem.common.ui.markets.MarketsListItemPlaceholder +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.models.market.state.SwapMarketState +import com.tangem.feature.swap.presentation.R + +internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { + item { + Text( + text = stringResourceSafe(R.string.markets_common_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16), + ) + } + when (state) { + is SwapMarketState.Loading -> { + items(count = 100, key = { "market_placeholder_$it" }) { + MarketsListItemPlaceholder() + } + } + is SwapMarketState.LoadingError -> { + item(key = "market_loading_error") { + LoadingErrorItem( + modifier = Modifier.fillParentMaxWidth(), + onTryAgain = state.onRetryClicked, + ) + } + } + SwapMarketState.SearchNothingFound -> { + item(key = "market_not_found") { + SearchNothingFoundText( + modifier = Modifier.fillParentMaxWidth(), + ) + } + } + is SwapMarketState.Content -> { + itemsIndexed( + items = state.items, + key = { _, item -> item.getComposeKey() }, + ) { index, item -> + MarketsListItem( + model = item, + onClick = { state.onItemClick(item) }, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + backgroundColor = TangemTheme.colors.background.action, + ), + ) + } + } + } +} + +@Composable +private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData(onRetryClick = onTryAgain) + } +} + +@Composable +private fun SearchNothingFoundText(modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(TangemTheme.dimens.spacing16), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResourceSafe(R.string.markets_search_token_no_result_title), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt new file mode 100644 index 0000000000..a0c8c9a1b1 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt @@ -0,0 +1,155 @@ +package com.tangem.feature.swap.ui.preview + +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.swap.models.SwapSelectTokenStateHolder +import com.tangem.feature.swap.models.TokenBalanceData +import com.tangem.feature.swap.models.TokenListUMData +import com.tangem.feature.swap.models.TokenToSelectState +import com.tangem.feature.swap.models.market.state.SwapMarketState +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class SwapSelectTokenPreviewProvider { + + fun provideSwapSelectTokenState(): SwapSelectTokenStateHolder { + return SwapSelectTokenStateHolder( + availableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(), + unavailableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(), + tokensListData = TokenListUMData.EmptyList, + isAfterSearch = false, + isBalanceHidden = false, + onSearchEntered = {}, + onTokenSelected = {}, + marketsState = createPreviewMarketsState(), + ) + } + + private fun createPreviewMarketsState() = SwapMarketState.Content( + items = createPreviewMarketItems(), + loadMore = { }, + onItemClick = { }, + visibleIdsChanged = { }, + ) + + private fun createPreviewMarketItems() = listOf( + createMarketItem( + id = "1", + iconUrl = "", + ratingPosition = "10", + marketCap = "$6.233 B", + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "2", + ratingPosition = "10", + marketCap = "$6.233 B", + trendType = PriceChangeType.NEUTRAL, + chartData = null, + ), + createMarketItem( + id = "3", + name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin", + ratingPosition = "10", + marketCap = "$6.23348172384781234 B", + trendType = PriceChangeType.DOWN, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "4", + ratingPosition = "10", + marketCap = null, + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "5", + ratingPosition = null, + marketCap = "$6.233 B", + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "6", + ratingPosition = null, + marketCap = null, + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + ).toImmutableList() + + private fun createMarketItem( + id: String, + name: String = "Bitcoin", + iconUrl: String? = null, + ratingPosition: String?, + marketCap: String?, + trendType: PriceChangeType, + chartData: MarketChartRawData?, + ) = MarketsListItemUM( + id = CryptoCurrency.RawID(id), + name = name, + currencySymbol = "BTC", + iconUrl = iconUrl, + ratingPosition = ratingPosition, + marketCap = marketCap, + price = MarketsListItemUM.Price(text = "31 285.72$"), + trendPercentText = "12.43%", + trendType = trendType, + chartData = chartData, + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ) + + companion object { + private const val CHART_VALUE_1 = 0.4 + private const val CHART_VALUE_2 = 0.2 + private const val CHART_VALUE_3 = 0.1 + private const val CHART_VALUE_4 = 2.0 + private const val CHART_VALUE_5 = 5.0 + private const val CHART_VALUE_6 = 3.0 + + private val PREVIEW_CHART_DATA = MarketChartRawData( + y = persistentListOf( + CHART_VALUE_1, + CHART_VALUE_2, + CHART_VALUE_1, + CHART_VALUE_3, + CHART_VALUE_1, + CHART_VALUE_4, + CHART_VALUE_5, + CHART_VALUE_3, + CHART_VALUE_4, + CHART_VALUE_4, + CHART_VALUE_6, + ), + ) + + private val previewToken = TokenToSelectState.TokenToSelect( + tokenIcon = CurrencyIconState.CoinIcon( + url = "", + fallbackResId = 0, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + id = "", + name = "Optimistic Ethereum (ETH)", + symbol = "USDC", + addedTokenBalanceData = TokenBalanceData( + amount = "15 000 $", + amountEquivalent = "15 000 USDT", + isBalanceHidden = false, + ), + ) + + private val previewTitle = TokenToSelectState.Title( + title = stringReference("MY TOKENS"), + ) + } +} \ No newline at end of file