Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-10 11:23:37 +01:00
parent 8adc179449
commit d5f9410905
35 changed files with 892 additions and 324 deletions

View file

@ -213,10 +213,10 @@ interface TangemTechApi {
@Query("page") page: String? = null,
@Query("limit") limit: Int? = null,
@Query("type") type: String? = null,
@Query("network") network: String? = null,
@Query("networkIds") networks: List<String>? = null,
): ApiResponse<EarnListResponse>
@GET("v1/earn/networks")
suspend fun getEarnNetworks(@Query("type") type: String): ApiResponse<EarnNetworkListResponse>
suspend fun getEarnNetworks(@Query("type") type: String? = null): ApiResponse<EarnNetworkListResponse>
// endregion
}

View file

@ -23,7 +23,7 @@ data class EarnTokenResponse(
@Json(name = "id") val id: String,
@Json(name = "symbol") val symbol: String,
@Json(name = "name") val name: String,
@Json(name = "address") val address: String,
@Json(name = "address") val address: String? = null,
@Json(name = "decimalCount") val decimalCount: Int? = null,
)

View file

@ -62,13 +62,16 @@ internal class DefaultEarnRepository(
).toBatchFlow()
}
override suspend fun fetchEarnNetworks(type: String) {
override suspend fun fetchEarnNetworks() {
runCatching(dispatchers.io) {
val response = tangemTechApi.getEarnNetworks(type = type).getOrThrow()
val response = tangemTechApi.getEarnNetworks().getOrThrow()
val items = response.items.map { dto ->
val blockchain = Blockchain.fromNetworkId(dto.networkId)
EarnNetwork(
networkId = dto.networkId,
isAdded = false,
fullName = blockchain?.fullName.orEmpty(),
symbol = blockchain?.currency.orEmpty(),
)
}
earnNetworksStore.store(Either.Right(items))
@ -136,7 +139,8 @@ internal fun createCryptoCurrencyForEarnToken(
cryptoCurrencyFactory: CryptoCurrencyFactory,
): CryptoCurrency? {
val blockchain = Blockchain.fromNetworkId(earnToken.networkId) ?: Blockchain.Unknown
return if (earnToken.token.address.isEmpty()) {
val address = earnToken.token.address
return if (address == null) {
cryptoCurrencyFactory.createCoin(
blockchain = blockchain,
extraDerivationPath = null,
@ -157,7 +161,7 @@ internal fun createCryptoCurrencyForEarnToken(
name = earnToken.token.name,
symbol = earnToken.token.symbol,
decimals = earnToken.token.decimalCount ?: blockchain.decimals(),
contractAddress = earnToken.token.address,
contractAddress = address,
)
}
}

View file

@ -100,7 +100,7 @@ internal class EarnTokensBatchFetcher(
page = page.toString(),
limit = limit,
type = params.type,
network = params.network,
networks = params.networks,
).getOrThrow()
val userWallet = userWalletsListRepository.selectedUserWallet.value

View file

@ -6,12 +6,12 @@ import kotlinx.serialization.Serializable
* Config of request earn tokens.
*
* @param type of Earn (ex: yield, staking).
* @param network networkId.
* @param networks list of networkId.
* @param isForEarn flag to load mostly used tokens.
*/
@Serializable
data class EarnTokensListConfig(
val type: String,
val network: String,
val type: String?,
val networks: List<String>?,
val isForEarn: Boolean,
)

View file

@ -13,7 +13,7 @@ interface EarnRepository {
/**
* Load all networks for Earn and store it in data store.
*/
suspend fun fetchEarnNetworks(type: String)
suspend fun fetchEarnNetworks()
fun observeEarnNetworks(): Flow<EarnNetworks?>

View file

@ -7,9 +7,9 @@ class FetchEarnNetworksUseCase(
private val repository: EarnRepository,
) {
suspend operator fun invoke(type: String): Either<Throwable, Unit> {
suspend operator fun invoke(): Either<Throwable, Unit> {
return Either.catch {
repository.fetchEarnNetworks(type = type)
repository.fetchEarnNetworks()
}
}
}

View file

@ -5,10 +5,14 @@ import kotlinx.serialization.Serializable
/**
* Model for networks in Earn.
* @param networkId - id of network
* @param fullName - full name of network
* @param symbol - symbol of network
* @param isAdded - is exists in any user's wallet
*/
@Serializable
data class EarnNetwork(
val networkId: String,
val fullName: String,
val symbol: String,
val isAdded: Boolean,
)

View file

@ -22,7 +22,7 @@ data class EarnToken(
val tokenId: String,
val tokenSymbol: String,
val tokenName: String,
val tokenAddress: String,
val tokenAddress: String?,
val decimalCount: Int?,
)

View file

@ -109,6 +109,7 @@ internal class FeedEntryChildFactory @Inject constructor(
DefaultEarnComponent(
appComponentContext = appComponentContext,
params = child.params,
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
)
}
}

View file

@ -5,15 +5,22 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.features.feed.components.feed.FeedPortfolioRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.model.earn.EarnModel
import com.tangem.features.feed.ui.earn.EarnContent
import kotlinx.serialization.Serializable
@ -21,10 +28,18 @@ import kotlinx.serialization.Serializable
internal class DefaultEarnComponent(
appComponentContext: AppComponentContext,
private val params: Params,
private val addToPortfolioComponentFactory: AddToPortfolioPreselectedDataComponent.Factory,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
private val earnModel = getOrCreateModel<EarnModel, Params>(params = params)
private val bottomSheetSlot = childSlot(
source = earnModel.bottomSheetNavigation,
serializer = FeedPortfolioRoute.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val background = LocalMainBottomSheetColor.current.value
@ -42,11 +57,29 @@ internal class DefaultEarnComponent(
@Composable
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by earnModel.state.collectAsStateWithLifecycle()
EarnContent(
state = state,
modifier = modifier,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: FeedPortfolioRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is FeedPortfolioRoute.AddToPortfolio -> {
addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioPreselectedDataComponent.Params(
tokenToAdd = config.tokenToAdd,
callback = earnModel.addToPortfolioCallback,
),
)
}
}
@Serializable

View file

@ -0,0 +1,50 @@
package com.tangem.features.feed.model.converter
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.models.earn.EarnRewardType
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.earn.EarnType
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class EarnTokenWithCurrencyToListItemUMConverter(
private val onItemClick: (EarnTokenWithCurrency) -> Unit,
) : Converter<EarnTokenWithCurrency, EarnListItemUM> {
private val cryptoCurrencyToIconStateConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: EarnTokenWithCurrency): EarnListItemUM {
return EarnListItemUM(
network = TextReference.Str(value.networkName),
symbol = TextReference.Str(value.earnToken.tokenSymbol),
tokenName = TextReference.Str(value.earnToken.tokenName),
currencyIconState = cryptoCurrencyToIconStateConverter.convert(value.cryptoCurrency),
earnValue = when (value.earnToken.rewardType) {
EarnRewardType.APR -> TextReference.Res(
id = R.string.staking_apr_earn_badge,
formatArgs = wrappedList(convertPercent(value.earnToken.apy)),
)
EarnRewardType.APY -> TextReference.Res(
id = R.string.yield_module_earn_badge,
formatArgs = wrappedList(convertPercent(value.earnToken.apy)),
)
},
earnType = when (value.earnToken.type) {
EarnType.STAKING -> TextReference.Res(R.string.common_staking)
EarnType.YIELD -> TextReference.Res(R.string.common_yield_mode)
},
onItemClick = { onItemClick(value) },
)
}
private fun convertPercent(value: String): TextReference {
val percent = BigDecimal(value).format { percent(withPercentSign = false) }
return TextReference.Str(percent)
}
}

View file

@ -1,131 +1,157 @@
package com.tangem.features.feed.model.earn
import androidx.compose.runtime.Stable
import arrow.core.Either
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.R
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.earn.usecase.*
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnNetworks
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.ui.earn.state.EarnBestOpportunitiesUM
import com.tangem.features.feed.ui.earn.state.EarnFilterByNetworkBottomSheetContentUM
import com.tangem.features.feed.ui.earn.state.EarnFilterByTypeBottomSheetContentUM
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.earn.state.EarnUM
import com.tangem.features.feed.components.feed.FeedPortfolioRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.model.earn.state.EarnStateController
import com.tangem.features.feed.model.earn.state.converter.EarnNetworksConverter
import com.tangem.features.feed.model.earn.state.transformers.*
import com.tangem.features.feed.model.earn.statemanager.EarnListBatchFlowManager
import com.tangem.features.feed.model.earn.statemanager.EarnListStateManager
import com.tangem.features.feed.ui.earn.state.*
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class EarnModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val fetchEarnNetworksUseCase: FetchEarnNetworksUseCase,
private val getEarnNetworksUseCase: GetEarnNetworksUseCase,
private val getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase,
private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase,
private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase,
private val appRouter: AppRouter,
private val stateController: EarnStateController,
) : Model() {
private val params = paramsContainer.require<DefaultEarnComponent.Params>()
private val earnNetworks = MutableStateFlow<EarnNetworks>(Either.Right(emptyList()))
private val earnNetworksConverter: EarnNetworksConverter by lazy { EarnNetworksConverter() }
private val earnListConfigProvider = Provider {
createEarnTokensListConfig(
selectedTypeFilter = stateController.value.selectedTypeFilter,
selectedNetworkFilter = stateController.value.selectedNetworkFilter,
earnNetworks = earnNetworks.value,
)
}
// TODO [REDACTED_TASK_KEY] remove mocks
private val mockNetworks: ImmutableList<EarnFilterNetworkUM> = persistentListOf(
EarnFilterNetworkUM.AllNetworks(
text = resourceReference(R.string.earn_filter_all_networks),
isSelected = false,
),
EarnFilterNetworkUM.MyNetworks(
text = resourceReference(R.string.earn_filter_my_networks),
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "ethereum",
text = stringReference("Ethereum"),
symbol = stringReference("ETH"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/ethereum.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "polygon",
text = stringReference("Polygon"),
symbol = stringReference("MATIC"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/matic-network.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "base",
text = stringReference("Base"),
symbol = stringReference("BASE"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/base.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "avalanche",
text = stringReference("Avalance"),
symbol = stringReference("AVAX"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/avalanche-2.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "cardano",
text = stringReference("Cardano"),
symbol = stringReference("ADA"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/cardano.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "bsc",
text = stringReference("Bitcoin Smart Chain"),
symbol = stringReference("BSC"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/binancecoin.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "bitcoin",
text = stringReference("Bitcoin"),
symbol = stringReference("BTC"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/bitcoin.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "solana",
text = stringReference("Solana"),
symbol = stringReference("SOL"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/solana.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "ton",
text = stringReference("Ton"),
symbol = stringReference("TON"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/the-open-network.png",
isSelected = false,
),
)
private val batchFlowManager by lazy {
EarnListBatchFlowManager(
getEarnTokensBatchFlowUseCase = getEarnTokensBatchFlowUseCase,
configProvider = earnListConfigProvider,
onItemClick = ::onEarnTokenClick,
modelScope = modelScope,
dispatchers = dispatchers,
)
}
val bottomSheetNavigation: SlotNavigation<FeedPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioPreselectedDataComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onSuccess(addedToken: CryptoCurrency, walletId: UserWalletId) {
bottomSheetNavigation.dismiss()
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = walletId,
currency = addedToken,
),
)
}
}
val state: StateFlow<EarnUM>
field = MutableStateFlow(createInitialState())
get() = stateController.uiState
private fun createInitialState(): EarnUM {
val defaultNetworkFilter = mockNetworks.first()
val defaultTypeFilter = EarnFilterTypeUM.All
init {
updateInitialState()
fetchEarnNetworks()
fetchTopEarnTokens()
subscribeOnNetworks()
subscribeOnBatchFlow()
subscribeToMostlyUsed()
}
return EarnUM(
mostlyUsed = EarnListUM.Loading,
bestOpportunities = EarnBestOpportunitiesUM.Loading,
selectedTypeFilter = defaultTypeFilter,
selectedNetworkFilter = defaultNetworkFilter,
filterByTypeBottomSheet = createTypeFilterBottomSheetConfig(defaultTypeFilter),
filterByNetworkBottomSheet = createNetworkFilterBottomSheetConfig(defaultNetworkFilter),
onBackClick = params.onBackClick,
onNetworkFilterClick = ::onNetworkFilterClick,
onTypeFilterClick = ::onTypeFilterClick,
)
private fun subscribeOnBatchFlow() {
combine(
batchFlowManager.uiItems,
batchFlowManager.initialLoadingError,
batchFlowManager.paginationStatus,
) { items, error, paginationStatus ->
val hasActiveFilters = state.value.selectedTypeFilter != EarnFilterTypeUM.All ||
state.value.selectedNetworkFilter !is EarnFilterNetworkUM.AllNetworks
EarnListStateManager.calculateState(
items = items,
error = error,
paginationStatus = paginationStatus,
hasActiveFilters = hasActiveFilters,
onRetryClick = { batchFlowManager.reload() },
onLoadMore = { batchFlowManager.loadMore() },
onClearFiltersClick = ::onClearFiltersClick,
)
}.onEach { bestOpportunitiesState ->
stateController.update(UpdateBestOpportunitiesStateTransformer(bestOpportunitiesState))
}
.onStart { batchFlowManager.reload() }
.launchIn(modelScope)
}
private fun subscribeToMostlyUsed() {
modelScope.launch(dispatchers.default) {
getTopEarnTokensUseCase().collect { earnResult ->
stateController.update(
UpdateMostlyUsedStateTransformer(
earnResult = earnResult,
onItemClick = ::onEarnTokenClick,
onRetryClick = ::fetchTopEarnTokens,
),
)
}
}
}
private fun subscribeOnNetworks() {
modelScope.launch(dispatchers.default) {
getEarnNetworksUseCase().collect(earnNetworks)
}
}
private fun fetchTopEarnTokens() {
modelScope.launch(dispatchers.default) {
fetchTopEarnTokensUseCase()
}
}
private fun fetchEarnNetworks() {
modelScope.launch(dispatchers.default) {
fetchEarnNetworksUseCase()
}
}
private fun createTypeFilterBottomSheetConfig(
@ -137,7 +163,7 @@ internal class EarnModel @Inject constructor(
onDismissRequest = ::hideTypeFilterBottomSheet,
content = EarnFilterByTypeBottomSheetContentUM(
selectedOption = selectedOption,
onOptionClick = ::onTypeFilterOptionClick,
onOptionClick = ::onTypeFilterOptionSelected,
),
)
}
@ -146,16 +172,40 @@ internal class EarnModel @Inject constructor(
selectedNetwork: EarnFilterNetworkUM,
isShown: Boolean = false,
): TangemBottomSheetConfig {
val updatedNetworks = mockNetworks.map { network ->
when {
network is EarnFilterNetworkUM.AllNetworks && selectedNetwork is EarnFilterNetworkUM.AllNetworks ->
network.copy(isSelected = true)
network is EarnFilterNetworkUM.MyNetworks && selectedNetwork is EarnFilterNetworkUM.MyNetworks ->
network.copy(isSelected = true)
network is EarnFilterNetworkUM.Network && selectedNetwork is EarnFilterNetworkUM.Network &&
network.id == selectedNetwork.id ->
network.copy(isSelected = true)
else -> network
val filters = buildList {
add(
EarnFilterNetworkUM.AllNetworks(
text = TextReference.Res(R.string.earn_filter_all_networks),
isSelected = false,
),
)
add(
EarnFilterNetworkUM.MyNetworks(
text = TextReference.Res(R.string.earn_filter_my_networks),
isSelected = false,
),
)
earnNetworks.value.onRight { networks ->
addAll(networks.map(earnNetworksConverter::convert))
}
}
val updatedNetworks = filters.map { filter ->
val shouldBeSelected = when (filter) {
is EarnFilterNetworkUM.AllNetworks -> selectedNetwork is EarnFilterNetworkUM.AllNetworks
is EarnFilterNetworkUM.MyNetworks -> selectedNetwork is EarnFilterNetworkUM.MyNetworks
is EarnFilterNetworkUM.Network ->
selectedNetwork is EarnFilterNetworkUM.Network && filter.id == selectedNetwork.id
}
if (filter.isSelected != shouldBeSelected) {
when (filter) {
is EarnFilterNetworkUM.AllNetworks -> filter.copy(isSelected = shouldBeSelected)
is EarnFilterNetworkUM.MyNetworks -> filter.copy(isSelected = shouldBeSelected)
is EarnFilterNetworkUM.Network -> filter.copy(isSelected = shouldBeSelected)
}
} else {
filter
}
}
@ -165,81 +215,108 @@ internal class EarnModel @Inject constructor(
content = EarnFilterByNetworkBottomSheetContentUM(
selectedNetwork = selectedNetwork,
networks = persistentListOf(*updatedNetworks.toTypedArray()),
onOptionClick = ::onNetworkFilterOptionClick,
onOptionClick = ::onNetworkFilterOptionSelected,
),
)
}
/* start of clicks area */
private fun onTypeFilterClick() {
state.update { currentState ->
currentState.copy(
filterByTypeBottomSheet = createTypeFilterBottomSheetConfig(
selectedOption = currentState.selectedTypeFilter,
isShown = true,
),
)
}
}
private fun onNetworkFilterClick() {
state.update { currentState ->
currentState.copy(
filterByNetworkBottomSheet = createNetworkFilterBottomSheetConfig(
selectedNetwork = currentState.selectedNetworkFilter,
isShown = true,
),
)
}
stateController.update(
EarnTypeFilterStateTransformer(
shouldShow = true,
filterByTypeBottomSheetConfig = ::createTypeFilterBottomSheetConfig,
),
)
}
private fun hideTypeFilterBottomSheet() {
state.update { currentState ->
currentState.copy(
filterByTypeBottomSheet = currentState.filterByTypeBottomSheet.copy(isShown = false),
)
}
stateController.update(
EarnTypeFilterStateTransformer(
shouldShow = false,
filterByTypeBottomSheetConfig = ::createTypeFilterBottomSheetConfig,
),
)
}
private fun onNetworkFilterClick() {
stateController.update(
EarnNetworkFilterStateTransformer(
shouldShow = true,
filterByNetworkBottomSheetConfig = ::createNetworkFilterBottomSheetConfig,
),
)
}
private fun hideNetworkFilterBottomSheet() {
state.update { currentState ->
currentState.copy(
filterByNetworkBottomSheet = currentState.filterByNetworkBottomSheet.copy(isShown = false),
)
}
stateController.update(
EarnNetworkFilterStateTransformer(
shouldShow = false,
filterByNetworkBottomSheetConfig = ::createNetworkFilterBottomSheetConfig,
),
)
}
private fun onTypeFilterOptionClick(type: EarnFilterTypeUM) {
state.update { currentState ->
currentState.copy(
selectedTypeFilter = type,
filterByTypeBottomSheet = createTypeFilterBottomSheetConfig(selectedOption = type),
)
}
private fun onTypeFilterOptionSelected(type: EarnFilterTypeUM) {
stateController.update(
EarnTypeFilterSelectedStateTransformer(
filter = type,
filterByTypeBottomSheetConfig = ::createTypeFilterBottomSheetConfig,
),
)
batchFlowManager.reload()
}
private fun onNetworkFilterOptionClick(network: EarnFilterNetworkUM) {
state.update { currentState ->
currentState.copy(
selectedNetworkFilter = network,
filterByNetworkBottomSheet = createNetworkFilterBottomSheetConfig(selectedNetwork = network),
)
}
private fun onNetworkFilterOptionSelected(network: EarnFilterNetworkUM) {
stateController.update(
EarnNetworkFilterSelectedStateTransformer(
filter = network,
filterByNetworkBottomSheetConfig = ::createNetworkFilterBottomSheetConfig,
),
)
batchFlowManager.reload()
}
// TODO [REDACTED_TASK_KEY] remove
@Suppress("UnusedPrivateMember")
private fun onClearFiltersClick() {
val defaultNetworkFilter = mockNetworks.first()
val defaultTypeFilter = EarnFilterTypeUM.All
state.update { currentState ->
currentState.copy(
selectedTypeFilter = defaultTypeFilter,
selectedNetworkFilter = defaultNetworkFilter,
filterByTypeBottomSheet = createTypeFilterBottomSheetConfig(selectedOption = defaultTypeFilter),
filterByNetworkBottomSheet = createNetworkFilterBottomSheetConfig(
selectedNetwork = defaultNetworkFilter,
stateController.update(
ClearEarnUMFilterStateTransformer(
filterByTypeBottomSheetConfig = ::createTypeFilterBottomSheetConfig,
filterByNetworkBottomSheetConfig = ::createNetworkFilterBottomSheetConfig,
),
)
batchFlowManager.reload()
}
private fun onEarnTokenClick(earnTokenWithCurrency: EarnTokenWithCurrency) {
bottomSheetNavigation.activate(
FeedPortfolioRoute.AddToPortfolio(
tokenToAdd = AddToPortfolioPreselectedDataComponent.TokenToAdd(
network = TokenMarketInfo.Network(
networkId = earnTokenWithCurrency.earnToken.networkId,
isExchangeable = false,
contractAddress = earnTokenWithCurrency.earnToken.tokenAddress,
decimalCount = earnTokenWithCurrency.earnToken.decimalCount,
),
id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId),
name = earnTokenWithCurrency.earnToken.tokenName,
symbol = earnTokenWithCurrency.earnToken.tokenSymbol,
),
)
}
),
)
}
/* end of clicks area */
private fun updateInitialState() {
stateController.update(
UpdateEarnUMInitialStateTransformer(
onBackClick = params.onBackClick,
onNetworkFilterClick = ::onNetworkFilterClick,
filterByTypeBottomSheetConfig = createTypeFilterBottomSheetConfig(state.value.selectedTypeFilter),
filterByNetworkBottomSheetConfig = createNetworkFilterBottomSheetConfig(
selectedNetwork = state.value.selectedNetworkFilter,
),
onTypeFilterClick = ::onTypeFilterClick,
),
)
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.feed.model.earn
import com.tangem.domain.earn.model.EarnTokensListConfig
import com.tangem.domain.models.earn.EarnNetwork
import com.tangem.domain.models.earn.EarnNetworks
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
internal fun createEarnTokensListConfig(
selectedTypeFilter: EarnFilterTypeUM,
selectedNetworkFilter: EarnFilterNetworkUM,
earnNetworks: EarnNetworks,
isForEarn: Boolean = false,
): EarnTokensListConfig {
val type = when (selectedTypeFilter) {
EarnFilterTypeUM.All -> null
EarnFilterTypeUM.Staking -> "staking"
EarnFilterTypeUM.YieldMode -> "yield"
}
val networks = when (selectedNetworkFilter) {
is EarnFilterNetworkUM.AllNetworks -> null
is EarnFilterNetworkUM.MyNetworks -> {
earnNetworks.fold(
ifLeft = { null },
ifRight = { networks ->
networks.filter(EarnNetwork::isAdded).map(EarnNetwork::networkId)
},
)
}
is EarnFilterNetworkUM.Network -> listOf(selectedNetworkFilter.id)
}
return EarnTokensListConfig(
type = type,
networks = networks,
isForEarn = isForEarn,
)
}

View file

@ -0,0 +1,48 @@
package com.tangem.features.feed.model.earn.state
import com.tangem.common.R
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.model.earn.state.transformers.EarnUMTransformer
import com.tangem.features.feed.ui.earn.state.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class EarnStateController @Inject constructor() {
private val mutableUiState: MutableStateFlow<EarnUM> = MutableStateFlow(value = getInitialState())
val uiState: StateFlow<EarnUM> get() = mutableUiState.asStateFlow()
val value: EarnUM get() = uiState.value
fun update(function: (EarnUM) -> EarnUM) {
mutableUiState.update(function = function)
}
fun update(transformer: EarnUMTransformer) {
mutableUiState.update(function = transformer::transform)
}
private fun getInitialState(): EarnUM {
return EarnUM(
mostlyUsed = EarnListUM.Loading,
bestOpportunities = EarnBestOpportunitiesUM.Loading,
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(
text = TextReference.Res(R.string.earn_filter_all_networks),
isSelected = true,
),
filterByTypeBottomSheet = TangemBottomSheetConfig.Empty,
filterByNetworkBottomSheet = TangemBottomSheetConfig.Empty,
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},
)
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.feed.model.earn.state.converter
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.domain.models.earn.EarnNetwork
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.utils.converter.Converter
internal class EarnNetworksConverter : Converter<EarnNetwork, EarnFilterNetworkUM.Network> {
override fun convert(value: EarnNetwork): EarnFilterNetworkUM.Network {
return EarnFilterNetworkUM.Network(
id = value.networkId,
text = TextReference.Str(value.fullName),
symbol = TextReference.Str(value.symbol),
iconRes = getActiveIconRes(Blockchain.fromNetworkId(value.networkId)?.id.orEmpty()),
isSelected = false,
)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.common.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class ClearEarnUMFilterStateTransformer(
private val filterByTypeBottomSheetConfig: (EarnFilterTypeUM, Boolean) -> TangemBottomSheetConfig,
private val filterByNetworkBottomSheetConfig: (EarnFilterNetworkUM, Boolean) -> TangemBottomSheetConfig,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
val defaultNetworksFilter = EarnFilterNetworkUM.AllNetworks(
text = TextReference.Res(R.string.earn_filter_all_networks),
isSelected = true,
)
return prevState.copy(
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = defaultNetworksFilter,
filterByTypeBottomSheet = filterByTypeBottomSheetConfig(EarnFilterTypeUM.All, false),
filterByNetworkBottomSheet = filterByNetworkBottomSheetConfig(defaultNetworksFilter, false),
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class EarnNetworkFilterSelectedStateTransformer(
private val filter: EarnFilterNetworkUM,
private val filterByNetworkBottomSheetConfig: (EarnFilterNetworkUM, Boolean) -> TangemBottomSheetConfig,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(
selectedNetworkFilter = filter,
filterByNetworkBottomSheet = filterByNetworkBottomSheetConfig(filter, false),
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class EarnNetworkFilterStateTransformer(
private val shouldShow: Boolean,
private val filterByNetworkBottomSheetConfig: (EarnFilterNetworkUM, Boolean) -> TangemBottomSheetConfig,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return if (shouldShow) {
prevState.copy(
filterByNetworkBottomSheet = filterByNetworkBottomSheetConfig(prevState.selectedNetworkFilter, true),
)
} else {
prevState.copy(filterByNetworkBottomSheet = prevState.filterByNetworkBottomSheet.copy(isShown = false))
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class EarnTypeFilterSelectedStateTransformer(
private val filter: EarnFilterTypeUM,
private val filterByTypeBottomSheetConfig: (EarnFilterTypeUM, Boolean) -> TangemBottomSheetConfig,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(
selectedTypeFilter = filter,
filterByTypeBottomSheet = filterByTypeBottomSheetConfig(filter, false),
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class EarnTypeFilterStateTransformer(
private val shouldShow: Boolean,
private val filterByTypeBottomSheetConfig: (EarnFilterTypeUM, Boolean) -> TangemBottomSheetConfig,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return if (shouldShow) {
prevState.copy(
filterByTypeBottomSheet = filterByTypeBottomSheetConfig(prevState.selectedTypeFilter, true),
)
} else {
prevState.copy(filterByTypeBottomSheet = prevState.filterByTypeBottomSheet.copy(isShown = false))
}
}
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.features.feed.ui.earn.state.EarnUM
internal interface EarnUMTransformer {
fun transform(prevState: EarnUM): EarnUM
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.features.feed.ui.earn.state.EarnBestOpportunitiesUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class UpdateBestOpportunitiesStateTransformer(
private val newState: EarnBestOpportunitiesUM,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(bestOpportunities = newState)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class UpdateEarnUMInitialStateTransformer(
private val onBackClick: () -> Unit,
private val filterByTypeBottomSheetConfig: TangemBottomSheetConfig,
private val filterByNetworkBottomSheetConfig: TangemBottomSheetConfig,
private val onNetworkFilterClick: () -> Unit,
private val onTypeFilterClick: () -> Unit,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(
onBackClick = onBackClick,
filterByNetworkBottomSheet = filterByNetworkBottomSheetConfig,
filterByTypeBottomSheet = filterByTypeBottomSheetConfig,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.features.feed.model.converter.EarnTokenWithCurrencyToListItemUMConverter
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.earn.state.EarnUM
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class UpdateMostlyUsedStateTransformer(
private val earnResult: EarnTopToken?,
private val onItemClick: (EarnTokenWithCurrency) -> Unit,
private val onRetryClick: () -> Unit,
) : EarnUMTransformer {
private val converter = EarnTokenWithCurrencyToListItemUMConverter(onItemClick = onItemClick)
override fun transform(prevState: EarnUM): EarnUM {
return when (earnResult) {
null -> prevState.copy(mostlyUsed = EarnListUM.Loading)
else -> earnResult.fold(
ifLeft = { prevState.copy(mostlyUsed = EarnListUM.Error(onRetryClicked = onRetryClick)) },
ifRight = { list ->
val newItems = list
.sortedWith(
compareByDescending<EarnTokenWithCurrency> {
it.earnToken.apy.toBigDecimalOrNull() ?: BigDecimal.ZERO
}.thenBy { it.earnToken.tokenName },
)
.map(converter::convert)
.toPersistentList()
prevState.copy(mostlyUsed = EarnListUM.Content(items = newItems))
},
)
}
}
}

View file

@ -0,0 +1,105 @@
package com.tangem.features.feed.model.earn.statemanager
import com.tangem.domain.earn.model.EarnTokensBatchingContext
import com.tangem.domain.earn.model.EarnTokensListConfig
import com.tangem.domain.earn.usecase.GetEarnTokensBatchFlowUseCase
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.features.feed.model.converter.EarnTokenWithCurrencyToListItemUMConverter
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.pagination.BatchAction
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
internal class EarnListBatchFlowManager(
getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase,
private val configProvider: Provider<EarnTokensListConfig>,
private val onItemClick: (EarnTokenWithCurrency) -> Unit,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, EarnTokensListConfig, Nothing>>()
private val converter = EarnTokenWithCurrencyToListItemUMConverter(onItemClick = onItemClick)
private val batchFlow = getEarnTokensBatchFlowUseCase(
context = EarnTokensBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = modelScope,
),
batchSize = DEFAULT_BATCH_SIZE,
)
val uiItems: StateFlow<ImmutableList<EarnListItemUM>> =
batchFlow.state
.scan(persistentListOf<EarnListItemUM>() to -1) { (accItems, lastProcessedBatchIndex), newState ->
if (newState.data.size <= lastProcessedBatchIndex) {
val newItems = newState.data
.flatMap { it.data }
.map(converter::convert)
.toPersistentList()
newItems to newState.data.lastIndex
} else {
val newBatches = newState.data.subList(lastProcessedBatchIndex + 1, newState.data.size)
val newItems = newBatches
.flatMap { it.data }
.map(converter::convert)
accItems.addAll(newItems) to newState.data.lastIndex
}
}
.map { (items, _) -> items }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = persistentListOf(),
)
val initialLoadingError: StateFlow<Throwable?> = batchFlow.state
.map { state ->
val status = state.status
if (status is PaginationStatus.InitialLoadingError) {
status.throwable
} else {
null
}
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
val paginationStatus: StateFlow<PaginationStatus<List<EarnTokenWithCurrency>>> = batchFlow.state
.map { it.status }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = PaginationStatus.InitialLoading,
)
fun reload() {
modelScope.launch(dispatchers.default) {
actionsFlow.emit(
BatchAction.Reload(requestParams = configProvider()),
)
}
}
fun loadMore() {
modelScope.launch(dispatchers.default) {
actionsFlow.emit(BatchAction.LoadMore())
}
}
private companion object {
private const val DEFAULT_BATCH_SIZE = 20
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.feed.model.earn.statemanager
import com.tangem.pagination.PaginationStatus
import com.tangem.features.feed.ui.earn.state.EarnBestOpportunitiesUM
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import kotlinx.collections.immutable.ImmutableList
@Suppress("LongParameterList")
internal object EarnListStateManager {
fun calculateState(
items: ImmutableList<EarnListItemUM>,
error: Throwable?,
paginationStatus: PaginationStatus<*>,
hasActiveFilters: Boolean,
onRetryClick: () -> Unit,
onLoadMore: () -> Unit,
onClearFiltersClick: () -> Unit,
): EarnBestOpportunitiesUM = when {
error != null -> EarnBestOpportunitiesUM.Error(onRetryClicked = onRetryClick)
paginationStatus is PaginationStatus.InitialLoading && items.isEmpty() ->
EarnBestOpportunitiesUM.Loading
items.isEmpty() && hasActiveFilters ->
EarnBestOpportunitiesUM.EmptyFiltered(onClearFilterClick = onClearFiltersClick)
items.isEmpty() -> EarnBestOpportunitiesUM.Empty
else -> EarnBestOpportunitiesUM.Content(items = items, onLoadMore = onLoadMore)
}
}

View file

@ -1,19 +1,10 @@
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.models.earn.EarnRewardType
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.earn.EarnType
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.features.feed.model.converter.EarnTokenWithCurrencyToListItemUMConverter
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
@ -24,7 +15,7 @@ internal class UpdateEarnStateTransformer(
private val earnResult: EarnTopToken?,
) : FeedListUMTransformer {
private val earnTokenWithCurrencyConverter = EarnTokenWithCurrencyConverter(onItemClick = onItemClick)
private val earnTokenWithCurrencyConverter = EarnTokenWithCurrencyToListItemUMConverter(onItemClick = onItemClick)
override fun transform(prevState: FeedListUM): FeedListUM {
if (!isEarnEnabled) return handleEmptyState(prevState)
@ -60,52 +51,22 @@ internal class UpdateEarnStateTransformer(
currentState: FeedListUM,
earnTokensWithCurrency: List<EarnTokenWithCurrency>,
): FeedListUM {
val newItems = earnTokensWithCurrency
.sortedWith(
compareByDescending<EarnTokenWithCurrency> {
it.earnToken.apy.toBigDecimalOrNull() ?: BigDecimal.ZERO
}.thenBy { it.earnToken.tokenName },
)
.map(earnTokenWithCurrencyConverter::convert)
.toPersistentList()
val newEarnListUM = when (val earnState = currentState.earnListUM) {
is EarnListUM.Content -> earnState.copy(items = newItems)
else -> EarnListUM.Content(items = newItems)
}
return currentState.copy(
earnListUM = EarnListUM.Content(
items = earnTokensWithCurrency
.sortedWith(
compareByDescending<EarnTokenWithCurrency> {
it.earnToken.apy.toBigDecimalOrNull() ?: BigDecimal.ZERO
}.thenBy { it.earnToken.tokenName },
)
.map { earnToken ->
earnTokenWithCurrencyConverter.convert(earnToken)
}
.toPersistentList(),
),
earnListUM = newEarnListUM,
)
}
private class EarnTokenWithCurrencyConverter(
private val onItemClick: (EarnTokenWithCurrency) -> Unit,
) : Converter<EarnTokenWithCurrency, EarnListItemUM> {
override fun convert(value: EarnTokenWithCurrency): EarnListItemUM {
return EarnListItemUM(
network = TextReference.Str(value.networkName),
symbol = TextReference.Str(value.earnToken.tokenSymbol),
tokenName = TextReference.Str(value.earnToken.tokenName),
currencyIconState = CryptoCurrencyToIconStateConverter().convert(value.cryptoCurrency),
earnValue = when (value.earnToken.rewardType) {
EarnRewardType.APR -> TextReference.Res(
id = R.string.staking_apr_earn_badge,
formatArgs = wrappedList(convertPercent(value.earnToken.apy)),
)
EarnRewardType.APY -> TextReference.Res(
id = R.string.yield_module_earn_badge,
formatArgs = wrappedList(convertPercent(value.earnToken.apy)),
)
},
earnType = when (value.earnToken.type) {
EarnType.STAKING -> TextReference.Res(R.string.common_staking)
EarnType.YIELD -> TextReference.Res(R.string.common_yield_mode)
},
onItemClick = { onItemClick(value) },
)
}
private fun convertPercent(value: String): TextReference {
val percent = BigDecimal(value).format { percent(withPercentSign = false) }
return TextReference.Str(percent)
}
}
}

View file

@ -15,6 +15,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -55,13 +56,22 @@ internal open class NewsListBatchFlowManager(
initialValue = persistentListOf(),
)
val uiItems: StateFlow<ImmutableList<ArticleConfigUM>>
get() = batchFlow.state
.map { batchListState ->
batchListState.data
.flatMap { batch -> batch.data }
.let { articles -> converter.convert(articles) }
val uiItems: StateFlow<ImmutableList<ArticleConfigUM>> =
batchFlow.state
.scan(persistentListOf<ArticleConfigUM>() to -1) { (accItems, lastProcessedBatchIndex), newState ->
if (newState.data.size <= lastProcessedBatchIndex) {
val newItems = converter.convert(newState.data.flatMap { it.data })
.toPersistentList()
newItems to newState.data.lastIndex
} else {
val newBatches = newState.data.subList(lastProcessedBatchIndex + 1, newState.data.size)
val newShortArticles = newBatches.flatMap { it.data }
val newItems = converter.convert(newShortArticles)
accItems.addAll(newItems) to newState.data.lastIndex
}
}
.map { (items, _) -> items }
.distinctUntilChanged()
.stateIn(
scope = modelScope,

View file

@ -5,14 +5,13 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
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.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -20,53 +19,49 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
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.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SmallButtonShimmer
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.ui.earn.components.EarnFilterByNetworkBottomSheet
import com.tangem.features.feed.ui.earn.components.EarnFilterByTypeBottomSheet
import com.tangem.features.feed.ui.earn.components.EarnItemPlaceholder
import com.tangem.features.feed.ui.earn.components.EarnListItem
import com.tangem.features.feed.ui.earn.components.MostlyUsedPlaceholder
import com.tangem.features.feed.ui.earn.state.EarnBestOpportunitiesUM
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.earn.state.EarnUM
import com.tangem.features.feed.ui.earn.components.*
import com.tangem.features.feed.ui.earn.state.*
import kotlinx.collections.immutable.persistentListOf
private const val EARN_LOAD_MORE_BUFFER = 3
@Composable
internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
val listState = rememberLazyListState()
EarnFilterByTypeBottomSheet(config = state.filterByTypeBottomSheet)
EarnFilterByNetworkBottomSheet(config = state.filterByNetworkBottomSheet)
if (state.bestOpportunities is EarnBestOpportunitiesUM.Content) {
PaginationHandler(
listState = listState,
state = state.bestOpportunities,
)
}
LazyColumn(
state = listState,
modifier = modifier
.fillMaxSize()
.background(background),
@ -109,7 +104,10 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
@Composable
private fun MostlyUsedContent(state: EarnListUM) {
AnimatedContent(state) { st ->
AnimatedContent(
targetState = state,
contentKey = { it::class.java },
) { st ->
when (st) {
is EarnListUM.Loading -> {
MostlyUsedPlaceholder()
@ -174,9 +172,11 @@ private fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier:
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
@ -207,34 +207,15 @@ private fun BestOpportunitiesFilters(
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
) {
AnimatedContent(state) { st ->
when (st) {
is EarnBestOpportunitiesUM.Loading -> {
FilterButtonsShimmer()
}
is EarnBestOpportunitiesUM.EmptyFiltered,
is EarnBestOpportunitiesUM.Content,
-> {
FilterButtons(
selectedNetworkFilterText = selectedNetworkFilterText,
selectedTypeFilterText = selectedTypeFilterText,
isEnabled = true,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
}
is EarnBestOpportunitiesUM.Empty,
is EarnBestOpportunitiesUM.Error,
-> {
FilterButtons(
selectedNetworkFilterText = selectedNetworkFilterText,
selectedTypeFilterText = selectedTypeFilterText,
isEnabled = false,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
}
}
when (state) {
is EarnBestOpportunitiesUM.Loading -> FilterButtonsShimmer()
else -> FilterButtons(
selectedNetworkFilterText = selectedNetworkFilterText,
selectedTypeFilterText = selectedTypeFilterText,
isEnabled = state is EarnBestOpportunitiesUM.Content || state is EarnBestOpportunitiesUM.EmptyFiltered,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
}
}
@ -424,13 +405,28 @@ private fun SectionHeader(title: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
.padding(start = 20.dp, end = 16.dp),
text = title,
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
}
@Composable
private fun PaginationHandler(listState: LazyListState, state: EarnBestOpportunitiesUM.Content) {
InfiniteListHandler(
listState = listState,
buffer = EARN_LOAD_MORE_BUFFER,
triggerLoadMoreCheckOnItemsCountChange = true,
onLoadMore = remember(state) {
{
state.onLoadMore()
true
}
},
)
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -465,6 +461,7 @@ private fun EarnContentPreview() {
network = "Ethereum Network",
),
),
onLoadMore = {},
),
),
)

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui.earn.components
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -13,14 +14,15 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.inputrow.InputRowChecked
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.components.rows.RowContentContainer
@ -165,12 +167,10 @@ private fun LazyListScope.specificNetworksList(
.heightIn(52.dp)
.padding(horizontal = 12.dp),
icon = {
CoinIcon(
Image(
modifier = Modifier.size(22.dp),
url = item.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
imageVector = ImageVector.vectorResource(item.iconRes),
contentDescription = item.symbol.resolveReference(),
)
},
text = {
@ -225,14 +225,14 @@ private fun Preview() {
id = "ethereum",
text = stringReference("Ethereum"),
symbol = stringReference("ETH"),
iconUrl = null,
iconRes = R.drawable.img_btc_22,
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "polygon",
text = stringReference("Polygon"),
symbol = stringReference("MATIC"),
iconUrl = null,
iconRes = R.drawable.img_btc_22,
isSelected = false,
),
),

View file

@ -23,7 +23,7 @@ internal sealed class EarnFilterNetworkUM {
val id: String,
override val text: TextReference,
val symbol: TextReference,
val iconUrl: String?,
val iconRes: Int,
override val isSelected: Boolean,
) : EarnFilterNetworkUM()
}

View file

@ -17,7 +17,10 @@ internal sealed interface EarnBestOpportunitiesUM {
data object Loading : EarnBestOpportunitiesUM
data object Empty : EarnBestOpportunitiesUM
data class EmptyFiltered(val onClearFilterClick: () -> Unit) : EarnBestOpportunitiesUM
data class Content(val items: ImmutableList<EarnListItemUM>) : EarnBestOpportunitiesUM
data class Content(
val items: ImmutableList<EarnListItemUM>,
val onLoadMore: () -> Unit,
) : EarnBestOpportunitiesUM
data class Error(val onRetryClicked: () -> Unit) : EarnBestOpportunitiesUM
}

View file

@ -45,10 +45,13 @@ internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM?, modif
modifier = Modifier.padding(horizontal = 16.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
AnimatedContent(targetState = earnListUM) { earnListState ->
when (earnListState) {
is EarnListUM.Content -> EarnContentBlock(items = earnListState.items)
is EarnListUM.Error -> EarnErrorBlock(onRetryClick = earnListState.onRetryClicked)
AnimatedContent(
targetState = earnListUM,
contentKey = { it::class.java },
) { earnListUM ->
when (earnListUM) {
is EarnListUM.Content -> EarnContentBlock(items = earnListUM.items)
is EarnListUM.Error -> EarnErrorBlock(onRetryClick = earnListUM.onRetryClicked)
EarnListUM.Loading -> EarnListPlaceholder(placeholderCount = PLACEHOLDER_ITEM_COUNT)
}
}

View file

@ -52,9 +52,7 @@ internal class DefaultTokenReceiveComponent @AssistedInject constructor(
)
}
override fun dismiss() {
model.params.onDismiss()
}
override fun dismiss() = model.params.onDismiss()
private fun onChildBack() {
when (contentStack.value.active.configuration) {