Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-11 11:56:37 +05:00
parent 3eb843a05f
commit 9c6d5fc4fa
15 changed files with 192 additions and 73 deletions

View file

@ -3,6 +3,7 @@ package com.tangem.datasource.api.stakekit.models.response.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.stakekit.models.request.Address
import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO.PendingAction.PendingActionArgs.Amount
import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionTypeDTO
import org.joda.time.DateTime
import java.math.BigDecimal
@ -31,6 +32,8 @@ data class BalanceDTO(
val pricePerShare: BigDecimal,
@Json(name = "pendingActions")
val pendingActions: List<PendingAction>,
@Json(name = "pendingActionConstraints")
val pendingActionConstraints: List<PendingActionConstraints>?,
@Json(name = "token")
val tokenDTO: TokenDTO,
@Json(name = "validatorAddress")
@ -99,7 +102,7 @@ data class BalanceDTO(
@JsonClass(generateAdapter = true)
data class Amount(
@Json(name = "required")
val required: Boolean,
val required: Boolean = true,
@Json(name = "minimum")
val minimum: BigDecimal?,
@Json(name = "maximum")
@ -136,6 +139,14 @@ data class BalanceDTO(
}
}
@JsonClass(generateAdapter = true)
data class PendingActionConstraints(
@Json(name = "type")
val type: StakingActionTypeDTO,
@Json(name = "amount")
val amountArg: Amount,
)
@JsonClass(generateAdapter = true)
data class Required(
@Json(name = "required")

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.local.token.converter
import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.PendingActionConstraints
import com.tangem.utils.converter.Converter
internal object PendingActionConstraintsConverter :
Converter<BalanceDTO.PendingActionConstraints, PendingActionConstraints> {
override fun convert(value: BalanceDTO.PendingActionConstraints): PendingActionConstraints {
return PendingActionConstraints(
type = StakingActionTypeConverter.convert(value.type),
amountArg = PendingAction.PendingActionArgs.Amount(
required = value.amountArg.required,
minimum = value.amountArg.minimum,
maximum = value.amountArg.maximum,
),
)
}
}

View file

@ -38,6 +38,8 @@ internal class YieldBalanceConverter(
pendingActions = PendingActionConverter
.convertList(item.pendingActions)
.sortedBy { it.passthrough },
pendingActionsConstraints = PendingActionConstraintsConverter
.convertList(item.pendingActionConstraints.orEmpty()),
isPending = false,
)
}

View file

@ -63,9 +63,15 @@ data class BalanceItem(
val validatorAddress: String?,
val date: DateTime?,
val pendingActions: List<PendingAction>,
val pendingActionsConstraints: List<PendingActionConstraints>,
val isPending: Boolean,
)
data class PendingActionConstraints(
val type: StakingActionType,
val amountArg: PendingAction.PendingActionArgs.Amount?,
)
data class PendingAction(
val type: StakingActionType,
val passthrough: String,
@ -135,5 +141,21 @@ enum class BalanceType(val order: Int) {
enum class RewardBlockType {
NoRewards,
Rewards,
RewardsRequirementsError,
RewardUnavailable,
;
/**
* Indicated whether action can be performed on available reward
* For example, pending action CLAIM_REWARDS could be called
*/
val isActionable: Boolean
get() = when (this) {
NoRewards,
RewardUnavailable,
-> false
RewardsRequirementsError,
Rewards,
-> true
}
}

View file

@ -88,6 +88,7 @@ class InvalidatePendingTransactionsUseCase(
validatorAddress = action.validatorAddress ?: action.validatorAddresses?.get(0) ?: "",
date = null,
pendingActions = emptyList(),
pendingActionsConstraints = emptyList(),
isPending = true,
),
)

View file

@ -16,6 +16,8 @@ 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.navigation.share.ShareManager
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -508,19 +510,34 @@ internal class StakingModel @Inject constructor(
analyticsEventHandler.send(StakingAnalyticsEvent.ButtonRewards)
val rewardsValidators =
stateController.value.rewardsValidatorsState as? StakingStates.RewardsValidatorsState.Data
val rewards = rewardsValidators?.rewards
if (rewards != null && rewards.isSingleItem()) {
onActiveStake(rewards.first())
} else {
analyticsEventHandler.send(
StakingAnalyticsEvent.ButtonValidator(
source = StakeScreenSource.Info,
),
val initialInfoState = stateController.value.initialInfoState as? StakingStates.InitialInfoState.Data
val yieldBalance = initialInfoState?.yieldBalance as? InnerYieldBalanceState.Data
val rewardBlockType = yieldBalance?.reward?.rewardBlockType
val rewardPendingActionConstraints = yieldBalance?.reward?.rewardConstraints
if (rewardBlockType == RewardBlockType.RewardsRequirementsError) {
stakingEventFactory.createStakingRewardsMinimumRequirementsErrorAlert(
cryptoCurrencyName = cryptoCurrencyStatus.currency.name,
cryptoAmountValue = rewardPendingActionConstraints?.amountArg?.minimum?.format {
crypto(cryptoCurrencyStatus.currency)
}.orEmpty(),
)
stateController.update {
value.copy(actionType = StakingActionCommonType.Pending.Rewards)
} else {
val rewards = rewardsValidators?.rewards
if (rewards != null && rewards.isSingleItem()) {
onActiveStake(rewards.first())
} else {
analyticsEventHandler.send(
StakingAnalyticsEvent.ButtonValidator(
source = StakeScreenSource.Info,
),
)
stateController.update {
value.copy(actionType = StakingActionCommonType.Pending.Rewards)
}
stakingStateRouter.showRewardsValidators()
}
stakingStateRouter.showRewardsValidators()
}
}

View file

@ -2,18 +2,14 @@ package com.tangem.features.staking.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.staking.model.stakekit.BalanceType
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.RewardBlockType
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.*
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
@Immutable
internal sealed class InnerYieldBalanceState {
data class Data(
val rewardsCrypto: String,
val rewardsFiat: String,
val rewardBlockType: RewardBlockType,
val reward: YieldReward,
val isActionable: Boolean,
val balances: ImmutableList<BalanceState>,
) : InnerYieldBalanceState()
@ -37,4 +33,12 @@ internal data class BalanceState(
val validator: Yield.Validator?,
val pendingActions: ImmutableList<PendingAction>,
val isPending: Boolean,
)
@Immutable
internal data class YieldReward(
val rewardsCrypto: String,
val rewardsFiat: String,
val rewardBlockType: RewardBlockType,
val rewardConstraints: PendingActionConstraints?,
)

View file

@ -6,8 +6,10 @@ 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.staking.model.stakekit.*
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState
import com.tangem.features.staking.impl.presentation.state.YieldReward
import com.tangem.lib.crypto.BlockchainUtils.isBSC
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.Provider
@ -33,7 +35,8 @@ internal class YieldBalancesConverter(
val balanceToShowItems = balancesToShowProvider()
return if (yieldBalance is YieldBalance.Data || balanceToShowItems.any { it.isPending }) {
val cryptoRewardsValue = (yieldBalance as? YieldBalance.Data)?.getRewardStakingBalance()
val yieldBalance = yieldBalance as? YieldBalance.Data
val cryptoRewardsValue = yieldBalance?.getRewardStakingBalance()
val fiatRate = cryptoCurrencyStatus.value.fiatRate
val fiatRewardsValue = if (fiatRate != null && cryptoRewardsValue != null) {
@ -41,17 +44,25 @@ internal class YieldBalancesConverter(
} else {
null
}
val (type, isActionable) = getRewardBlockType()
val type = getRewardBlockType()
val pendingRewardsConstraints = yieldBalance?.balance?.items
?.firstOrNull { it.type == BalanceType.REWARDS }
?.pendingActionsConstraints
?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS }
InnerYieldBalanceState.Data(
rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) },
rewardsFiat = fiatRewardsValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
rewardBlockType = type,
isActionable = isActionable,
reward = YieldReward(
rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) },
rewardsFiat = fiatRewardsValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
rewardBlockType = type,
rewardConstraints = pendingRewardsConstraints,
),
isActionable = type.isActionable,
balances = balanceToShowItems.mapBalances(),
)
} else {
@ -66,7 +77,7 @@ internal class YieldBalancesConverter(
.sortedBy { it.type.order }
.toPersistentList()
private fun getRewardBlockType(): Pair<RewardBlockType, Boolean> {
private fun getRewardBlockType(): RewardBlockType {
val blockchainId = cryptoCurrencyStatus.currency.network.id.value
val yieldBalance = cryptoCurrencyStatus.value.yieldBalance as? YieldBalance.Data
val rewards = yieldBalance?.balance?.items
@ -76,9 +87,10 @@ internal class YieldBalancesConverter(
val isRewardsClaimable = rewards?.isNotEmpty() == true
return when {
isSolana(blockchainId) || isBSC(blockchainId) -> RewardBlockType.RewardUnavailable to false
isRewardsClaimable -> RewardBlockType.Rewards to isActionable
else -> RewardBlockType.NoRewards to false
isSolana(blockchainId) || isBSC(blockchainId) -> RewardBlockType.RewardUnavailable
isRewardsClaimable && isActionable -> RewardBlockType.Rewards
isRewardsClaimable && !isActionable -> RewardBlockType.RewardsRequirementsError
else -> RewardBlockType.NoRewards
}
}
}

View file

@ -68,4 +68,17 @@ internal sealed class StakingAlertUM : AlertUM {
override val message: TextReference = resourceReference(id = R.string.staking_notification_ton_activate_account)
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
}
data class RewardsMinimumRequirementsError(
val cryptoCurrencyName: String,
val cryptoAmountValue: String,
) : StakingAlertUM() {
override val onConfirmClick: (() -> Unit)? = null
override val title: TextReference? = null
override val message: TextReference = resourceReference(
id = R.string.staking_details_min_rewards_notification,
formatArgs = wrappedList(cryptoCurrencyName, cryptoAmountValue),
)
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
}
}

View file

@ -51,4 +51,15 @@ internal class StakingEventFactory(
val alert = StakingEvent.ShowAlert(alert = StakingAlertUM.InitializeAccount)
stateController.updateEvent(alert)
}
fun createStakingRewardsMinimumRequirementsErrorAlert(cryptoCurrencyName: String, cryptoAmountValue: String) {
stateController.updateEvent(
StakingEvent.ShowAlert(
alert = StakingAlertUM.RewardsMinimumRequirementsError(
cryptoCurrencyName = cryptoCurrencyName,
cryptoAmountValue = cryptoAmountValue,
),
),
)
}
}

View file

@ -1,9 +1,9 @@
package com.tangem.features.staking.impl.presentation.state.previewdata
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.list.RoundedListWithDividersItemData
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.domain.staking.model.stakekit.BalanceType
import com.tangem.domain.staking.model.stakekit.RewardBlockType
import com.tangem.domain.staking.model.stakekit.Yield
@ -11,6 +11,7 @@ import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.YieldReward
import kotlinx.collections.immutable.persistentListOf
internal object InitialStakingStatePreview {
@ -63,9 +64,12 @@ internal object InitialStakingStatePreview {
val stateWithYield = defaultState.copy(
yieldBalance = InnerYieldBalanceState.Data(
rewardsFiat = "100 $",
rewardsCrypto = "100 SOL",
rewardBlockType = RewardBlockType.RewardUnavailable,
reward = YieldReward(
rewardsFiat = "100 $",
rewardsCrypto = "100 SOL",
rewardBlockType = RewardBlockType.RewardUnavailable,
rewardConstraints = null,
),
isActionable = true,
balances = persistentListOf(
BalanceState(

View file

@ -43,28 +43,7 @@ internal class SetConfirmationStateInitTransformer(
@Suppress("CyclomaticComplexMethod")
override fun transform(prevState: StakingUiState): StakingUiState {
val actionType = when {
isEnter -> StakingActionCommonType.Enter(
skipEnterAmount = yieldArgs.enter.isPartialAmountDisabled,
)
isImplicitExit || isExplicitExit -> StakingActionCommonType.Exit(isPartiallyUnstakeDisabled(prevState))
else -> when (pendingAction?.type) {
StakingActionType.STAKE -> StakingActionCommonType.Pending.Stake(
skipEnterAmount = yieldArgs.enter.isPartialAmountDisabled,
)
StakingActionType.UNSTAKE -> StakingActionCommonType.Exit(
partiallyUnstakeDisabled = isPartiallyUnstakeDisabled(prevState),
)
StakingActionType.CLAIM_REWARDS,
StakingActionType.RESTAKE_REWARDS,
-> StakingActionCommonType.Pending.Rewards
StakingActionType.VOTE_LOCKED,
StakingActionType.RESTAKE,
-> StakingActionCommonType.Pending.Restake
else -> StakingActionCommonType.Pending.Other
}
}
val actionType = getActionType(prevState)
return prevState.copy(
actionType = actionType,
balanceState = balanceState,
@ -88,6 +67,26 @@ internal class SetConfirmationStateInitTransformer(
)
}
private fun getActionType(prevState: StakingUiState): StakingActionCommonType {
val isPartialEnterAmountDisabled = yieldArgs.enter.isPartialAmountDisabled
val isPartialExitAmountDisabled = isPartiallyUnstakeDisabled(prevState)
return when {
isEnter -> StakingActionCommonType.Enter(isPartialEnterAmountDisabled)
isImplicitExit || isExplicitExit -> StakingActionCommonType.Exit(isPartialExitAmountDisabled)
else -> when (pendingAction?.type) {
StakingActionType.STAKE -> StakingActionCommonType.Pending.Stake(isPartialEnterAmountDisabled)
StakingActionType.UNSTAKE -> StakingActionCommonType.Exit(isPartialExitAmountDisabled)
StakingActionType.CLAIM_REWARDS,
StakingActionType.RESTAKE_REWARDS,
-> StakingActionCommonType.Pending.Rewards
StakingActionType.VOTE_LOCKED,
StakingActionType.RESTAKE,
-> StakingActionCommonType.Pending.Restake
else -> StakingActionCommonType.Pending.Other
}
}
}
private fun isPartiallyUnstakeDisabled(state: StakingUiState): Boolean {
val isSolana = BlockchainUtils.isSolana(state.cryptoCurrencyBlockchainId)
val isValidatorPreferred = balanceState?.validator?.preferred == true

View file

@ -98,9 +98,7 @@ internal class AddStakingNotificationsTransformer(
onReload = prevState.clickIntents::getFee,
feeValue = feeValue,
)
addStakingErrorNotifications(
stakingError = stakingError,
)
addStakingErrorNotifications(stakingError = stakingError)
// warnings
addWarningNotifications(
prevState = prevState,

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.unit.Density
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.components.inputrow.InputRowDefault
import com.tangem.core.ui.components.inputrow.InputRowImageInfo
import com.tangem.core.ui.components.list.roundedListWithDividersItems
@ -38,19 +39,18 @@ import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.staking.model.stakekit.BalanceType
import com.tangem.domain.staking.model.stakekit.RewardBlockType
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.previewdata.InitialStakingStatePreview
import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.utils.StringsSigns.DOT
import com.tangem.utils.extensions.orZero
@ -214,14 +214,17 @@ private fun StakingRewardBlock(
onRewardsClick: () -> Unit,
isBalanceHidden: Boolean,
) {
val (text, textColor) = when (yieldBalanceState.rewardBlockType) {
RewardBlockType.Rewards -> {
val reward = yieldBalanceState.reward
val (text, textColor) = when (yieldBalanceState.reward.rewardBlockType) {
RewardBlockType.RewardsRequirementsError,
RewardBlockType.Rewards,
-> {
annotatedReference {
append(yieldBalanceState.rewardsFiat.orMaskWithStars(isBalanceHidden))
append(reward.rewardsFiat.orMaskWithStars(isBalanceHidden))
appendSpace()
append(DOT)
appendSpace()
append(yieldBalanceState.rewardsCrypto.orMaskWithStars(isBalanceHidden))
append(reward.rewardsCrypto.orMaskWithStars(isBalanceHidden))
} to TangemTheme.colors.text.primary1
}
RewardBlockType.RewardUnavailable -> {
@ -232,7 +235,7 @@ private fun StakingRewardBlock(
resourceReference(R.string.staking_details_no_rewards_to_claim) to TangemTheme.colors.text.tertiary
}
}
val isShowIcon = yieldBalanceState.rewardBlockType == RewardBlockType.Rewards && yieldBalanceState.isActionable
val isShowIcon = reward.rewardBlockType == RewardBlockType.Rewards && yieldBalanceState.isActionable
InputRowDefault(
title = resourceReference(R.string.staking_rewards),
text = text,

View file

@ -155,7 +155,11 @@ internal class TokenDetailsStakingInfoConverter(
}
return when (rewardBlockType) {
RewardBlockType.Rewards -> resourceReference(
RewardBlockType.NoRewards -> resourceReference(R.string.staking_details_no_rewards_to_claim)
RewardBlockType.RewardUnavailable -> TextReference.EMPTY
RewardBlockType.RewardsRequirementsError,
RewardBlockType.Rewards,
-> resourceReference(
R.string.staking_details_rewards_to_claim,
wrappedList(
stakingRewardAmount.format {
@ -166,8 +170,6 @@ internal class TokenDetailsStakingInfoConverter(
},
),
)
RewardBlockType.NoRewards -> resourceReference(R.string.staking_details_no_rewards_to_claim)
RewardBlockType.RewardUnavailable -> TextReference.EMPTY
}
}
}