Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-25 09:42:40 +02:00
parent 072d04125d
commit e00fd4599e
14 changed files with 492 additions and 253 deletions

View file

@ -199,14 +199,21 @@ internal class StakingModel @Inject constructor(
}
private var appCurrency: AppCurrency by Delegates.notNull()
private val balancesToShow: List<BalanceItem>
private val balancesToShow: List<StakingBalanceEntry>
get() {
val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit
return invalidatePendingTransactionsUseCase(
balanceItems = stakeKitBalance?.balance?.items.orEmpty(),
stakingActions = stakingActions,
token = integration.token,
).getOrElse { emptyList() }
val stakingBalance = cryptoCurrencyStatus.value.stakingBalance
return when (stakingBalance) {
is StakingBalance.Data.StakeKit -> {
val invalidatedItems = invalidatePendingTransactionsUseCase(
balanceItems = stakingBalance.balance.items,
stakingActions = stakingActions,
token = integration.token,
).getOrElse { emptyList() }
invalidatedItems.toStakingBalanceEntries()
}
is StakingBalance.Data.P2PEthPool -> stakingBalance.entries
else -> emptyList()
}
}
private var isInitialInfoAnalyticSent: Boolean = false

View file

@ -1,177 +0,0 @@
package com.tangem.features.staking.impl.presentation.state.converters
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.BalanceItem
import com.tangem.domain.models.staking.BalanceType
import com.tangem.domain.models.staking.BalanceType.Companion.isClickable
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.action.StakingActionType
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.utils.getRewardStakingBalance
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.isTon
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toPersistentList
import kotlinx.datetime.Instant
import java.math.BigDecimal
import java.util.Calendar
internal class BalanceItemConverter(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrencyProvider: Provider<AppCurrency>,
private val integration: StakingIntegration,
) : Converter<BalanceItem, BalanceState?> {
override fun convert(value: BalanceItem): BalanceState? {
val appCurrency = appCurrencyProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val target = integration.targets.firstOrNull {
value.validatorAddress?.contains(it.address, ignoreCase = true) == true
}
val cryptoAmount = value.getBalanceValue()
val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount)
val title = value.type.getTitle(target?.name)
return title?.let {
BalanceState(
groupId = value.groupId,
target = target,
title = title,
subtitle = getSubtitle(value),
type = value.type,
cryptoValue = cryptoAmount.parseBigDecimal(cryptoCurrency.decimals),
cryptoAmount = cryptoAmount,
formattedCryptoAmount = stringReference(
cryptoAmount.format { crypto(cryptoCurrency) },
),
fiatAmount = fiatAmount,
formattedFiatAmount = stringReference(
fiatAmount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
),
rawCurrencyId = value.rawCurrencyId,
pendingActions = value.pendingActions.toPersistentList(),
isClickable = value.isClickable(),
isPending = value.isPending,
targetAddress = value.validatorAddress,
)
}
}
private fun BalanceItem.getBalanceValue(): BigDecimal {
val isIncludeStakingTotalBalance = BlockchainUtils.isIncludeStakingTotalBalance(
blockchainId = cryptoCurrencyStatus.currency.network.rawId,
)
val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit
return if (isIncludeStakingTotalBalance) {
amount
} else {
amount - stakeKitBalance?.getRewardStakingBalance().orZero()
}
}
private fun BalanceType.getTitle(validatorName: String?) = when (this) {
BalanceType.PREPARING,
BalanceType.STAKED,
-> validatorName?.let { stringReference(it) }
BalanceType.UNSTAKED -> resourceReference(R.string.staking_unstaked)
BalanceType.UNSTAKING -> resourceReference(R.string.staking_unstaking)
BalanceType.LOCKED -> resourceReference(R.string.staking_locked)
BalanceType.AVAILABLE,
BalanceType.REWARDS,
BalanceType.UNLOCKING,
BalanceType.UNKNOWN,
-> null
}
private fun getSubtitle(balance: BalanceItem) = when (balance.type) {
BalanceType.UNSTAKING -> getUnbondingDate(balance.date)
BalanceType.UNSTAKED -> resourceReference(R.string.staking_tap_to_withdraw)
BalanceType.LOCKED -> if (balance.pendingActions.any { it.type == StakingActionType.VOTE_LOCKED }) {
resourceReference(R.string.staking_tap_to_unlock_or_vote)
} else {
resourceReference(R.string.staking_tap_to_unlock)
}
BalanceType.PREPARING -> {
val warmupPeriod = integration.warmupPeriodDays
combinedReference(
resourceReference(R.string.staking_details_warmup_period),
stringReference(" "),
pluralReference(R.plurals.common_days, warmupPeriod, wrappedList(warmupPeriod)),
)
}
BalanceType.AVAILABLE,
BalanceType.STAKED,
BalanceType.UNLOCKING,
BalanceType.REWARDS,
BalanceType.UNKNOWN,
-> null
}
private fun getUnbondingDate(date: Instant?): TextReference? {
val unbondingPeriod = integration.cooldownPeriodDays ?: return null
if (date == null) {
return combinedReference(
resourceReference(R.string.staking_details_unbonding_period),
stringReference(" "),
pluralReference(R.plurals.common_days, unbondingPeriod, wrappedList(unbondingPeriod)),
)
}
val nowCalendar = Calendar.getInstance()
nowCalendar.resetHours()
val endDate = Calendar.getInstance()
endDate.timeInMillis = date.toEpochMilliseconds()
endDate.resetHours()
val days = ((endDate.timeInMillis - nowCalendar.timeInMillis) / DAY_IN_MILLIS).toInt()
return if (days > 0) {
resourceReference(
R.string.common_left,
wrappedList(
pluralReference(R.plurals.common_days, days, wrappedList(days)),
),
)
} else {
resourceReference(R.string.common_today)
}
}
private fun BalanceItem.isClickable(): Boolean {
val networkId = cryptoCurrencyStatus.currency.network.rawId
return when {
// TON allows withdrawing funds in the preparing state, unlike other networks.
isTon(networkId) && this.type == BalanceType.PREPARING -> {
pendingActions.any { it.type == StakingActionType.WITHDRAW }
}
else -> this.type.isClickable() && !this.isPending
}
}
private fun Calendar.resetHours() {
this[Calendar.HOUR_OF_DAY] = 0
this[Calendar.MINUTE] = 0
this[Calendar.SECOND] = 0
this[Calendar.MILLISECOND] = 0
}
private companion object {
const val DAY_IN_MILLIS = 24 * 60 * 60 * 1000
}
}

View file

@ -24,20 +24,22 @@ internal class RewardsValidatorStateConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
private val integration: StakingIntegration,
) : Converter<Unit, StakingStates.RewardsValidatorsState> {
override fun convert(value: Unit): StakingStates.RewardsValidatorsState {
val stakingBalance = cryptoCurrencyStatus.value.stakingBalance
return if (stakingBalance is StakingBalance.Data.StakeKit) {
val balances = stakingBalance.balance.items
StakingStates.RewardsValidatorsState.Data(
isPrimaryButtonEnabled = true,
rewards = balances
.filter { it.type == BalanceType.REWARDS }
.mapRewardBalances(cryptoCurrencyStatus)
.toPersistentList(),
)
} else {
// TODO p2p
StakingStates.RewardsValidatorsState.Empty()
return when (stakingBalance) {
is StakingBalance.Data.StakeKit -> {
val balances = stakingBalance.balance.items
StakingStates.RewardsValidatorsState.Data(
isPrimaryButtonEnabled = true,
rewards = balances
.filter { it.type == BalanceType.REWARDS }
.mapRewardBalances(cryptoCurrencyStatus)
.toPersistentList(),
)
}
is StakingBalance.Data.P2PEthPool -> StakingStates.RewardsValidatorsState.Empty()
else -> StakingStates.RewardsValidatorsState.Empty()
}
}

View file

@ -0,0 +1,227 @@
package com.tangem.features.staking.impl.presentation.state.converters
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.BalanceType
import com.tangem.domain.models.staking.PendingAction
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingBalanceEntry
import com.tangem.domain.models.staking.StakingEntryActions
import com.tangem.domain.models.staking.StakingEntryType
import com.tangem.domain.models.staking.action.StakingActionType
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.StakingTarget
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.isTon
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.datetime.Instant
import java.math.BigDecimal
import java.util.Calendar
internal class StakingBalanceEntryConverter(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrencyProvider: Provider<AppCurrency>,
private val integration: StakingIntegration,
) : Converter<StakingBalanceEntry, BalanceState?> {
override fun convert(value: StakingBalanceEntry): BalanceState? {
val appCurrency = appCurrencyProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val target = findTarget(value)
val cryptoAmount = value.getBalanceValue()
val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount)
val title = value.type.getTitle(target?.name ?: value.validator?.name)
return title?.let {
BalanceState(
groupId = value.id,
target = target,
title = title,
subtitle = getSubtitle(value),
type = value.type.toBalanceType(),
cryptoValue = cryptoAmount.parseBigDecimal(cryptoCurrency.decimals),
cryptoAmount = cryptoAmount,
formattedCryptoAmount = stringReference(
cryptoAmount.format { crypto(cryptoCurrency) },
),
fiatAmount = fiatAmount,
formattedFiatAmount = stringReference(
fiatAmount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
),
rawCurrencyId = value.rawCurrencyId,
pendingActions = value.getPendingActions().toPersistentList(),
isClickable = value.isClickable(),
isPending = value.isPending,
targetAddress = value.validator?.address,
)
}
}
private fun findTarget(entry: StakingBalanceEntry): StakingTarget? {
return integration.targets.firstOrNull {
entry.validator?.address?.contains(it.address, ignoreCase = true) == true
}
}
private fun StakingBalanceEntry.getBalanceValue(): BigDecimal {
val isIncludeStakingTotalBalance = BlockchainUtils.isIncludeStakingTotalBalance(
blockchainId = cryptoCurrencyStatus.currency.network.rawId,
)
return if (isIncludeStakingTotalBalance) {
amount
} else {
val stakingBalance = cryptoCurrencyStatus.value.stakingBalance
if (stakingBalance is StakingBalance.Data.StakeKit) {
amount - stakingBalance.totalRewards
} else {
amount
}
}
}
private fun StakingEntryType.getTitle(validatorName: String?): TextReference? = when (this) {
StakingEntryType.PREPARING,
StakingEntryType.STAKED,
-> validatorName?.let { stringReference(it) }
StakingEntryType.WITHDRAWABLE -> resourceReference(R.string.staking_unstaked)
StakingEntryType.UNSTAKING -> resourceReference(R.string.staking_unstaking)
StakingEntryType.LOCKED -> resourceReference(R.string.staking_locked)
StakingEntryType.AVAILABLE,
StakingEntryType.REWARDS,
StakingEntryType.UNLOCKING,
StakingEntryType.UNKNOWN,
-> null
}
private fun getSubtitle(entry: StakingBalanceEntry): TextReference? = when (entry.type) {
StakingEntryType.UNSTAKING -> getUnbondingDate(entry.date)
StakingEntryType.WITHDRAWABLE -> resourceReference(R.string.staking_tap_to_withdraw)
StakingEntryType.LOCKED -> {
val hasVoteLocked = entry.getPendingActions().any { it.type == StakingActionType.VOTE_LOCKED }
if (hasVoteLocked) {
resourceReference(R.string.staking_tap_to_unlock_or_vote)
} else {
resourceReference(R.string.staking_tap_to_unlock)
}
}
StakingEntryType.PREPARING -> {
val warmupPeriod = integration.warmupPeriodDays
TextReference.Combined(
wrappedList(
resourceReference(R.string.staking_details_warmup_period),
stringReference(" "),
pluralReference(R.plurals.common_days, warmupPeriod, wrappedList(warmupPeriod)),
),
)
}
StakingEntryType.AVAILABLE,
StakingEntryType.STAKED,
StakingEntryType.UNLOCKING,
StakingEntryType.REWARDS,
StakingEntryType.UNKNOWN,
-> null
}
private fun getUnbondingDate(date: Instant?): TextReference? {
val unbondingPeriod = integration.cooldownPeriodDays ?: return null
if (date == null) {
return TextReference.Combined(
wrappedList(
resourceReference(R.string.staking_details_unbonding_period),
stringReference(" "),
pluralReference(R.plurals.common_days, unbondingPeriod, wrappedList(unbondingPeriod)),
),
)
}
val nowCalendar = Calendar.getInstance()
nowCalendar.resetHours()
val endDate = Calendar.getInstance()
endDate.timeInMillis = date.toEpochMilliseconds()
endDate.resetHours()
val days = ((endDate.timeInMillis - nowCalendar.timeInMillis) / DAY_IN_MILLIS).toInt()
return if (days > 0) {
resourceReference(
R.string.common_left,
wrappedList(
pluralReference(R.plurals.common_days, days, wrappedList(days)),
),
)
} else {
resourceReference(R.string.common_today)
}
}
private fun StakingBalanceEntry.isClickable(): Boolean {
val networkId = cryptoCurrencyStatus.currency.network.rawId
return when {
// TON allows withdrawing funds in the preparing state, unlike other networks.
isTon(networkId) && this.type == StakingEntryType.PREPARING -> {
getPendingActions().any { it.type == StakingActionType.WITHDRAW }
}
else -> this.type.isClickableType() && !this.isPending
}
}
private fun StakingEntryType.isClickableType(): Boolean = when (this) {
StakingEntryType.STAKED,
StakingEntryType.WITHDRAWABLE,
StakingEntryType.LOCKED,
-> true
else -> false
}
private fun StakingBalanceEntry.getPendingActions(): List<PendingAction> {
return when (val actions = this.actions) {
is StakingEntryActions.StakeKit -> actions.pendingActions
is StakingEntryActions.P2PEthPool -> persistentListOf()
}
}
private fun StakingEntryType.toBalanceType(): BalanceType {
return when (this) {
StakingEntryType.AVAILABLE -> BalanceType.AVAILABLE
StakingEntryType.STAKED -> BalanceType.STAKED
StakingEntryType.PREPARING -> BalanceType.PREPARING
StakingEntryType.LOCKED -> BalanceType.LOCKED
StakingEntryType.UNSTAKING -> BalanceType.UNSTAKING
StakingEntryType.UNLOCKING -> BalanceType.UNLOCKING
StakingEntryType.WITHDRAWABLE -> BalanceType.UNSTAKED
StakingEntryType.REWARDS -> BalanceType.REWARDS
StakingEntryType.UNKNOWN -> BalanceType.UNKNOWN
}
}
private fun Calendar.resetHours() {
this[Calendar.HOUR_OF_DAY] = 0
this[Calendar.MINUTE] = 0
this[Calendar.SECOND] = 0
this[Calendar.MILLISECOND] = 0
}
private companion object {
const val DAY_IN_MILLIS = 24 * 60 * 60 * 1000
}
}

View file

@ -6,13 +6,13 @@ import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.BalanceItem
import com.tangem.domain.models.staking.BalanceType
import com.tangem.domain.models.staking.RewardBlockType
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingBalanceEntry
import com.tangem.domain.models.staking.StakingEntryType
import com.tangem.domain.models.staking.action.StakingActionType
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.utils.getRewardStakingBalance
import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState
import com.tangem.features.staking.impl.presentation.state.YieldReward
import com.tangem.lib.crypto.BlockchainUtils
@ -24,23 +24,23 @@ import kotlinx.collections.immutable.toPersistentList
internal class YieldBalancesConverter(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrencyProvider: Provider<AppCurrency>,
private val balancesToShowProvider: Provider<List<BalanceItem>>,
private val balancesToShowProvider: Provider<List<StakingBalanceEntry>>,
private val integration: StakingIntegration,
) : Converter<Unit, InnerYieldBalanceState> {
private val balanceItemConverter by lazy(LazyThreadSafetyMode.NONE) {
BalanceItemConverter(cryptoCurrencyStatus, appCurrencyProvider, integration)
private val balanceEntryConverter by lazy(LazyThreadSafetyMode.NONE) {
StakingBalanceEntryConverter(cryptoCurrencyStatus, appCurrencyProvider, integration)
}
override fun convert(value: Unit): InnerYieldBalanceState {
val appCurrency = appCurrencyProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit
val balanceToShowItems = balancesToShowProvider()
val stakingBalance = cryptoCurrencyStatus.value.stakingBalance
val balanceEntries = balancesToShowProvider()
val hasStakingData = stakingBalance is StakingBalance.Data
return if (stakeKitBalance != null || balanceToShowItems.any { it.isPending }) {
val cryptoRewardsValue = stakeKitBalance?.getRewardStakingBalance()
return if (hasStakingData || balanceEntries.any { it.isPending }) {
val cryptoRewardsValue = (stakingBalance as? StakingBalance.Data)?.totalRewards
val fiatRate = cryptoCurrencyStatus.value.fiatRate
val fiatRewardsValue = if (fiatRate != null && cryptoRewardsValue != null) {
@ -48,14 +48,11 @@ internal class YieldBalancesConverter(
} else {
null
}
val type = getRewardBlockType()
val pendingRewardsConstraints = stakeKitBalance?.balance?.items
?.firstOrNull { it.type == BalanceType.REWARDS }
?.pendingActionsConstraints
?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS }
val type = getRewardBlockType(stakingBalance)
val pendingRewardsConstraints = getRewardConstraints(stakingBalance)
InnerYieldBalanceState.Data(
integrationId = stakeKitBalance?.stakingId?.integrationId,
integrationId = stakingBalance?.stakingId?.integrationId,
reward = YieldReward(
rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) },
rewardsFiat = fiatRewardsValue.format {
@ -68,24 +65,32 @@ internal class YieldBalancesConverter(
rewardConstraints = pendingRewardsConstraints,
),
isActionable = type.isActionable,
balances = balanceToShowItems.mapBalances(),
balances = balanceEntries.mapBalances(),
)
} else {
// TODO p2p
InnerYieldBalanceState.Empty
}
}
private fun List<BalanceItem>.mapBalances() = asSequence()
.filterNot { it.amount.isZero() || it.type == BalanceType.REWARDS }
.mapNotNull(balanceItemConverter::convert)
private fun List<StakingBalanceEntry>.mapBalances() = asSequence()
.filterNot { it.amount.isZero() || it.type == StakingEntryType.REWARDS }
.mapNotNull(balanceEntryConverter::convert)
.sortedByDescending { it.cryptoAmount }
.sortedBy { it.type.order }
.toPersistentList()
private fun getRewardBlockType(): RewardBlockType {
private fun getRewardBlockType(stakingBalance: StakingBalance?): RewardBlockType {
val blockchainId = cryptoCurrencyStatus.currency.network.rawId
val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit
if (stakingBalance is StakingBalance.Data.P2PEthPool) {
return if (isStakingRewardUnavailable(blockchainId)) {
RewardBlockType.RewardUnavailable.DefaultRewardUnavailable
} else {
RewardBlockType.NoRewards
}
}
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
val rewards = stakeKitBalance?.balance?.items
?.filter { it.type == BalanceType.REWARDS && !it.amount.isZero() }
@ -105,4 +110,11 @@ internal class YieldBalancesConverter(
else -> RewardBlockType.NoRewards
}
}
private fun getRewardConstraints(stakingBalance: StakingBalance?) =
(stakingBalance as? StakingBalance.Data.StakeKit)
?.balance?.items
?.firstOrNull { it.type == BalanceType.REWARDS }
?.pendingActionsConstraints
?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS }
}

View file

@ -17,7 +17,7 @@ import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.BalanceItem
import com.tangem.domain.models.staking.StakingBalanceEntry
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.StakingTarget
@ -49,7 +49,7 @@ internal class SetInitialDataStateTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val userWalletProvider: Provider<UserWallet>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val balancesToShowProvider: Provider<List<BalanceItem>>,
private val balancesToShowProvider: Provider<List<StakingBalanceEntry>>,
private val isAccountsModeEnabled: Boolean,
private val account: Account.CryptoPortfolio?,
private val isBalanceHidden: Boolean,