Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-13 15:57:36 +03:00
parent 434c459728
commit 42f276c56f
25 changed files with 308 additions and 157 deletions

View file

@ -4,6 +4,7 @@ import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.staking.impl.R
internal sealed class StakingNotification(val config: NotificationConfig) {
@ -49,12 +50,23 @@ internal sealed class StakingNotification(val config: NotificationConfig) {
),
) {
data class EarnRewards(
val subtitleResourceId: Int,
val currencyName: String,
val days: Int,
) : Warning(
title = resourceReference(R.string.staking_notification_earn_rewards_title),
subtitle = resourceReference(
R.string.common_error, // FIXME
subtitleResourceId,
wrappedList(currencyName),
),
)
data class Unstake(
val cooldownPeriodDays: Int,
) : Warning(
title = resourceReference(R.string.common_unstake),
subtitle = resourceReference(
R.string.staking_notification_unstake_text,
wrappedList(cooldownPeriodDays, cooldownPeriodDays),
),
)
}

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub
import com.tangem.features.staking.impl.presentation.state.transformers.SetButtonsStateTransformer
import com.tangem.features.staking.impl.presentation.state.transformers.SetTitleTransformer
import com.tangem.utils.transformer.Transformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -25,20 +26,24 @@ internal class StakingStateController @Inject constructor() {
val uiState: StateFlow<StakingUiState> get() = mutableUiState.asStateFlow()
private val buttonsTransformer = SetButtonsStateTransformer()
private val titleTransformer = SetTitleTransformer
fun update(function: (StakingUiState) -> StakingUiState) {
mutableUiState.update(function = function)
mutableUiState.update(function = buttonsTransformer::transform)
mutableUiState.update(function = titleTransformer::transform)
}
fun update(transformer: Transformer<StakingUiState>) {
mutableUiState.update(function = transformer::transform)
mutableUiState.update(function = buttonsTransformer::transform)
mutableUiState.update(function = titleTransformer::transform)
}
fun clear() {
mutableUiState.update { getInitialState() }
mutableUiState.update(function = buttonsTransformer::transform)
mutableUiState.update(function = titleTransformer::transform)
}
private fun getInitialState(): StakingUiState {

View file

@ -5,6 +5,7 @@ import com.tangem.blockchain.common.AmountType.Coin
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.features.staking.impl.presentation.state.StakingNotification
import com.tangem.features.staking.impl.presentation.state.StakingStates
@ -81,7 +82,7 @@ internal object ConfirmationStatePreviewData {
notifications = persistentListOf(
StakingNotification.Warning.EarnRewards(
currencyName = "Solana",
days = 2,
subtitleResourceId = R.string.staking_notification_earn_rewards_text_period_day,
),
),
transactionDoneState = TransactionDoneState.Empty,

View file

@ -1,8 +1,11 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal class SetConfirmationStateLoadingTransformer(
@ -24,12 +27,7 @@ internal class SetConfirmationStateLoadingTransformer(
chosenValidator = chosenValidator,
availableValidators = yield.validators,
),
notifications = persistentListOf(
StakingNotification.Warning.EarnRewards(
currencyName = yield.token.name,
days = yield.metadata.cooldownPeriod.days,
),
),
notifications = getNotifications(prevState),
footerText = "",
transactionDoneState = TransactionDoneState.Empty,
pendingActions = persistentListOf(),
@ -38,4 +36,41 @@ internal class SetConfirmationStateLoadingTransformer(
),
)
}
private fun getNotifications(prevState: StakingUiState): ImmutableList<StakingNotification> {
return persistentListOf(
if (prevState.actionType == StakingActionCommonType.EXIT) {
StakingNotification.Warning.Unstake(
cooldownPeriodDays = yield.metadata.cooldownPeriod.days,
)
} else {
StakingNotification.Warning.EarnRewards(
currencyName = yield.token.name,
subtitleResourceId = getEarnRewardsPeriod(yield.metadata.rewardSchedule),
)
},
)
}
private fun getEarnRewardsPeriod(rewardSchedule: Yield.Metadata.RewardSchedule): Int {
return when (rewardSchedule) {
Yield.Metadata.RewardSchedule.BLOCK,
Yield.Metadata.RewardSchedule.DAY,
Yield.Metadata.RewardSchedule.ERA,
Yield.Metadata.RewardSchedule.EPOCH,
-> R.string.staking_notification_earn_rewards_text_period_day
Yield.Metadata.RewardSchedule.HOUR,
-> R.string.staking_notification_earn_rewards_text_period_hour
Yield.Metadata.RewardSchedule.WEEK,
-> R.string.staking_notification_earn_rewards_text_period_week
Yield.Metadata.RewardSchedule.MONTH,
-> R.string.staking_notification_earn_rewards_text_period_month
else
-> R.string.staking_notification_earn_rewards_text_period_day
}
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.core.ui.components.list.RoundedListWithDividersItemData
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.core.serialization.SerializedBigDecimal
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
@ -61,6 +62,7 @@ internal class SetInitialDataStateTransformer(
R.string.staking_title_stake,
wrappedList(cryptoCurrencyStatusProvider().currency.name),
),
cryptoCurrencyName = cryptoCurrencyStatusProvider.invoke().currency.name,
clickIntents = clickIntents,
currentStep = StakingStep.InitialInfo,
initialInfoState = createInitialInfoState(),
@ -74,7 +76,7 @@ internal class SetInitialDataStateTransformer(
private fun createInitialInfoState(): StakingStates.InitialInfoState.Data {
return StakingStates.InitialInfoState.Data(
isPrimaryButtonEnabled = true,
aprRange = getAprRange(),
aprRange = getAprRange(yield.validators),
infoItems = getInfoItems(),
onInfoClick = clickIntents::onInfoClick,
yieldBalance = yieldBalancesConverter.convert(Unit),
@ -84,24 +86,26 @@ internal class SetInitialDataStateTransformer(
private fun getInfoItems(): PersistentList<RoundedListWithDividersItemData> {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val yieldBalance = cryptoCurrencyStatus.value.yieldBalance
return listOfNotNull(
createAnnualPercentageRateItem(),
createAnnualPercentageRateItem(yield.validators),
createAvailableItem(cryptoCurrencyStatus),
createUnbondingPeriodItem(),
createMinimumRequirementItem(cryptoCurrencyStatus),
createRewardClaimingItem(),
createWarmupPeriodItem(),
createRewardScheduleItem(),
createUnbondingPeriodItem(yield.metadata.cooldownPeriod.days),
createMinimumRequirementItem(
cryptoCurrencyStatus = cryptoCurrencyStatus,
minimumCryptoAmount = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum,
),
createRewardClaimingItem(yield.metadata.rewardClaiming),
createWarmupPeriodItem(yield.metadata.warmupPeriod.days),
createRewardScheduleItem(yield.metadata.rewardSchedule),
).toPersistentList()
}
private fun createAnnualPercentageRateItem(): RoundedListWithDividersItemData {
private fun createAnnualPercentageRateItem(validators: List<Yield.Validator>): RoundedListWithDividersItemData {
return RoundedListWithDividersItemData(
id = R.string.staking_details_annual_percentage_rate,
startText = TextReference.Res(R.string.staking_details_annual_percentage_rate),
endText = getAprRange(),
endText = getAprRange(validators),
iconClick = { clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) },
)
}
@ -120,14 +124,14 @@ internal class SetInitialDataStateTransformer(
)
}
private fun createUnbondingPeriodItem(): RoundedListWithDividersItemData {
private fun createUnbondingPeriodItem(cooldownPeriodDays: Int): RoundedListWithDividersItemData {
return RoundedListWithDividersItemData(
id = R.string.staking_details_unbonding_period,
startText = TextReference.Res(R.string.staking_details_unbonding_period),
endText = pluralReference(
id = R.plurals.common_days,
count = yield.metadata.cooldownPeriod.days,
formatArgs = wrappedList(yield.metadata.cooldownPeriod.days),
count = cooldownPeriodDays,
formatArgs = wrappedList(cooldownPeriodDays),
),
iconClick = { clickIntents.onInfoClick(InfoType.UNBONDING_PERIOD) },
)
@ -135,53 +139,60 @@ internal class SetInitialDataStateTransformer(
private fun createMinimumRequirementItem(
cryptoCurrencyStatus: CryptoCurrencyStatus,
minimumCryptoAmount: SerializedBigDecimal?,
): RoundedListWithDividersItemData? {
val minimumCryptoAmount = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum
return minimumCryptoAmount?.let {
RoundedListWithDividersItemData(
id = R.string.staking_details_minimum_requirement,
startText = TextReference.Res(R.string.staking_details_minimum_requirement),
endText = TextReference.Str(
value = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = it,
cryptoCurrency = cryptoCurrencyStatus.currency.symbol,
decimals = cryptoCurrencyStatus.currency.decimals,
),
minimumCryptoAmount ?: return null
return RoundedListWithDividersItemData(
id = R.string.staking_details_minimum_requirement,
startText = TextReference.Res(R.string.staking_details_minimum_requirement),
endText = TextReference.Str(
value = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = minimumCryptoAmount,
cryptoCurrency = cryptoCurrencyStatus.currency.symbol,
decimals = cryptoCurrencyStatus.currency.decimals,
),
)
}
),
)
}
private fun createRewardClaimingItem(): RoundedListWithDividersItemData {
private fun createRewardClaimingItem(
rewardClaiming: Yield.Metadata.RewardClaiming,
): RoundedListWithDividersItemData? {
val endTextId = rewardClaimingResources[rewardClaiming] ?: return null
return RoundedListWithDividersItemData(
id = R.string.staking_details_reward_claiming,
startText = TextReference.Res(R.string.staking_details_reward_claiming),
endText = TextReference.Str(yield.metadata.rewardClaiming),
endText = TextReference.Res(endTextId),
iconClick = { clickIntents.onInfoClick(InfoType.REWARD_CLAIMING) },
)
}
private fun createWarmupPeriodItem(): RoundedListWithDividersItemData? {
val warmupPeriod = yield.metadata.warmupPeriod.days
if (warmupPeriod == 0) return null
private fun createWarmupPeriodItem(warmupPeriodDays: Int): RoundedListWithDividersItemData? {
if (warmupPeriodDays == 0) return null
return RoundedListWithDividersItemData(
id = R.string.staking_details_warmup_period,
startText = TextReference.Res(R.string.staking_details_warmup_period),
endText = pluralReference(
id = R.plurals.common_days,
count = yield.metadata.warmupPeriod.days,
formatArgs = wrappedList(yield.metadata.warmupPeriod.days),
count = warmupPeriodDays,
formatArgs = wrappedList(warmupPeriodDays),
),
iconClick = { clickIntents.onInfoClick(InfoType.WARMUP_PERIOD) },
)
}
private fun createRewardScheduleItem(): RoundedListWithDividersItemData {
private fun createRewardScheduleItem(
rewardSchedule: Yield.Metadata.RewardSchedule,
): RoundedListWithDividersItemData? {
val endTextId = rewardScheduleResources[rewardSchedule] ?: return null
return RoundedListWithDividersItemData(
id = R.string.staking_details_reward_schedule,
startText = TextReference.Res(R.string.staking_details_reward_schedule),
endText = TextReference.Str(yield.metadata.rewardSchedule),
endText = TextReference.Res(endTextId),
iconClick = { clickIntents.onInfoClick(InfoType.REWARD_SCHEDULE) },
)
}
@ -200,8 +211,8 @@ internal class SetInitialDataStateTransformer(
)
}
private fun getAprRange(): TextReference {
val aprValues = yield.validators.mapNotNull { it.apr }
private fun getAprRange(validators: List<Yield.Validator>): TextReference {
val aprValues = validators.mapNotNull { it.apr }
val minApr = aprValues.min()
val maxApr = aprValues.max()
@ -223,5 +234,22 @@ internal class SetInitialDataStateTransformer(
companion object {
private val EQUALITY_THRESHOLD = BigDecimal(1E-10)
private val rewardScheduleResources = mapOf(
Yield.Metadata.RewardSchedule.BLOCK to R.string.staking_reward_schedule_each_day,
Yield.Metadata.RewardSchedule.WEEK to R.string.staking_reward_schedule_week,
Yield.Metadata.RewardSchedule.HOUR to R.string.staking_reward_schedule_hour,
Yield.Metadata.RewardSchedule.DAY to R.string.staking_reward_schedule_each_day,
Yield.Metadata.RewardSchedule.MONTH to R.string.staking_reward_schedule_month,
Yield.Metadata.RewardSchedule.ERA to R.string.staking_reward_schedule_era,
Yield.Metadata.RewardSchedule.EPOCH to R.string.staking_reward_schedule_epoch,
Yield.Metadata.RewardSchedule.UNKNOWN to null,
)
private val rewardClaimingResources = mapOf(
Yield.Metadata.RewardClaiming.MANUAL to R.string.staking_reward_claiming_manual,
Yield.Metadata.RewardClaiming.AUTO to R.string.staking_reward_claiming_auto,
Yield.Metadata.RewardSchedule.UNKNOWN to null,
)
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.utils.transformer.Transformer
internal object SetTitleTransformer : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val actionType = prevState.actionType
val currentStep = prevState.currentStep
val title = when {
currentStep == StakingStep.Amount -> {
resourceReference(R.string.send_amount_label)
}
currentStep == StakingStep.Validators -> {
resourceReference(R.string.staking_validators)
}
actionType == StakingActionCommonType.EXIT && currentStep != StakingStep.InitialInfo -> {
resourceReference(
R.string.staking_title_unstake,
wrappedList(prevState.cryptoCurrencyName),
)
}
else -> {
resourceReference(
R.string.staking_title_stake,
wrappedList(prevState.cryptoCurrencyName),
)
}
}
return prevState.copy(title = title)
}
}

View file

@ -31,7 +31,7 @@ internal fun StakingClaimRewardsValidatorContent(
if (state !is StakingStates.RewardsValidatorsState.Data) return
Column(
modifier = Modifier // Do not put fillMaxSize() in here
.background(TangemTheme.colors.background.tertiary)
.background(TangemTheme.colors.background.secondary)
.padding(horizontal = TangemTheme.dimens.spacing12)
.verticalScroll(rememberScrollState()),
) {

View file

@ -40,7 +40,7 @@ internal fun StakingConfirmationContent(
Column(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.background(TangemTheme.colors.background.secondary)
.padding(horizontal = TangemTheme.dimens.spacing16)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
@ -107,14 +108,16 @@ internal fun StakingInitialInfoContent(state: StakingStates.InitialInfoState, cl
@Composable
private fun BannerBlock(onClick: () -> Unit) {
Box(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(),
onClick = onClick,
),
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(),
onClick = onClick,
),
) {
Image(
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.FillWidth,
painter = painterResource(R.drawable.img_staking_banner),
contentDescription = null,
)

View file

@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tangem.common.ui.amountScreen.AmountScreenContent
import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig
@ -36,7 +35,7 @@ internal fun StakingScreen(uiState: StakingUiState) {
BackHandler(onBack = uiState.clickIntents::onPrevClick)
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary)
.background(color = TangemTheme.colors.background.secondary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
@ -72,14 +71,6 @@ fun StakingBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) {
@Composable
private fun SendAppBar(uiState: StakingUiState) {
val titleRes = when (uiState.currentStep) {
StakingStep.Amount -> stringResource(id = R.string.send_amount_label)
StakingStep.InitialInfo,
StakingStep.RewardsValidators,
StakingStep.Validators,
StakingStep.Confirmation,
-> uiState.title.resolveReference()
}
val backIcon = when (uiState.currentStep) {
StakingStep.Amount,
StakingStep.Validators,
@ -94,10 +85,10 @@ private fun SendAppBar(uiState: StakingUiState) {
}
}
AppBarWithBackButtonAndIcon(
text = titleRes,
text = uiState.title.resolveReference(),
backIconRes = backIcon,
onBackClick = uiState.clickIntents::onBackClick,
backgroundColor = TangemTheme.colors.background.tertiary,
backgroundColor = TangemTheme.colors.background.secondary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}

View file

@ -4,12 +4,10 @@ import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
@ -47,23 +45,6 @@ internal fun StakingValidatorListContent(
contentPadding = PaddingValues(bottom = bottomBarHeight),
modifier = modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
item(key = "HEADER") {
Text(
text = stringResource(R.string.staking_validator),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.clip(CornersToRound.TOP_2.getShape())
.background(TangemTheme.colors.background.action)
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing8,
),
)
}
if (state is ValidatorState.Content) {
val validators = state.availableValidators
items(
@ -93,10 +74,10 @@ internal fun StakingValidatorListContent(
onSelect = { clickIntents.onValidatorSelect(item) },
modifier = Modifier
.clip(
if (index == validators.lastIndex) {
CornersToRound.BOTTOM_2
} else {
CornersToRound.ZERO
when (index) {
0 -> CornersToRound.TOP_2
validators.lastIndex -> CornersToRound.BOTTOM_2
else -> CornersToRound.ZERO
}.getShape(),
)
.background(TangemTheme.colors.background.action),

View file

@ -88,7 +88,7 @@ private fun BoxScope.FeeLoading(feeState: FeeState) {
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier.size(
height = TangemTheme.dimens.size12,
height = TangemTheme.dimens.size24,
width = TangemTheme.dimens.size90,
),
)
@ -107,7 +107,7 @@ private fun BoxScope.FeeError(feeState: FeeState) {
Text(
text = BigDecimalFormatter.EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
style = TangemTheme.typography.body1,
)
}
}
@ -117,7 +117,7 @@ private fun BoxScope.FeeError(feeState: FeeState) {
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) value: FeeState.Content) {
private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) value: FeeState) {
TangemThemePreview {
StakingFeeBlock(
feeState = value,
@ -125,11 +125,13 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va
}
}
private class FeeBlockPreviewProvider : PreviewParameterProvider<FeeState.Content> {
private class FeeBlockPreviewProvider : PreviewParameterProvider<FeeState> {
override val values: Sequence<FeeState.Content>
override val values: Sequence<FeeState>
get() = sequenceOf(
feeState,
contentState,
FeeState.Loading,
FeeState.Error,
)
private val fee = Fee.Common(
@ -141,7 +143,7 @@ private class FeeBlockPreviewProvider : PreviewParameterProvider<FeeState.Conten
),
)
private val feeState = FeeState.Content(
private val contentState = FeeState.Content(
fee = fee,
rate = BigDecimal.ONE,
appCurrency = AppCurrency.Default,

View file

@ -221,7 +221,7 @@ internal class StakingViewModel @Inject constructor(
}
override fun onInitialInfoBannerClick() {
innerRouter.openUrl(WHAT_IS_STAKING_ARTICLE_URL)
// innerRouter.openUrl(WHAT_IS_STAKING_ARTICLE_URL)
}
override fun onInfoClick(infoType: InfoType) {