diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/BalanceItemExt.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/BalanceItemExt.kt new file mode 100644 index 0000000000..b71f5b9b52 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/BalanceItemExt.kt @@ -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.toStakingBalanceEntries( + validatorNameResolver: (String?) -> String? = { null }, +): List { + return map { it.toStakingBalanceEntry(validatorNameResolver(it.validatorAddress)) } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt new file mode 100644 index 0000000000..c189a5465f --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/P2PEthPoolStakingAccountExt.kt @@ -0,0 +1,64 @@ +package com.tangem.domain.models.staking + +import java.math.BigDecimal + +fun P2PEthPoolStakingAccount.toStakingBalanceEntries(vaultName: String? = null): List { + 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, + ), + ) + } + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt index a83dd1b1e2..3e852db2fa 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalance.kt @@ -1,6 +1,7 @@ package com.tangem.domain.models.staking import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.serialization.SerializedBigDecimal import kotlinx.serialization.Serializable import java.math.BigDecimal @@ -21,6 +22,9 @@ sealed interface StakingBalance { @Serializable sealed interface Data : StakingBalance { + /** Provider-agnostic list of balance entries for UI display */ + val entries: List + @Serializable data class StakeKit( override val stakingId: StakingID, @@ -28,25 +32,23 @@ sealed interface StakingBalance { val balance: YieldBalanceItem, ) : Data { - override val totalStaked: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.STAKED } - .sumOf { it.amount } + override val totalStaked: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.STAKED } + .sumOf { it.amount } - override val totalRewards: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.REWARDS } - .sumOf { it.amount } + override val totalRewards: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.REWARDS } + .sumOf { it.amount } - override val unstakingAmount: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.UNSTAKING || it.type == BalanceType.UNLOCKING } - .sumOf { it.amount } + override val unstakingAmount: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.UNSTAKING || it.type == BalanceType.UNLOCKING } + .sumOf { it.amount } - override val withdrawableAmount: BigDecimal - get() = balance.items - .filter { it.type == BalanceType.UNSTAKED } - .sumOf { it.amount } + override val withdrawableAmount: SerializedBigDecimal = balance.items + .filter { it.type == BalanceType.UNSTAKED } + .sumOf { it.amount } + + override val entries: List = balance.items.toStakingBalanceEntries() } @Serializable @@ -56,17 +58,15 @@ sealed interface StakingBalance { val account: P2PEthPoolStakingAccount, ) : Data { - override val totalStaked: BigDecimal - get() = account.stake.assets + override val totalStaked: SerializedBigDecimal = account.stake.assets - override val totalRewards: BigDecimal - get() = account.stake.totalEarnedAssets + override val totalRewards: SerializedBigDecimal = account.stake.totalEarnedAssets - override val unstakingAmount: BigDecimal - get() = account.exitQueue.total + override val unstakingAmount: SerializedBigDecimal = account.exitQueue.total - override val withdrawableAmount: BigDecimal - get() = account.availableToWithdraw + override val withdrawableAmount: SerializedBigDecimal = account.availableToWithdraw + + override val entries: List = account.toStakingBalanceEntries() } } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalanceEntry.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalanceEntry.kt new file mode 100644 index 0000000000..bd38711c65 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingBalanceEntry.kt @@ -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?, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryActions.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryActions.kt new file mode 100644 index 0000000000..da7f7c48e8 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryActions.kt @@ -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, + val pendingActionsConstraints: List, + ) : StakingEntryActions { + val hasPendingActions: Boolean get() = pendingActions.isNotEmpty() + } + + @Serializable + data class P2PEthPool( + val ticket: String?, + val estimatedWithdrawalDate: Instant?, + val isClaimable: Boolean, + ) : StakingEntryActions +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryType.kt new file mode 100644 index 0000000000..ec0f416e45 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/staking/StakingEntryType.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt index 84fa8cedeb..f8a1d282a2 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/CryptoCurrencyStatusFactoryTest.kt @@ -428,10 +428,10 @@ class CryptoCurrencyStatusFactoryTest { source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value }, - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), @@ -525,10 +525,10 @@ class CryptoCurrencyStatusFactoryTest { source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value }, - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), @@ -614,10 +614,10 @@ class CryptoCurrencyStatusFactoryTest { source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns currency.id.rawCurrencyId?.value }, - mockk { + mockk(relaxed = true) { every { this@mockk.token.coinGeckoId } returns "unknown" }, ), diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt index 7f08ae5dbc..197949310f 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/TotalFiatBalanceCalculatorTest.kt @@ -589,11 +589,11 @@ class TotalFiatBalanceCalculatorTest { private fun createStakeKitBalance(amount: BigDecimal, balanceType: BalanceType): StakingBalance.Data.StakeKit { return StakingBalance.Data.StakeKit( - stakingId = mockk(), + stakingId = mockk(relaxed = true), source = StatusSource.ACTUAL, balance = YieldBalanceItem( items = listOf( - mockk { + mockk(relaxed = true) { every { this@mockk.amount } returns amount every { this@mockk.type } returns balanceType }, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 07da487fa9..494dcdd997 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -199,14 +199,21 @@ internal class StakingModel @Inject constructor( } private var appCurrency: AppCurrency by Delegates.notNull() - private val balancesToShow: List + private val balancesToShow: List 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 diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt deleted file mode 100644 index 1b4a71149b..0000000000 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt +++ /dev/null @@ -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, - private val integration: StakingIntegration, -) : Converter { - - 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 - } -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt index e81cb4512e..bfa8c333f9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt @@ -24,20 +24,22 @@ internal class RewardsValidatorStateConverter( private val appCurrencyProvider: Provider, private val integration: StakingIntegration, ) : Converter { + 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() } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt new file mode 100644 index 0000000000..45009e5529 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt @@ -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, + private val integration: StakingIntegration, +) : Converter { + + 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 { + 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 + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt index 1c1b278c34..3fcfbe6f06 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt @@ -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, - private val balancesToShowProvider: Provider>, + private val balancesToShowProvider: Provider>, private val integration: StakingIntegration, ) : Converter { - 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.mapBalances() = asSequence() - .filterNot { it.amount.isZero() || it.type == BalanceType.REWARDS } - .mapNotNull(balanceItemConverter::convert) + private fun List.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 } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index f1404c4a63..730f8ffad1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -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, private val appCurrencyProvider: Provider, - private val balancesToShowProvider: Provider>, + private val balancesToShowProvider: Provider>, private val isAccountsModeEnabled: Boolean, private val account: Account.CryptoPortfolio?, private val isBalanceHidden: Boolean,