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

@ -0,0 +1,27 @@
package com.tangem.domain.models.staking
import com.tangem.domain.models.staking.StakingEntryType.Companion.fromBalanceType
fun BalanceItem.toStakingBalanceEntry(validatorName: String? = null): StakingBalanceEntry {
return StakingBalanceEntry(
id = groupId,
type = fromBalanceType(type),
amount = amount,
validator = validatorAddress?.let {
ValidatorInfo(address = it, name = validatorName)
},
date = date,
actions = StakingEntryActions.StakeKit(
pendingActions = pendingActions,
pendingActionsConstraints = pendingActionsConstraints,
),
isPending = isPending,
rawCurrencyId = rawCurrencyId,
)
}
fun List<BalanceItem>.toStakingBalanceEntries(
validatorNameResolver: (String?) -> String? = { null },
): List<StakingBalanceEntry> {
return map { it.toStakingBalanceEntry(validatorNameResolver(it.validatorAddress)) }
}

View file

@ -0,0 +1,64 @@
package com.tangem.domain.models.staking
import java.math.BigDecimal
fun P2PEthPoolStakingAccount.toStakingBalanceEntries(vaultName: String? = null): List<StakingBalanceEntry> {
return buildList {
if (stake.assets > BigDecimal.ZERO) {
add(
StakingBalanceEntry(
id = vaultAddress,
type = StakingEntryType.STAKED,
amount = stake.assets,
validator = ValidatorInfo(address = vaultAddress, name = vaultName),
date = null,
actions = StakingEntryActions.P2PEthPool(
ticket = null,
estimatedWithdrawalDate = null,
isClaimable = false,
),
isPending = false,
rawCurrencyId = null,
),
)
}
exitQueue.requests.forEach { request ->
add(
StakingBalanceEntry(
id = "${vaultAddress}_${request.ticket}",
type = StakingEntryType.UNSTAKING,
amount = request.totalAssets,
validator = ValidatorInfo(address = vaultAddress, name = vaultName),
date = request.withdrawalTimestamp,
actions = StakingEntryActions.P2PEthPool(
ticket = request.ticket,
estimatedWithdrawalDate = request.withdrawalTimestamp,
isClaimable = request.isClaimable,
),
isPending = false,
rawCurrencyId = null,
),
)
}
if (availableToWithdraw > BigDecimal.ZERO) {
add(
StakingBalanceEntry(
id = "${vaultAddress}_withdrawable",
type = StakingEntryType.WITHDRAWABLE,
amount = availableToWithdraw,
validator = ValidatorInfo(address = vaultAddress, name = vaultName),
date = null,
actions = StakingEntryActions.P2PEthPool(
ticket = null,
estimatedWithdrawalDate = null,
isClaimable = true,
),
isPending = false,
rawCurrencyId = null,
),
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.models.staking package com.tangem.domain.models.staking
import com.tangem.domain.models.StatusSource import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.math.BigDecimal import java.math.BigDecimal
@ -21,6 +22,9 @@ sealed interface StakingBalance {
@Serializable @Serializable
sealed interface Data : StakingBalance { sealed interface Data : StakingBalance {
/** Provider-agnostic list of balance entries for UI display */
val entries: List<StakingBalanceEntry>
@Serializable @Serializable
data class StakeKit( data class StakeKit(
override val stakingId: StakingID, override val stakingId: StakingID,
@ -28,25 +32,23 @@ sealed interface StakingBalance {
val balance: YieldBalanceItem, val balance: YieldBalanceItem,
) : Data { ) : Data {
override val totalStaked: BigDecimal override val totalStaked: SerializedBigDecimal = balance.items
get() = balance.items
.filter { it.type == BalanceType.STAKED } .filter { it.type == BalanceType.STAKED }
.sumOf { it.amount } .sumOf { it.amount }
override val totalRewards: BigDecimal override val totalRewards: SerializedBigDecimal = balance.items
get() = balance.items
.filter { it.type == BalanceType.REWARDS } .filter { it.type == BalanceType.REWARDS }
.sumOf { it.amount } .sumOf { it.amount }
override val unstakingAmount: BigDecimal override val unstakingAmount: SerializedBigDecimal = balance.items
get() = balance.items
.filter { it.type == BalanceType.UNSTAKING || it.type == BalanceType.UNLOCKING } .filter { it.type == BalanceType.UNSTAKING || it.type == BalanceType.UNLOCKING }
.sumOf { it.amount } .sumOf { it.amount }
override val withdrawableAmount: BigDecimal override val withdrawableAmount: SerializedBigDecimal = balance.items
get() = balance.items
.filter { it.type == BalanceType.UNSTAKED } .filter { it.type == BalanceType.UNSTAKED }
.sumOf { it.amount } .sumOf { it.amount }
override val entries: List<StakingBalanceEntry> = balance.items.toStakingBalanceEntries()
} }
@Serializable @Serializable
@ -56,17 +58,15 @@ sealed interface StakingBalance {
val account: P2PEthPoolStakingAccount, val account: P2PEthPoolStakingAccount,
) : Data { ) : Data {
override val totalStaked: BigDecimal override val totalStaked: SerializedBigDecimal = account.stake.assets
get() = account.stake.assets
override val totalRewards: BigDecimal override val totalRewards: SerializedBigDecimal = account.stake.totalEarnedAssets
get() = account.stake.totalEarnedAssets
override val unstakingAmount: BigDecimal override val unstakingAmount: SerializedBigDecimal = account.exitQueue.total
get() = account.exitQueue.total
override val withdrawableAmount: BigDecimal override val withdrawableAmount: SerializedBigDecimal = account.availableToWithdraw
get() = account.availableToWithdraw
override val entries: List<StakingBalanceEntry> = account.toStakingBalanceEntries()
} }
} }

View file

@ -0,0 +1,23 @@
package com.tangem.domain.models.staking
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
data class StakingBalanceEntry(
val id: String,
val type: StakingEntryType,
val amount: SerializedBigDecimal,
val validator: ValidatorInfo?,
val date: Instant?,
val actions: StakingEntryActions,
val isPending: Boolean,
val rawCurrencyId: String?,
)
@Serializable
data class ValidatorInfo(
val address: String,
val name: String?,
)

View file

@ -0,0 +1,23 @@
package com.tangem.domain.models.staking
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
sealed interface StakingEntryActions {
@Serializable
data class StakeKit(
val pendingActions: List<PendingAction>,
val pendingActionsConstraints: List<PendingActionConstraints>,
) : StakingEntryActions {
val hasPendingActions: Boolean get() = pendingActions.isNotEmpty()
}
@Serializable
data class P2PEthPool(
val ticket: String?,
val estimatedWithdrawalDate: Instant?,
val isClaimable: Boolean,
) : StakingEntryActions
}

View file

@ -0,0 +1,31 @@
package com.tangem.domain.models.staking
import kotlinx.serialization.Serializable
@Serializable
enum class StakingEntryType {
AVAILABLE,
STAKED,
PREPARING,
LOCKED,
UNSTAKING,
UNLOCKING,
WITHDRAWABLE,
REWARDS,
UNKNOWN,
;
companion object {
fun fromBalanceType(type: BalanceType): StakingEntryType = when (type) {
BalanceType.AVAILABLE -> AVAILABLE
BalanceType.STAKED -> STAKED
BalanceType.PREPARING -> PREPARING
BalanceType.LOCKED -> LOCKED
BalanceType.UNSTAKING -> UNSTAKING
BalanceType.UNLOCKING -> UNLOCKING
BalanceType.UNSTAKED -> WITHDRAWABLE // stakekit's UNSTAKED = ready to withdraw
BalanceType.REWARDS -> REWARDS
BalanceType.UNKNOWN -> UNKNOWN
}
}
}

View file

@ -428,10 +428,10 @@ class CryptoCurrencyStatusFactoryTest {
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,
balance = YieldBalanceItem( balance = YieldBalanceItem(
items = listOf( items = listOf(
mockk<BalanceItem> { mockk<BalanceItem>(relaxed = true) {
every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value
}, },
mockk<BalanceItem> { mockk<BalanceItem>(relaxed = true) {
every { this@mockk.token.coinGeckoId } returns "unknown" every { this@mockk.token.coinGeckoId } returns "unknown"
}, },
), ),
@ -525,10 +525,10 @@ class CryptoCurrencyStatusFactoryTest {
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,
balance = YieldBalanceItem( balance = YieldBalanceItem(
items = listOf( items = listOf(
mockk<BalanceItem> { mockk<BalanceItem>(relaxed = true) {
every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value
}, },
mockk<BalanceItem> { mockk<BalanceItem>(relaxed = true) {
every { this@mockk.token.coinGeckoId } returns "unknown" every { this@mockk.token.coinGeckoId } returns "unknown"
}, },
), ),
@ -614,10 +614,10 @@ class CryptoCurrencyStatusFactoryTest {
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,
balance = YieldBalanceItem( balance = YieldBalanceItem(
items = listOf( items = listOf(
mockk<BalanceItem> { mockk<BalanceItem>(relaxed = true) {
every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value
}, },
mockk<BalanceItem> { mockk<BalanceItem>(relaxed = true) {
every { this@mockk.token.coinGeckoId } returns "unknown" every { this@mockk.token.coinGeckoId } returns "unknown"
}, },
), ),

View file

@ -589,11 +589,11 @@ class TotalFiatBalanceCalculatorTest {
private fun createStakeKitBalance(amount: BigDecimal, balanceType: BalanceType): StakingBalance.Data.StakeKit { private fun createStakeKitBalance(amount: BigDecimal, balanceType: BalanceType): StakingBalance.Data.StakeKit {
return StakingBalance.Data.StakeKit( return StakingBalance.Data.StakeKit(
stakingId = mockk(), stakingId = mockk(relaxed = true),
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,
balance = YieldBalanceItem( balance = YieldBalanceItem(
items = listOf( items = listOf(
mockk<BalanceItem> { mockk<BalanceItem>(relaxed = true) {
every { this@mockk.amount } returns amount every { this@mockk.amount } returns amount
every { this@mockk.type } returns balanceType every { this@mockk.type } returns balanceType
}, },

View file

@ -199,14 +199,21 @@ internal class StakingModel @Inject constructor(
} }
private var appCurrency: AppCurrency by Delegates.notNull() private var appCurrency: AppCurrency by Delegates.notNull()
private val balancesToShow: List<BalanceItem> private val balancesToShow: List<StakingBalanceEntry>
get() { get() {
val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit val stakingBalance = cryptoCurrencyStatus.value.stakingBalance
return invalidatePendingTransactionsUseCase( return when (stakingBalance) {
balanceItems = stakeKitBalance?.balance?.items.orEmpty(), is StakingBalance.Data.StakeKit -> {
val invalidatedItems = invalidatePendingTransactionsUseCase(
balanceItems = stakingBalance.balance.items,
stakingActions = stakingActions, stakingActions = stakingActions,
token = integration.token, token = integration.token,
).getOrElse { emptyList() } ).getOrElse { emptyList() }
invalidatedItems.toStakingBalanceEntries()
}
is StakingBalance.Data.P2PEthPool -> stakingBalance.entries
else -> emptyList()
}
} }
private var isInitialInfoAnalyticSent: Boolean = false 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,9 +24,11 @@ internal class RewardsValidatorStateConverter(
private val appCurrencyProvider: Provider<AppCurrency>, private val appCurrencyProvider: Provider<AppCurrency>,
private val integration: StakingIntegration, private val integration: StakingIntegration,
) : Converter<Unit, StakingStates.RewardsValidatorsState> { ) : Converter<Unit, StakingStates.RewardsValidatorsState> {
override fun convert(value: Unit): StakingStates.RewardsValidatorsState { override fun convert(value: Unit): StakingStates.RewardsValidatorsState {
val stakingBalance = cryptoCurrencyStatus.value.stakingBalance val stakingBalance = cryptoCurrencyStatus.value.stakingBalance
return if (stakingBalance is StakingBalance.Data.StakeKit) { return when (stakingBalance) {
is StakingBalance.Data.StakeKit -> {
val balances = stakingBalance.balance.items val balances = stakingBalance.balance.items
StakingStates.RewardsValidatorsState.Data( StakingStates.RewardsValidatorsState.Data(
isPrimaryButtonEnabled = true, isPrimaryButtonEnabled = true,
@ -35,9 +37,9 @@ internal class RewardsValidatorStateConverter(
.mapRewardBalances(cryptoCurrencyStatus) .mapRewardBalances(cryptoCurrencyStatus)
.toPersistentList(), .toPersistentList(),
) )
} else { }
// TODO p2p is StakingBalance.Data.P2PEthPool -> StakingStates.RewardsValidatorsState.Empty()
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.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus 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
import com.tangem.domain.models.staking.RewardBlockType import com.tangem.domain.models.staking.RewardBlockType
import com.tangem.domain.models.staking.StakingBalance 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.models.staking.action.StakingActionType
import com.tangem.domain.staking.model.StakingIntegration 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.InnerYieldBalanceState
import com.tangem.features.staking.impl.presentation.state.YieldReward import com.tangem.features.staking.impl.presentation.state.YieldReward
import com.tangem.lib.crypto.BlockchainUtils import com.tangem.lib.crypto.BlockchainUtils
@ -24,23 +24,23 @@ import kotlinx.collections.immutable.toPersistentList
internal class YieldBalancesConverter( internal class YieldBalancesConverter(
private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrencyProvider: Provider<AppCurrency>, private val appCurrencyProvider: Provider<AppCurrency>,
private val balancesToShowProvider: Provider<List<BalanceItem>>, private val balancesToShowProvider: Provider<List<StakingBalanceEntry>>,
private val integration: StakingIntegration, private val integration: StakingIntegration,
) : Converter<Unit, InnerYieldBalanceState> { ) : Converter<Unit, InnerYieldBalanceState> {
private val balanceItemConverter by lazy(LazyThreadSafetyMode.NONE) { private val balanceEntryConverter by lazy(LazyThreadSafetyMode.NONE) {
BalanceItemConverter(cryptoCurrencyStatus, appCurrencyProvider, integration) StakingBalanceEntryConverter(cryptoCurrencyStatus, appCurrencyProvider, integration)
} }
override fun convert(value: Unit): InnerYieldBalanceState { override fun convert(value: Unit): InnerYieldBalanceState {
val appCurrency = appCurrencyProvider() val appCurrency = appCurrencyProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency val cryptoCurrency = cryptoCurrencyStatus.currency
val stakeKitBalance = cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit val stakingBalance = cryptoCurrencyStatus.value.stakingBalance
val balanceToShowItems = balancesToShowProvider() val balanceEntries = balancesToShowProvider()
val hasStakingData = stakingBalance is StakingBalance.Data
return if (stakeKitBalance != null || balanceToShowItems.any { it.isPending }) { return if (hasStakingData || balanceEntries.any { it.isPending }) {
val cryptoRewardsValue = stakeKitBalance?.getRewardStakingBalance() val cryptoRewardsValue = (stakingBalance as? StakingBalance.Data)?.totalRewards
val fiatRate = cryptoCurrencyStatus.value.fiatRate val fiatRate = cryptoCurrencyStatus.value.fiatRate
val fiatRewardsValue = if (fiatRate != null && cryptoRewardsValue != null) { val fiatRewardsValue = if (fiatRate != null && cryptoRewardsValue != null) {
@ -48,14 +48,11 @@ internal class YieldBalancesConverter(
} else { } else {
null null
} }
val type = getRewardBlockType() val type = getRewardBlockType(stakingBalance)
val pendingRewardsConstraints = stakeKitBalance?.balance?.items val pendingRewardsConstraints = getRewardConstraints(stakingBalance)
?.firstOrNull { it.type == BalanceType.REWARDS }
?.pendingActionsConstraints
?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS }
InnerYieldBalanceState.Data( InnerYieldBalanceState.Data(
integrationId = stakeKitBalance?.stakingId?.integrationId, integrationId = stakingBalance?.stakingId?.integrationId,
reward = YieldReward( reward = YieldReward(
rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) }, rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) },
rewardsFiat = fiatRewardsValue.format { rewardsFiat = fiatRewardsValue.format {
@ -68,24 +65,32 @@ internal class YieldBalancesConverter(
rewardConstraints = pendingRewardsConstraints, rewardConstraints = pendingRewardsConstraints,
), ),
isActionable = type.isActionable, isActionable = type.isActionable,
balances = balanceToShowItems.mapBalances(), balances = balanceEntries.mapBalances(),
) )
} else { } else {
// TODO p2p
InnerYieldBalanceState.Empty InnerYieldBalanceState.Empty
} }
} }
private fun List<BalanceItem>.mapBalances() = asSequence() private fun List<StakingBalanceEntry>.mapBalances() = asSequence()
.filterNot { it.amount.isZero() || it.type == BalanceType.REWARDS } .filterNot { it.amount.isZero() || it.type == StakingEntryType.REWARDS }
.mapNotNull(balanceItemConverter::convert) .mapNotNull(balanceEntryConverter::convert)
.sortedByDescending { it.cryptoAmount } .sortedByDescending { it.cryptoAmount }
.sortedBy { it.type.order } .sortedBy { it.type.order }
.toPersistentList() .toPersistentList()
private fun getRewardBlockType(): RewardBlockType { private fun getRewardBlockType(stakingBalance: StakingBalance?): RewardBlockType {
val blockchainId = cryptoCurrencyStatus.currency.network.rawId 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 val rewards = stakeKitBalance?.balance?.items
?.filter { it.type == BalanceType.REWARDS && !it.amount.isZero() } ?.filter { it.type == BalanceType.REWARDS && !it.amount.isZero() }
@ -105,4 +110,11 @@ internal class YieldBalancesConverter(
else -> RewardBlockType.NoRewards 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.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus 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.models.wallet.UserWallet
import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.StakingTarget import com.tangem.domain.staking.model.StakingTarget
@ -49,7 +49,7 @@ internal class SetInitialDataStateTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus, private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val userWalletProvider: Provider<UserWallet>, private val userWalletProvider: Provider<UserWallet>,
private val appCurrencyProvider: Provider<AppCurrency>, private val appCurrencyProvider: Provider<AppCurrency>,
private val balancesToShowProvider: Provider<List<BalanceItem>>, private val balancesToShowProvider: Provider<List<StakingBalanceEntry>>,
private val isAccountsModeEnabled: Boolean, private val isAccountsModeEnabled: Boolean,
private val account: Account.CryptoPortfolio?, private val account: Account.CryptoPortfolio?,
private val isBalanceHidden: Boolean, private val isBalanceHidden: Boolean,