Updated on 2026-08-14

This commit is contained in:
Tangem 2024-09-27 13:54:41 +03:00
parent 67be280129
commit deb6d570b0
20 changed files with 111 additions and 105 deletions

View file

@ -16,7 +16,7 @@ import retrofit2.http.*
interface StakeKitApi {
@GET("yields/enabled")
suspend fun getMultipleYields(
suspend fun getEnabledYields(
@Query("preferredValidatorsOnly") preferredValidatorsOnly: Boolean? = null,
@Query("ledgerWalletAPICompatible") ledgerWalletAPICompatible: Boolean? = null,
@Query("type") type: YieldType? = null,

View file

@ -5,7 +5,7 @@ import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -29,9 +29,13 @@ internal class DefaultStakingBalanceStore(
}
}
override fun get(userWalletId: UserWalletId, address: String, integrationId: String): Flow<YieldBalanceWrapperDTO> {
override fun get(
userWalletId: UserWalletId,
address: String,
integrationId: String,
): Flow<YieldBalanceWrapperDTO?> {
return dataStore.get(userWalletId.stringValue)
.mapNotNull { balances ->
.map { balances ->
balances.firstOrNull { it.integrationId == integrationId && it.addresses.address == address }
}
}

View file

@ -12,7 +12,7 @@ interface StakingBalanceStore {
suspend fun store(userWalletId: UserWalletId, items: Set<YieldBalanceWrapperDTO>)
fun get(userWalletId: UserWalletId, address: String, integrationId: String): Flow<YieldBalanceWrapperDTO>
fun get(userWalletId: UserWalletId, address: String, integrationId: String): Flow<YieldBalanceWrapperDTO?>
suspend fun getSyncOrNull(
userWalletId: UserWalletId,

View file

@ -111,7 +111,7 @@ internal class DefaultStakingRepository(
rawNetworkId.plus(rawCurrencyId)
}
override fun isStakingSupported(integrationKey: String): Boolean {
override fun isStakingSupportedInMobileApp(integrationKey: String): Boolean {
return integrationIdMap.containsKey(integrationKey)
}
@ -121,7 +121,7 @@ internal class DefaultStakingRepository(
key = YIELDS_STORE_KEY,
skipCache = refresh,
block = {
val stakingTokensWithYields = stakeKitApi.getMultipleYields(preferredValidatorsOnly = true)
val stakingTokensWithYields = stakeKitApi.getEnabledYields(preferredValidatorsOnly = true)
.getOrThrow()
stakingYieldsStore.store(stakingTokensWithYields.data)
@ -166,17 +166,22 @@ internal class DefaultStakingRepository(
val rawCurrencyId = cryptoCurrency.id.rawCurrencyId ?: return StakingAvailability.Unavailable
return withContext(dispatchers.io) {
val yields = getEnabledYields() ?: return@withContext StakingAvailability.Unavailable
val isSupportedInMobileApp = isStakingSupportedInMobileApp(getIntegrationKey(cryptoCurrency.id))
val yields = getEnabledYields() ?: return@withContext if (isSupportedInMobileApp) {
StakingAvailability.TemporaryUnavailable
} else {
StakingAvailability.Unavailable
}
val prefetchedYield = findPrefetchedYield(yields, rawCurrencyId, cryptoCurrency.symbol)
val isSupported = isStakingSupported(getIntegrationKey(cryptoCurrency.id))
when {
prefetchedYield != null && isSupported -> {
prefetchedYield != null && isSupportedInMobileApp -> {
StakingAvailability.Available(prefetchedYield.id)
}
prefetchedYield == null && isSupported -> {
StakingAvailability.TemporaryDisabled
prefetchedYield == null && isSupportedInMobileApp -> {
StakingAvailability.TemporaryUnavailable
}
else -> StakingAvailability.Unavailable
}
@ -336,8 +341,13 @@ internal class DefaultStakingRepository(
val integrationId = integrationIdMap[getIntegrationKey(cryptoCurrency.id)]
?: error("Could not get integrationId")
stakingBalanceStore.get(userWalletId, address, integrationId)
.distinctUntilChanged()
.collectLatest {
send(yieldBalanceConverter.convert(it))
if (it != null) {
send(yieldBalanceConverter.convert(it))
} else {
error("No yield balance available for currency ${cryptoCurrency.id.value}")
}
}
}
@ -385,12 +395,13 @@ internal class DefaultStakingRepository(
key = getYieldBalancesKey(userWalletId),
skipCache = refresh,
block = {
val yields = getEnabledYields() ?: error("No yields found")
val availableCurrencies = cryptoCurrencies
.mapNotNull { currency ->
val addresses = walletManagersFacade.getAddresses(userWalletId, currency.network)
val integrationId = integrationIdMap[getIntegrationKey(currency.id)]
if (integrationId != null) {
if (integrationId != null && yields.any { it.id == integrationId }) {
addresses to integrationId
} else {
null

View file

@ -6,5 +6,5 @@ sealed class StakingAvailability {
data object Unavailable : StakingAvailability()
data object TemporaryDisabled : StakingAvailability()
data object TemporaryUnavailable : StakingAvailability()
}

View file

@ -23,7 +23,7 @@ interface StakingRepository {
fun getIntegrationKey(cryptoCurrencyId: CryptoCurrency.ID): String
fun isStakingSupported(integrationKey: String): Boolean
fun isStakingSupportedInMobileApp(integrationKey: String): Boolean
suspend fun fetchEnabledYields(refresh: Boolean)

View file

@ -146,7 +146,7 @@ internal class CurrenciesStatusesLceOperations(
val quote = quotes?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId }
val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network }
val address = extractAddress(networkStatus)
val isStakingSupported = stakingRepository.isStakingSupported(
val isStakingSupported = stakingRepository.isStakingSupportedInMobileApp(
stakingRepository.getIntegrationKey(currency.id),
)
val yieldBalance = if (isStakingSupported) {

View file

@ -271,7 +271,7 @@ internal class CurrenciesStatusesOperations(
val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network }
val address = extractAddress(networkStatus)
val isStakingSupported = stakingRepository.isStakingSupported(
val isStakingSupported = stakingRepository.isStakingSupportedInMobileApp(
stakingRepository.getIntegrationKey(currency.id),
)
val yieldBalance = if (isStakingSupported) {

View file

@ -26,7 +26,7 @@ class MockStakingRepository : StakingRepository {
override fun getIntegrationKey(cryptoCurrencyId: CryptoCurrency.ID): String = ""
override fun isStakingSupported(currencyId: String): Boolean = true
override fun isStakingSupportedInMobileApp(integrationKey: String): Boolean = true
override suspend fun fetchEnabledYields(refresh: Boolean) {
/* no-op */

View file

@ -139,8 +139,8 @@ internal object TokenDetailsPreviewData {
private val marketPriceLoading = MarketPriceBlockState.Loading(currencySymbol = "USDT")
val stakingTemporaryUnavailableBlock = StakingBlockUM.TemporaryUnavailable
val stakingLoadingBlock = StakingBlockUM.Loading(iconState)
val stakingErrorBlock = StakingBlockUM.Error(iconState)
val stakingAvailableBlock = StakingBlockUM.StakeAvailable(
titleText = resourceReference(
@ -312,7 +312,6 @@ internal object TokenDetailsPreviewData {
bottomSheetConfig = null,
isBalanceHidden = false,
isMarketPriceAvailable = false,
isStakingBlockShown = false,
event = consumedEvent(),
)
@ -343,7 +342,6 @@ internal object TokenDetailsPreviewData {
bottomSheetConfig = null,
isBalanceHidden = false,
isMarketPriceAvailable = true,
isStakingBlockShown = true,
event = consumedEvent(),
)

View file

@ -6,7 +6,8 @@ import java.math.BigDecimal
@Stable
internal sealed interface StakingBlockUM {
data class Error(val iconState: IconState) : StakingBlockUM
data object TemporaryUnavailable : StakingBlockUM
data class Loading(val iconState: IconState) : StakingBlockUM

View file

@ -17,7 +17,7 @@ internal data class TokenDetailsState(
val tokenInfoBlockState: TokenInfoBlockState,
val tokenBalanceBlockState: TokenDetailsBalanceBlockState,
val marketPriceBlockState: MarketPriceBlockState,
val stakingBlocksState: StakingBlockUM,
val stakingBlocksState: StakingBlockUM?,
val notifications: ImmutableList<TokenDetailsNotification>,
val pendingTxs: PersistentList<TransactionState>,
val swapTxs: PersistentList<SwapTransactionsState>,
@ -27,6 +27,5 @@ internal data class TokenDetailsState(
val bottomSheetConfig: TangemBottomSheetConfig?,
val isBalanceHidden: Boolean,
val isMarketPriceAvailable: Boolean,
val isStakingBlockShown: Boolean,
val event: StateEvent<TextReference>,
)

View file

@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.stakekit.RewardBlockType
import com.tangem.domain.staking.model.stakekit.YieldBalance
@ -37,6 +38,7 @@ internal class TokenDetailsLoadedBalanceConverter(
private val currentStateProvider: Provider<TokenDetailsState>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val stakingEntryInfoProvider: Provider<StakingEntryInfo?>,
private val stakingAvailabilityProvider: Provider<StakingAvailability>,
private val symbol: String,
private val decimals: Int,
private val clickIntents: TokenDetailsClickIntents,
@ -57,7 +59,6 @@ internal class TokenDetailsLoadedBalanceConverter(
private fun convertError(): TokenDetailsState {
val state = currentStateProvider()
return state.copy(
isStakingBlockShown = false,
tokenBalanceBlockState = TokenDetailsBalanceBlockState.Error(
actionButtons = state.tokenBalanceBlockState.actionButtons,
balanceSegmentedButtonConfig = state.tokenBalanceBlockState.balanceSegmentedButtonConfig,
@ -137,19 +138,26 @@ internal class TokenDetailsLoadedBalanceConverter(
}
}
private fun getYieldBalance(status: CryptoCurrencyStatus, state: TokenDetailsState): StakingBlockUM {
private fun getYieldBalance(status: CryptoCurrencyStatus, state: TokenDetailsState): StakingBlockUM? {
val yieldBalance = status.value.yieldBalance as? YieldBalance.Data
val stakingCryptoAmount = yieldBalance?.getTotalStakingBalance()
val stakingEntryInfo = stakingEntryInfoProvider.invoke()
val stakingAvailability = stakingAvailabilityProvider.invoke()
val iconState = state.tokenInfoBlockState.iconState
return when {
stakingAvailability == StakingAvailability.TemporaryUnavailable -> {
StakingBlockUM.TemporaryUnavailable
}
stakingAvailability == StakingAvailability.Unavailable -> {
null
}
stakingCryptoAmount.isNullOrZero() && stakingEntryInfo != null -> {
getStakeAvailableState(stakingEntryInfo, iconState)
}
stakingCryptoAmount.isNullOrZero() && stakingEntryInfo == null -> {
StakingBlockUM.Error(iconState = iconState)
null
}
else -> {
val fiatRate = status.value.fiatRate

View file

@ -73,7 +73,6 @@ internal class TokenDetailsSkeletonStateConverter(
bottomSheetConfig = null,
isBalanceHidden = true,
isMarketPriceAvailable = value.id.rawCurrencyId != null,
isStakingBlockShown = false,
event = consumedEvent(),
)
}

View file

@ -19,7 +19,6 @@ import com.tangem.domain.card.NetworkHasDerivationUseCase
import com.tangem.domain.common.CardTypesResolver
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.*
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
@ -50,6 +49,7 @@ internal class TokenDetailsStateFactory(
private val currentStateProvider: Provider<TokenDetailsState>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val stakingEntryInfoProvider: Provider<StakingEntryInfo?>,
private val stakingAvailabilityProvider: Provider<StakingAvailability>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
private val clickIntents: TokenDetailsClickIntents,
private val networkHasDerivationUseCase: NetworkHasDerivationUseCase,
@ -78,6 +78,7 @@ internal class TokenDetailsStateFactory(
currentStateProvider = currentStateProvider,
appCurrencyProvider = appCurrencyProvider,
stakingEntryInfoProvider = stakingEntryInfoProvider,
stakingAvailabilityProvider = stakingAvailabilityProvider,
symbol = symbol,
decimals = decimals,
clickIntents = clickIntents,
@ -114,13 +115,6 @@ internal class TokenDetailsStateFactory(
)
}
private val stakingStateConverter by lazy {
TokenStakingStateConverter(
currentStateProvider = currentStateProvider,
clickIntents = clickIntents,
)
}
private val balanceSelectStateConverter by lazy {
TokenDetailsBalanceSelectStateConverter(
currentStateProvider = currentStateProvider,
@ -233,18 +227,6 @@ internal class TokenDetailsStateFactory(
)
}
fun getStateWithUpdatedStakingAvailability(stakingAvailability: StakingAvailability): TokenDetailsState {
return currentStateProvider().copy(
isStakingBlockShown = stakingAvailability != StakingAvailability.Unavailable,
)
}
fun getStateWithStaking(stakingEither: Either<StakingError, StakingEntryInfo>): TokenDetailsState {
return currentStateProvider().copy(
stakingBlocksState = stakingStateConverter.convert(stakingEither),
)
}
fun getRefreshingState(): TokenDetailsState {
return refreshStateConverter.convert(true)
}

View file

@ -1,50 +0,0 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import arrow.core.Either
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.feature.tokendetails.presentation.tokendetails.state.StakingBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class TokenStakingStateConverter(
private val clickIntents: TokenDetailsClickIntents,
private val currentStateProvider: Provider<TokenDetailsState>,
) : Converter<Either<StakingError, StakingEntryInfo>, StakingBlockUM> {
override fun convert(value: Either<StakingError, StakingEntryInfo>): StakingBlockUM {
val state = currentStateProvider()
if (state.stakingBlocksState is StakingBlockUM.Staked) return state.stakingBlocksState
val iconState = state.tokenInfoBlockState.iconState
return value.fold(
ifLeft = {
StakingBlockUM.Error(iconState = iconState)
},
ifRight = { stakingEntryInfo ->
val apr = BigDecimalFormatter.formatPercent(
percent = stakingEntryInfo.apr,
useAbsoluteValue = true,
)
StakingBlockUM.StakeAvailable(
titleText = resourceReference(
id = R.string.token_details_staking_block_title,
formatArgs = wrappedList(apr),
),
subtitleText = resourceReference(
id = R.string.staking_notification_earn_rewards_text,
formatArgs = wrappedList(stakingEntryInfo.tokenSymbol),
),
iconState = iconState,
onStakeClicked = clickIntents::onStakeBannerClick,
)
},
)
}
}

View file

@ -164,7 +164,7 @@ internal fun TokenDetailsScreen(state: TokenDetailsState, tokenMarketBlockCompon
}
}
if (state.isStakingBlockShown) {
if (state.stakingBlocksState != null) {
item(
key = StakingBlockUM::class.java,
contentType = StakingBlockUM::class.java,

View file

@ -0,0 +1,51 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.tokendetails.impl.R
@Composable
internal fun StakingTemporaryUnavailableBlock(modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
modifier = modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.primary)
.padding(TangemTheme.dimens.spacing12),
) {
Text(
text = stringResource(R.string.staking_native),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Text(
text = stringResource(R.string.staking_notification_network_error_text),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun StakingTemporaryUnavailableBlock_Preview() {
TangemThemePreview {
StakingTemporaryUnavailableBlock(
modifier = Modifier.padding(TangemTheme.dimens.spacing16),
)
}
}
// endregion

View file

@ -23,8 +23,8 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.getGreyScaleColorFilter
import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData.stakingAvailableBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData.stakingErrorBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData.stakingLoadingBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData.stakingTemporaryUnavailableBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.state.IconState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.StakingBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.CurrencyIcon
@ -45,7 +45,7 @@ internal fun TokenStakingBlock(state: StakingBlockUM, isBalanceHidden: Boolean,
label = "Staking block animation",
) {
when (it) {
is StakingBlockUM.Error -> Row {} // TODO staking error
is StakingBlockUM.TemporaryUnavailable -> StakingTemporaryUnavailableBlock(modifier)
is StakingBlockUM.Loading -> StakingLoading(
iconState = it.iconState,
modifier = modifier,
@ -185,7 +185,7 @@ private class StakingBlockStateProvider : CollectionPreviewParameterProvider<Sta
collection = listOf(
stakingLoadingBlock,
stakingAvailableBlock,
stakingErrorBlock,
stakingTemporaryUnavailableBlock,
),
)
// endregion Preview

View file

@ -149,12 +149,14 @@ internal class TokenDetailsViewModel @Inject constructor(
private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null
private var stakingEntryInfo: StakingEntryInfo? = null
private var stakingAvailability: StakingAvailability = StakingAvailability.Unavailable
private var swapTxStatusTaskScheduler = SingleTaskScheduler<PersistentList<SwapTransactionsState>>()
private val stateFactory = TokenDetailsStateFactory(
currentStateProvider = Provider { uiState.value },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
stakingEntryInfoProvider = Provider { stakingEntryInfo },
stakingAvailabilityProvider = Provider { stakingAvailability },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
clickIntents = this,
networkHasDerivationUseCase = networkHasDerivationUseCase,
@ -389,12 +391,13 @@ internal class TokenDetailsViewModel @Inject constructor(
private fun updateStakingInfo() {
viewModelScope.launch {
val stakingAvailability = getStakingAvailabilityUseCase(
val availability = getStakingAvailabilityUseCase(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
).getOrElse { StakingAvailability.Unavailable }
internalUiState.value = stateFactory.getStateWithUpdatedStakingAvailability(stakingAvailability)
stakingAvailability = availability
if (stakingAvailability is StakingAvailability.Available) {
val stakingInfo = getStakingEntryInfoUseCase(
cryptoCurrencyId = cryptoCurrency.id,