Updated on 2026-08-14

This commit is contained in:
Tangem 2024-07-01 16:39:42 +05:00
parent 2d8d9453bd
commit f916d7394b
21 changed files with 559 additions and 153 deletions

View file

@ -0,0 +1,30 @@
package com.tangem.features.staking.impl.presentation.state
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.staking.model.BalanceType
import com.tangem.domain.staking.model.Yield
sealed class InnerYieldBalanceState {
data class Data(
val rewardsCrypto: String,
val rewardsFiat: String,
val isRewardsToClaim: Boolean,
val balance: List<BalanceGroupedState>,
) : InnerYieldBalanceState()
data object Empty : InnerYieldBalanceState()
}
data class BalanceGroupedState(
val items: List<BalanceState>,
val type: BalanceType,
val footer: TextReference?,
val title: TextReference,
)
data class BalanceState(
val validator: Yield.Validator,
val cryptoAmount: TextReference,
val fiatAmount: TextReference,
val rawCurrencyId: String?,
)

View file

@ -53,6 +53,7 @@ internal sealed class StakingStates {
val warmupPeriod: String,
val rewardSchedule: String,
val onInfoClick: (InfoType) -> Unit,
val yieldBalance: InnerYieldBalanceState,
) : InitialInfoState()
data class Empty(

View file

@ -0,0 +1,59 @@
package com.tangem.features.staking.impl.presentation.state.previewdata
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.staking.model.BalanceType
import com.tangem.domain.staking.model.Yield
import com.tangem.features.staking.impl.presentation.state.BalanceGroupedState
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
internal object InitialStakingStatePreview {
val defaultState = StakingStates.InitialInfoState.Data(
isPrimaryButtonEnabled = true,
available = "15 SOL",
onStake = "0 SOL",
aprRange = stringReference("2.54-5.12%"),
unbondingPeriod = "3d",
minimumRequirement = "12 SOL",
rewardClaiming = "Auto",
warmupPeriod = "Days",
rewardSchedule = "Block",
onInfoClick = {},
yieldBalance = InnerYieldBalanceState.Empty,
)
val stateWithYield = defaultState.copy(
yieldBalance = InnerYieldBalanceState.Data(
rewardsFiat = "100 $",
rewardsCrypto = "100 SOL",
isRewardsToClaim = false,
balance = listOf(
BalanceGroupedState(
type = BalanceType.STAKED,
title = stringReference("Staked"),
footer = null,
items = listOf(
BalanceState(
cryptoAmount = stringReference("100 SOL"),
fiatAmount = stringReference("100 $"),
rawCurrencyId = null,
validator = Yield.Validator(
address = "address",
status = "status",
name = "Binance",
image = null,
website = null,
apr = "5".toBigDecimal(),
commission = null,
stakedBalance = null,
votingPower = null,
preferred = false,
),
),
),
),
),
),
)
}

View file

@ -9,17 +9,19 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.BalanceType
import com.tangem.domain.staking.model.Yield
import com.tangem.domain.staking.model.YieldBalance
import com.tangem.domain.staking.model.YieldBalanceItem
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingStep
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.features.staking.impl.presentation.state.ValidatorState
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmStakingStatePreviewData
import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
import java.math.BigDecimal
@ -55,15 +57,20 @@ internal class SetInitialDataStateTransformer(
private fun createInitialInfoState(): StakingStates.InitialInfoState.Data {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val yieldBalance = cryptoCurrencyStatus.value.yieldBalance
return StakingStates.InitialInfoState.Data(
isPrimaryButtonEnabled = true,
available = BigDecimalFormatter.formatCryptoAmount(
cryptoCurrencyStatus.value.amount,
cryptoAmount = cryptoCurrencyStatus.value.amount,
cryptoCurrency = cryptoCurrencyStatus.currency.symbol,
decimals = cryptoCurrencyStatus.currency.decimals,
),
onStake = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = (yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero(),
cryptoCurrency = cryptoCurrencyStatus.currency.symbol,
decimals = cryptoCurrencyStatus.currency.decimals,
),
onStake = "0 SOL", // TODO staking add after adding /balances request
aprRange = getAprRange(),
unbondingPeriod = yield.metadata.cooldownPeriod.days.toString(),
minimumRequirement = yield.metadata.minimumStake.toString(),
@ -71,6 +78,7 @@ internal class SetInitialDataStateTransformer(
warmupPeriod = yield.metadata.warmupPeriod.days.toString(),
rewardSchedule = yield.metadata.rewardSchedule,
onInfoClick = clickIntents::onInfoClick,
yieldBalance = getStakedBalances(cryptoCurrencyStatus),
)
}
@ -108,6 +116,96 @@ internal class SetInitialDataStateTransformer(
return resourceReference(R.string.common_range, wrappedList(formattedMinApr, formattedMaxApr))
}
private fun getStakedBalances(cryptoCurrencyStatus: CryptoCurrencyStatus): InnerYieldBalanceState {
val yieldBalance = cryptoCurrencyStatus.value.yieldBalance
return if (yieldBalance is YieldBalance.Data) {
val appCurrency = appCurrencyProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val cryptoRewardsValue = yieldBalance.getRewardStakingBalance()
val fiatRewardsValue = cryptoCurrencyStatus.value.fiatRate?.times(cryptoRewardsValue)
val groupedBalances = getGroupedBalance(yieldBalance.balance, cryptoCurrencyStatus, appCurrency)
InnerYieldBalanceState.Data(
rewardsCrypto = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = cryptoRewardsValue,
cryptoCurrency = cryptoCurrency,
),
rewardsFiat = BigDecimalFormatter.formatFiatAmount(
fiatAmount = fiatRewardsValue,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
),
isRewardsToClaim = !cryptoRewardsValue.isNullOrZero(),
balance = groupedBalances,
)
} else {
InnerYieldBalanceState.Empty
}
}
private fun getGroupedBalance(
balance: YieldBalanceItem,
cryptoCurrencyStatus: CryptoCurrencyStatus,
appCurrency: AppCurrency,
) = balance.items
.sortedByDescending { it.type }
.groupBy { it.type }
.mapNotNull { item ->
val (title, footer) = getValidatorBalanceInto(item.key)
val balances = item.value.mapNotNull { balance ->
val validator = yield.validators.firstOrNull {
balance.validatorAddress?.contains(it.address, ignoreCase = true) == true
}
val cryptoAmount = balance.amount * balance.pricePerShare
val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount)
validator?.let {
BalanceState(
validator = validator,
cryptoAmount = stringReference(
BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = balance.amount,
cryptoCurrency = cryptoCurrencyStatus.currency,
),
),
fiatAmount = stringReference(
BigDecimalFormatter.formatFiatAmount(
fiatAmount = fiatAmount,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
),
),
rawCurrencyId = balance.rawCurrencyId,
)
}
}
title?.let {
BalanceGroupedState(
items = balances,
type = item.key,
footer = footer,
title = it,
)
}
}
private fun getValidatorBalanceInto(type: BalanceType) = when (type) {
BalanceType.PREPARING,
BalanceType.STAKED,
-> resourceReference(R.string.staking_active) to resourceReference(R.string.staking_active_footer)
BalanceType.UNSTAKING,
BalanceType.UNLOCKING,
BalanceType.UNSTAKED,
-> resourceReference(R.string.staking_unstaked) to resourceReference(R.string.staking_unstaked_footer)
BalanceType.AVAILABLE,
BalanceType.LOCKED,
BalanceType.UNKNOWN,
BalanceType.REWARDS,
-> null to null
}
companion object {
private val EQUALITY_THRESHOLD = BigDecimal(1E-10)
}

View file

@ -1,6 +1,8 @@
package com.tangem.features.staking.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@ -11,36 +13,64 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.containers.FooterContainer
import com.tangem.core.ui.components.inputrow.InputRowDefault
import com.tangem.core.ui.components.inputrow.InputRowImageInfo
import com.tangem.core.ui.components.rows.CornersToRound
import com.tangem.core.ui.components.rows.RoundableCornersRow
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.BalanceGroupedState
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.transformers.InfoType
import com.tangem.utils.Strings.DOT
import com.tangem.utils.extensions.orZero
@Composable
internal fun StakingInitialInfoContent(state: StakingStates.InitialInfoState) {
if (state !is StakingStates.InitialInfoState.Data) return
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
modifier = Modifier // Do not put fillMaxSize() in here
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing16)
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
)
.verticalScroll(rememberScrollState()),
) {
MetricsBlock(state)
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(state.yieldBalance == InnerYieldBalanceState.Empty) {
MetricsBlock(state)
}
StakingDetailsRows(state)
Spacer(modifier = Modifier.height(8.dp))
AnimatedContent(targetState = state.yieldBalance, label = "Rewards block visibility animation") {
if (it is InnerYieldBalanceState.Data) {
StakingRewardBlock(
rewardCrypto = it.rewardsCrypto,
rewardFiat = it.rewardsFiat,
isRewardsToClaim = it.isRewardsToClaim,
)
}
}
AnimatedContent(targetState = state.yieldBalance, label = "Rewards block visibility animation") {
if (it is InnerYieldBalanceState.Data) {
ActiveStakingBlock(it.balance)
}
}
}
}
@ -52,7 +82,7 @@ private fun MetricsBlock(state: StakingStates.InitialInfoState.Data) {
color = TangemTheme.colors.background.primary,
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
)
.padding(16.dp)
.padding(TangemTheme.dimens.spacing12)
.fillMaxWidth(),
) {
Text(
@ -60,7 +90,7 @@ private fun MetricsBlock(state: StakingStates.InitialInfoState.Data) {
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(TangemTheme.dimens.size8))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@ -108,53 +138,117 @@ private fun MetricsBlock(state: StakingStates.InitialInfoState.Data) {
@Composable
internal fun StakingDetailsRows(state: StakingStates.InitialInfoState.Data) {
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_available),
endText = state.available,
cornersToRound = CornersToRound.TOP_2,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_on_stake),
endText = state.onStake,
cornersToRound = CornersToRound.ZERO,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_apy),
endText = state.aprRange.resolveReference(),
cornersToRound = CornersToRound.ZERO,
iconClick = { state.onInfoClick(InfoType.APY) },
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_unbonding_period),
endText = state.unbondingPeriod,
cornersToRound = CornersToRound.ZERO,
iconClick = { state.onInfoClick(InfoType.UNBOUNDING_PERIOD) },
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_minimum_requirement),
endText = state.minimumRequirement,
cornersToRound = CornersToRound.ZERO,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_reward_claiming),
endText = state.rewardClaiming,
cornersToRound = CornersToRound.ZERO,
iconClick = { state.onInfoClick(InfoType.REWARD_CLAIMING) },
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_warmup_period),
endText = state.warmupPeriod,
cornersToRound = CornersToRound.ZERO,
iconClick = { state.onInfoClick(InfoType.WARMUP_PERIOD) },
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_reward_schedule),
endText = state.rewardSchedule,
cornersToRound = CornersToRound.BOTTOM_2,
iconClick = { state.onInfoClick(InfoType.REWARD_SCHEDULE) },
Column {
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_available),
endText = state.available,
cornersToRound = CornersToRound.TOP_2,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_on_stake),
endText = state.onStake,
cornersToRound = CornersToRound.ZERO,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_apy),
endText = state.aprRange.resolveReference(),
cornersToRound = CornersToRound.ZERO,
iconClick = { state.onInfoClick(InfoType.APY) },
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_unbonding_period),
endText = state.unbondingPeriod,
cornersToRound = CornersToRound.ZERO,
iconClick = { state.onInfoClick(InfoType.UNBOUNDING_PERIOD) },
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_minimum_requirement),
endText = state.minimumRequirement,
cornersToRound = CornersToRound.ZERO,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_reward_claiming),
endText = state.rewardClaiming,
cornersToRound = CornersToRound.ZERO,
iconClick = { state.onInfoClick(InfoType.REWARD_CLAIMING) },
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_warmup_period),
endText = state.warmupPeriod,
cornersToRound = CornersToRound.ZERO,
iconClick = { state.onInfoClick(InfoType.WARMUP_PERIOD) },
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_reward_schedule),
endText = state.rewardSchedule,
cornersToRound = CornersToRound.BOTTOM_2,
iconClick = { state.onInfoClick(InfoType.REWARD_SCHEDULE) },
)
}
}
@Composable
private fun StakingRewardBlock(rewardCrypto: String, rewardFiat: String, isRewardsToClaim: Boolean) {
val (text, textColor) = if (isRewardsToClaim) {
annotatedReference(
buildAnnotatedString {
append("+")
appendSpace()
append(rewardFiat)
appendSpace()
append(DOT)
appendSpace()
append(rewardCrypto)
},
) to TangemTheme.colors.text.primary1
} else {
resourceReference(R.string.staking_details_no_rewards_to_claim) to TangemTheme.colors.text.tertiary
}
InputRowDefault(
title = resourceReference(R.string.staking_rewards),
text = text,
iconRes = R.drawable.ic_chevron_right_24,
textColor = textColor,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
}
@Composable
private fun ActiveStakingBlock(groupes: List<BalanceGroupedState>) {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
groupes.forEach { group ->
FooterContainer(
footer = group.footer?.resolveReference(),
modifier = Modifier,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
) {
group.items.forEachIndexed { index, balance ->
InputRowImageInfo(
title = group.title.takeIf { index == 0 },
subtitle = stringReference(balance.validator.name),
caption = stringReference(
BigDecimalFormatter.formatPercent(balance.validator.apr.orZero(), true),
),
infoTitle = balance.fiatAmount,
infoSubtitle = balance.cryptoAmount,
imageUrl = balance.validator.image.orEmpty(),
)
}
}
}
}
}
}
@Composable
private fun InitialInfoContentRow(
startText: String,
@ -191,18 +285,8 @@ private fun StakingInitialInfoContent_Preview(
private class StakingInitialInfoContentPreviewProvider : PreviewParameterProvider<StakingStates.InitialInfoState.Data> {
override val values: Sequence<StakingStates.InitialInfoState.Data>
get() = sequenceOf(
StakingStates.InitialInfoState.Data(
isPrimaryButtonEnabled = true,
available = "15 SOL",
onStake = "0 SOL",
aprRange = stringReference("2.54-5.12%"),
unbondingPeriod = "3d",
minimumRequirement = "12 SOL",
rewardClaiming = "Auto",
warmupPeriod = "Days",
rewardSchedule = "Block",
onInfoClick = {},
),
InitialStakingStatePreview.defaultState,
InitialStakingStatePreview.stateWithYield,
)
}
// endregion

View file

@ -25,6 +25,7 @@ import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.res.TangemTheme
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.StakingStep
import com.tangem.features.staking.impl.presentation.state.StakingUiState
@ -49,12 +50,9 @@ internal fun StakingNavigationButtons(uiState: StakingUiState, modifier: Modifie
@Composable
private fun StakingNavigationButton(uiState: StakingUiState, modifier: Modifier = Modifier) {
val hapticFeedback = LocalHapticFeedback.current
val confirmState = uiState.confirmStakingState
val isSuccess = (confirmState as? StakingStates.ConfirmStakingState.Data)?.isSuccess ?: false
val isStaking = (confirmState as? StakingStates.ConfirmStakingState.Data)?.isStaking ?: false
val isButtonsVisible = uiState.currentStep != StakingStep.Success
val isStakingState = uiState.currentStep == StakingStep.Success && !isSuccess && !isStaking
val isButtonsVisible = isPrevButtonVisible(uiState.currentStep)
val isStakingState = uiState.currentStep == StakingStep.Confirm
val (buttonTextId, buttonClick) = getButtonData(
currentState = uiState,
@ -103,15 +101,32 @@ private fun StakingNavigationButton(uiState: StakingUiState, modifier: Modifier
private fun getButtonData(currentState: StakingUiState): Pair<Int, () -> Unit> {
return when (currentState.currentStep) {
StakingStep.InitialInfo,
StakingStep.InitialInfo -> {
val initialState = currentState.initialInfoState as? StakingStates.InitialInfoState.Data
if (initialState?.yieldBalance is InnerYieldBalanceState.Data) {
R.string.staking_stake_more to currentState.clickIntents::onNextClick
} else {
R.string.common_next to currentState.clickIntents::onNextClick
}
}
StakingStep.Amount,
StakingStep.Confirm,
StakingStep.Success,
-> R.string.common_next to currentState.clickIntents::onNextClick
StakingStep.Confirm -> R.string.common_stake to currentState.clickIntents::onNextClick
StakingStep.Validators -> R.string.common_continue to currentState.clickIntents::onNextClick
StakingStep.Success -> R.string.common_close to currentState.clickIntents::onBackClick
}
}
private fun isPrevButtonVisible(step: StakingStep): Boolean = when (step) {
StakingStep.InitialInfo,
StakingStep.Confirm,
StakingStep.Success,
-> false
StakingStep.Amount,
StakingStep.Validators,
-> true
}
private fun isButtonEnabled(uiState: StakingUiState): Boolean {
return when (uiState.currentStep) {
StakingStep.InitialInfo -> uiState.initialInfoState.isPrimaryButtonEnabled