Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-11 18:55:37 +03:00
parent 330ae73357
commit e7efe7b76a
4636 changed files with 234864 additions and 63507 deletions

1
features/staking/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,24 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("kotlin-parcelize")
id("configuration")
}
android {
namespace = "com.tangem.features.staking.api"
}
dependencies {
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/** Domain models */
implementation(projects.domain.staking.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
/** AndroidX */
implementation(deps.androidx.fragment.ktx)
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.staking.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
interface StakingComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrencyId: CryptoCurrency.ID,
val yieldId: String,
)
interface Factory : ComponentFactory<Params, StakingComponent>
}

1
features/staking/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,82 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.staking.impl"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.fragment.ktx)
implementation(deps.androidx.appCompat)
implementation(deps.androidx.paging.runtime)
/** Other dependencies */
implementation(deps.kotlin.immutable.collections)
implementation(deps.material)
implementation(deps.arrow.core)
implementation(deps.lifecycle.compose)
implementation(deps.jodatime)
implementation(deps.timber)
/** Compose */
implementation(deps.compose.accompanist.systemUiController)
implementation(deps.compose.material3)
implementation(deps.compose.material)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.navigation)
implementation(deps.compose.navigation.hilt)
implementation(deps.compose.constraintLayout)
/** Tangem SDKs */
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.ui)
implementation(projects.core.utils)
implementation(projects.core.navigation)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.decompose)
/** Domain */
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.staking)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.transaction)
implementation(projects.domain.transaction.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.feedback)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)
/** Libs */
implementation(projects.libs.crypto)
/** Feature modules */
implementation(projects.features.staking.api)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.staking.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.staking.api.StakingComponent
import com.tangem.features.staking.impl.presentation.model.StakingModel
import com.tangem.features.staking.impl.presentation.ui.StakingScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultStakingComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: StakingComponent.Params,
) : StakingComponent, AppComponentContext by appComponentContext {
private val model: StakingModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val currentState by model.uiState.collectAsStateWithLifecycle()
StakingScreen(currentState)
}
@AssistedFactory
interface Factory : StakingComponent.Factory {
override fun create(context: AppComponentContext, params: StakingComponent.Params): DefaultStakingComponent
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.features.staking.impl.analytics
import com.tangem.core.analytics.api.ParamsInterceptor
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.staking.analytics.StakingAnalyticsEvent
class StakingParamsInterceptor(private val tokenSymbol: String) : ParamsInterceptor {
override fun id() = ID
override fun canBeAppliedTo(event: AnalyticsEvent): Boolean {
return event is StakingAnalyticsEvent
}
override fun intercept(params: MutableMap<String, String>) {
params[AnalyticsParam.TOKEN_PARAM] = tokenSymbol
}
companion object {
const val ID = "StakingParamsInterceptorId"
}
}

View file

@ -0,0 +1,121 @@
package com.tangem.features.staking.impl.analytics.utils
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.staking.analytics.StakeScreenSource
import com.tangem.domain.staking.analytics.StakingAnalyticsEvent
import com.tangem.features.staking.impl.presentation.state.*
internal class StakingAnalyticSender(
private val analyticsEventHandler: AnalyticsEventHandler,
) {
fun initialInfoScreen(value: StakingUiState) {
val initialInfoState = value.initialInfoState as? StakingStates.InitialInfoState.Data
val validatorState = initialInfoState?.yieldBalance as? InnerYieldBalanceState.Data
val validatorCount = validatorState?.balances
?.filterNot { it.validator?.address.isNullOrBlank() }
?.distinctBy { it.validator?.address }
?.size ?: 0
analyticsEventHandler.send(
StakingAnalyticsEvent.StakingInfoScreenOpened(
validatorsCount = validatorCount,
),
)
}
fun confirmationScreen(value: StakingUiState) {
val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data
val validatorState = value.validatorState as? StakingStates.ValidatorState.Data
val validatorName = validatorState?.chosenValidator?.name ?: return
if (confirmationState?.innerState == InnerConfirmationStakingState.COMPLETED) return
analyticsEventHandler.send(
StakingAnalyticsEvent.ConfirmationScreenOpened(
validator = validatorName,
action = getStakingActionType(value),
),
)
}
fun screenCancel(value: StakingUiState) {
analyticsEventHandler.send(
StakingAnalyticsEvent.ButtonCancel(
source = when (value.currentStep) {
StakingStep.InitialInfo -> StakeScreenSource.Info
StakingStep.Amount -> StakeScreenSource.Amount
StakingStep.Confirmation -> StakeScreenSource.Confirmation
StakingStep.Validators,
StakingStep.RestakeValidator,
StakingStep.RewardsValidators,
-> StakeScreenSource.Validators
},
),
)
}
fun sendTransactionApprovalAnalytics(tokenCryptoCurrency: CryptoCurrency) {
analyticsEventHandler.send(
Basic.TransactionSent(
sentFrom = AnalyticsParam.TxSentFrom.Approve(
blockchain = tokenCryptoCurrency.network.name,
token = tokenCryptoCurrency.symbol,
feeType = AnalyticsParam.FeeType.Normal,
permissionType = ApproveType.LIMITED.name,
),
memoType = Basic.TransactionSent.MemoType.Null,
),
)
}
fun sendTransactionStakingAnalytics(value: StakingUiState) {
val validatorState = value.validatorState as? StakingStates.ValidatorState.Data
val validatorName = validatorState?.chosenValidator?.name ?: return
analyticsEventHandler.send(
Basic.TransactionSent(
sentFrom = AnalyticsParam.TxSentFrom.Staking(
blockchain = value.cryptoCurrencyName,
token = value.cryptoCurrencySymbol,
feeType = AnalyticsParam.FeeType.Normal,
),
memoType = Basic.TransactionSent.MemoType.Null,
),
)
analyticsEventHandler.send(
StakingAnalyticsEvent.StakeInProgressScreenOpened(
validator = validatorName,
action = getStakingActionType(value),
),
)
}
fun sendTransactionStakingClickedAnalytics(value: StakingUiState) {
val validatorState = value.validatorState as? StakingStates.ValidatorState.Data
val validatorName = validatorState?.chosenValidator?.name ?: return
analyticsEventHandler.send(
StakingAnalyticsEvent.ButtonAction(
action = getStakingActionType(value),
validator = validatorName,
),
)
}
private fun getStakingActionType(value: StakingUiState): StakingActionType {
val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data
return when (value.actionType) {
StakingActionCommonType.Enter -> StakingActionType.STAKE
is StakingActionCommonType.Exit -> StakingActionType.UNSTAKE
is StakingActionCommonType.Pending -> confirmationState?.pendingAction?.type ?: StakingActionType.UNKNOWN
}
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.staking.impl.di
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.staking.api.StakingComponent
import com.tangem.features.staking.impl.DefaultStakingComponent
import com.tangem.features.staking.impl.navigation.DefaultStakingRouter
import com.tangem.features.staking.impl.navigation.InnerStakingRouter
import com.tangem.features.staking.impl.presentation.model.StakingModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(SingletonComponent::class)
internal interface StakingModule {
@Binds
fun bindComponentFactory(factory: DefaultStakingComponent.Factory): StakingComponent.Factory
@Binds
@IntoMap
@ClassKey(StakingModel::class)
fun bindModel(model: StakingModel): Model
}
@Module
@InstallIn(DecomposeComponent::class)
internal interface StakingComponentModule {
@Binds
@ComponentScoped
fun bindRouter(impl: DefaultStakingRouter): InnerStakingRouter
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.staking.impl.navigation
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import javax.inject.Inject
@ComponentScoped
internal class DefaultStakingRouter @Inject constructor(
private val urlOpener: UrlOpener,
private val router: AppRouter,
) : InnerStakingRouter {
override fun openUrl(url: String) {
urlOpener.openUrl(url)
}
override fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) {
router.pop { isSuccess ->
if (isSuccess) {
router.push(
AppRoute.CurrencyDetails(
userWalletId = userWalletId,
currency = currency,
),
)
}
}
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.staking.impl.navigation
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
internal interface InnerStakingRouter {
fun openUrl(url: String)
fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency)
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.staking.impl.presentation.model
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType
import java.math.BigDecimal
@Suppress("TooManyFunctions")
internal interface StakingClickIntents : AmountScreenClickIntents {
fun onBackClick()
fun onNextClick(balanceState: BalanceState? = null)
fun onActionClick()
fun onPrevClick()
fun onRefreshSwipe(isRefreshing: Boolean)
fun onInitialInfoBannerClick()
fun onInfoClick(infoType: InfoType)
fun onAmountEnterClick()
fun getFee()
override fun onAmountNext() = onNextClick()
fun openValidators()
fun onValidatorSelect(validator: Yield.Validator)
fun openRewardsValidators()
fun onActiveStake(activeStake: BalanceState)
fun onActiveStakeAnalytic()
fun showApprovalBottomSheet()
fun onApproveTypeChange(approveType: ApproveType)
fun onApprovalClick()
fun onAmountReduceByClick(
reduceAmountBy: BigDecimal,
reduceAmountByDiff: BigDecimal,
notification: Class<out NotificationUM>,
)
fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class<out NotificationUM>)
fun onNotificationCancel(notification: Class<out NotificationUM>)
fun onExploreClick()
fun onShareClick()
fun onFailedTxEmailClick(errorMessage: String)
fun openTokenDetails(cryptoCurrency: CryptoCurrency)
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.staking.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.appcurrency.model.AppCurrency
import java.math.BigDecimal
@Immutable
internal sealed class FeeState {
data class Content(
val fee: Fee?,
val rate: BigDecimal?,
val isFeeConvertibleToFiat: Boolean,
val appCurrency: AppCurrency,
val isFeeApproximate: Boolean,
) : FeeState()
data object Loading : FeeState()
data object Error : FeeState()
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.staking.impl.presentation.state
internal enum class InnerConfirmationStakingState {
ASSENT,
IN_PROGRESS,
COMPLETED,
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.staking.impl.presentation.state
import com.tangem.blockchain.common.transaction.TransactionFee
internal sealed class InnerFeeState {
data class Content(
val fees: TransactionFee,
) : InnerFeeState()
data object Loading : InnerFeeState()
data object Error : InnerFeeState()
}

View file

@ -0,0 +1,42 @@
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 kotlinx.collections.immutable.ImmutableList
import org.joda.time.DateTime
import java.math.BigDecimal
internal sealed class InnerYieldBalanceState {
data class Data(
val rewardsCrypto: String,
val rewardsFiat: String,
val rewardBlockType: RewardBlockType,
val isActionable: Boolean,
val balances: ImmutableList<BalanceState>,
) : InnerYieldBalanceState()
data object Empty : InnerYieldBalanceState()
}
@Immutable
internal data class BalanceState(
val groupId: String,
val title: TextReference,
val type: BalanceType,
val subtitle: TextReference?,
val isClickable: Boolean,
val cryptoValue: String,
val cryptoAmount: BigDecimal,
val formattedCryptoAmount: TextReference,
val fiatAmount: BigDecimal?,
val formattedFiatAmount: TextReference,
val rawCurrencyId: String?,
val validator: Yield.Validator?,
val pendingActions: ImmutableList<PendingAction>,
val isPending: Boolean,
val date: DateTime,
)

View file

@ -0,0 +1,105 @@
package com.tangem.features.staking.impl.presentation.state
import androidx.annotation.StringRes
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.*
import com.tangem.features.staking.impl.R
internal object StakingNotification {
sealed class Error(
title: TextReference,
subtitle: TextReference,
buttonState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : NotificationUM.Error(
title = title,
subtitle = subtitle,
iconResId = R.drawable.ic_alert_24,
buttonState = buttonState,
onCloseClick = onCloseClick,
) {
data class StakedPositionNotFoundError(val message: String) : StakingNotification.Error(
title = stringReference(message),
subtitle = stringReference(message),
)
data class Common(val subtitle: TextReference) : StakingNotification.Error(
title = resourceReference(R.string.common_error),
subtitle = subtitle,
)
}
sealed class Warning(
title: TextReference,
subtitle: TextReference,
buttonsState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : NotificationUM.Warning(
title = title,
subtitle = subtitle,
iconResId = R.drawable.img_attention_20,
buttonsState = buttonsState,
onCloseClick = onCloseClick,
) {
data class TransactionInProgress(
val title: TextReference,
val description: TextReference,
) : StakingNotification.Warning(title = title, subtitle = description)
data object LowStakedBalance : StakingNotification.Warning(
title = resourceReference(R.string.staking_notification_low_staked_balance_title),
subtitle = resourceReference(R.string.staking_notification_low_staked_balance_text),
)
}
sealed class Info(
title: TextReference,
subtitle: TextReference,
buttonsState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : NotificationUM.Info(
title = title,
subtitle = subtitle,
buttonsState = buttonsState,
onCloseClick = onCloseClick,
) {
data class EarnRewards(
val subtitleText: TextReference,
) : StakingNotification.Info(
title = resourceReference(R.string.staking_notification_earn_rewards_title),
subtitle = subtitleText,
)
data object StakeEntireBalance : StakingNotification.Info(
title = resourceReference(R.string.common_network_fee_title),
subtitle = resourceReference(R.string.staking_notification_stake_entire_balance_text),
)
data class Unstake(
val cooldownPeriodDays: Int,
@StringRes val subtitleRes: Int,
) : StakingNotification.Info(
title = resourceReference(R.string.common_unstake),
subtitle = resourceReference(
subtitleRes,
wrappedList(
pluralReference(
id = R.plurals.common_days,
count = cooldownPeriodDays,
formatArgs = wrappedList(cooldownPeriodDays),
),
),
),
)
data class Ordinary(
val title: TextReference,
val text: TextReference,
) : StakingNotification.Info(
title = title,
subtitle = text,
)
}
}

View file

@ -0,0 +1,93 @@
package com.tangem.features.staking.impl.presentation.state
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.presentation.state.events.StakingEvent
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
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class StakingStateController @Inject constructor(
urlOpener: UrlOpener,
) {
val value: StakingUiState get() = uiState.value
private val mutableUiState: MutableStateFlow<StakingUiState> = MutableStateFlow(value = getInitialState())
val uiState: StateFlow<StakingUiState> get() = mutableUiState.asStateFlow()
private val buttonsTransformer = SetButtonsStateTransformer(urlOpener)
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 updateAll(vararg transformer: Transformer<StakingUiState>) {
transformer.forEach { mutableUiState.update(function = it::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)
}
fun updateEvent(event: StakingEvent?) {
mutableUiState.update {
it.copy(event = event?.let { triggeredEvent(event, ::dismissAlert) } ?: consumedEvent())
}
}
fun dismissAlert() {
mutableUiState.update { it.copy(event = consumedEvent()) }
}
private fun getInitialState(): StakingUiState {
return StakingUiState(
title = TextReference.EMPTY,
subtitle = null,
clickIntents = StakingClickIntentsStub,
walletName = "",
cryptoCurrencyName = "",
cryptoCurrencySymbol = "",
cryptoCurrencyBlockchainId = "",
currentStep = StakingStep.InitialInfo,
initialInfoState = StakingStates.InitialInfoState.Empty(),
amountState = AmountState.Empty(),
validatorState = StakingStates.ValidatorState.Empty(),
rewardsValidatorsState = StakingStates.RewardsValidatorsState.Empty(),
confirmationState = StakingStates.ConfirmationState.Empty(),
isBalanceHidden = false,
event = consumedEvent(),
bottomSheetConfig = null,
actionType = StakingActionCommonType.Enter,
buttonsState = NavigationButtonsState.Empty,
balanceState = null,
)
}
}

View file

@ -0,0 +1,94 @@
package com.tangem.features.staking.impl.presentation.state
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.staking.analytics.StakingAnalyticsEvent
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.analytics.utils.StakingAnalyticSender
internal class StakingStateRouter(
private val appRouter: AppRouter,
private val stateController: StakingStateController,
private val analyticsEventsHandler: AnalyticsEventHandler,
) {
private val analyticSender = StakingAnalyticSender(analyticsEventsHandler)
fun onBackClick() {
analyticSender.screenCancel(stateController.value)
appRouter.pop()
stateController.clear()
}
fun onNextClick() {
when (stateController.value.currentStep) {
StakingStep.InitialInfo -> when (val actionType = stateController.value.actionType) {
StakingActionCommonType.Enter -> showAmount()
is StakingActionCommonType.Exit -> if (actionType.partiallyUnstakeDisabled) {
showConfirmation()
} else {
showAmount()
}
StakingActionCommonType.Pending.Other,
StakingActionCommonType.Pending.Rewards,
-> showConfirmation()
StakingActionCommonType.Pending.Restake -> showRestakeValidators()
}
StakingStep.RestakeValidator,
StakingStep.RewardsValidators,
StakingStep.Validators,
StakingStep.Amount,
-> showConfirmation()
StakingStep.Confirmation -> showInitial()
}
}
fun onPrevClick() {
val uiState = stateController.uiState.value
when (uiState.currentStep) {
StakingStep.InitialInfo -> onBackClick()
StakingStep.RestakeValidator,
StakingStep.RewardsValidators,
StakingStep.Amount,
-> showInitial()
StakingStep.Confirmation -> {
when (val actionType = uiState.actionType) {
StakingActionCommonType.Enter -> showAmount()
is StakingActionCommonType.Pending -> showInitial()
is StakingActionCommonType.Exit -> {
if (actionType.partiallyUnstakeDisabled) showInitial() else showAmount()
}
}
}
StakingStep.Validators -> showConfirmation()
}
}
fun showValidators() {
stateController.update { it.copy(currentStep = StakingStep.Validators) }
}
private fun showInitial() {
analyticSender.initialInfoScreen(stateController.value)
stateController.update { it.copy(currentStep = StakingStep.InitialInfo) }
}
fun showRewardsValidators() {
analyticsEventsHandler.send(StakingAnalyticsEvent.RewardScreenOpened)
stateController.update { it.copy(currentStep = StakingStep.RewardsValidators) }
}
private fun showAmount() {
analyticsEventsHandler.send(StakingAnalyticsEvent.AmountScreenOpened)
stateController.update { it.copy(currentStep = StakingStep.Amount) }
}
private fun showRestakeValidators() {
stateController.update { it.copy(currentStep = StakingStep.RestakeValidator) }
}
private fun showConfirmation() {
analyticSender.confirmationScreen(stateController.value)
stateController.update { it.copy(currentStep = StakingStep.Confirmation) }
}
}

View file

@ -0,0 +1,141 @@
package com.tangem.features.staking.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.list.RoundedListWithDividersItemData
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType
import com.tangem.features.staking.impl.presentation.state.events.StakingEvent
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
/**
* Ui states of the staking screen
*/
@Immutable
internal data class StakingUiState(
val title: TextReference,
val subtitle: TextReference?,
val clickIntents: StakingClickIntents,
val walletName: String,
val cryptoCurrencyName: String,
val cryptoCurrencySymbol: String,
val cryptoCurrencyBlockchainId: String,
val currentStep: StakingStep,
val initialInfoState: StakingStates.InitialInfoState,
val amountState: AmountState,
val rewardsValidatorsState: StakingStates.RewardsValidatorsState,
val confirmationState: StakingStates.ConfirmationState,
val validatorState: StakingStates.ValidatorState,
val isBalanceHidden: Boolean,
val bottomSheetConfig: TangemBottomSheetConfig?,
val actionType: StakingActionCommonType,
val buttonsState: NavigationButtonsState,
val event: StateEvent<StakingEvent>,
val balanceState: BalanceState?,
) {
fun copyWrapped(
initialInfoState: StakingStates.InitialInfoState = this.initialInfoState,
amountState: AmountState = this.amountState,
confirmationState: StakingStates.ConfirmationState = this.confirmationState,
validatorState: StakingStates.ValidatorState = this.validatorState,
): StakingUiState = copy(
initialInfoState = initialInfoState,
amountState = amountState,
confirmationState = confirmationState,
validatorState = validatorState,
)
}
internal sealed class StakingStates {
abstract val isPrimaryButtonEnabled: Boolean
/** Initial info state */
sealed class InitialInfoState : StakingStates() {
data class Data(
override val isPrimaryButtonEnabled: Boolean,
val showBanner: Boolean,
val infoItems: ImmutableList<RoundedListWithDividersItemData>,
val aprRange: TextReference,
val onInfoClick: (InfoType) -> Unit,
val yieldBalance: InnerYieldBalanceState,
val pullToRefreshConfig: PullToRefreshConfig,
) : InitialInfoState()
data class Empty(
override val isPrimaryButtonEnabled: Boolean = false,
) : InitialInfoState()
}
/** Select validator to claim rewards state */
sealed class RewardsValidatorsState : StakingStates() {
data class Data(
override val isPrimaryButtonEnabled: Boolean,
val rewards: ImmutableList<BalanceState>,
) : RewardsValidatorsState()
data class Empty(
override val isPrimaryButtonEnabled: Boolean = false,
) : RewardsValidatorsState()
}
sealed class ValidatorState : StakingStates() {
abstract val isClickable: Boolean
data class Data(
override val isPrimaryButtonEnabled: Boolean,
override val isClickable: Boolean,
val isVisibleOnConfirmation: Boolean,
val chosenValidator: Yield.Validator,
val activeValidator: Yield.Validator?,
val availableValidators: List<Yield.Validator>,
) : ValidatorState()
data class Empty(
override val isClickable: Boolean = false,
override val isPrimaryButtonEnabled: Boolean = false,
) : ValidatorState()
}
/** Confirmation state */
sealed class ConfirmationState : StakingStates() {
data class Data(
override val isPrimaryButtonEnabled: Boolean,
val innerState: InnerConfirmationStakingState,
val feeState: FeeState,
val pendingAction: PendingAction?,
val pendingActions: ImmutableList<PendingAction>?,
val notifications: ImmutableList<NotificationUM>,
val footerText: TextReference,
val transactionDoneState: TransactionDoneState,
val isApprovalNeeded: Boolean,
val isAmountEditable: Boolean,
val allowance: BigDecimal,
val reduceAmountBy: BigDecimal?,
) : ConfirmationState()
data class Empty(
override val isPrimaryButtonEnabled: Boolean = false,
) : ConfirmationState()
}
}
enum class StakingStep {
InitialInfo,
RewardsValidators,
Amount,
RestakeValidator,
Confirmation,
Validators,
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.staking.impl.presentation.state
import androidx.compose.runtime.Immutable
@Immutable
internal sealed class TransactionDoneState {
data class Content(
val timestamp: Long,
val txUrl: String,
) : TransactionDoneState()
data object Empty : TransactionDoneState()
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.staking.impl.presentation.state.bottomsheet
internal enum class InfoType {
ANNUAL_PERCENTAGE_RATE,
UNBONDING_PERIOD,
REWARD_CLAIMING,
WARMUP_PERIOD,
REWARD_SCHEDULE,
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.staking.impl.presentation.state.bottomsheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.staking.model.stakekit.PendingAction
internal data class StakingActionSelectionBottomSheetConfig(
val title: TextReference,
val actions: List<PendingAction>,
val onActionSelect: (PendingAction) -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,9 @@
package com.tangem.features.staking.impl.presentation.state.bottomsheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
internal data class StakingInfoBottomSheetConfig(
val title: TextReference,
val text: TextReference,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,149 @@
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.format
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.stakekit.BalanceItem
import com.tangem.domain.staking.model.stakekit.BalanceType
import com.tangem.domain.staking.model.stakekit.BalanceType.Companion.isClickable
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
import org.joda.time.DateTime
import java.util.Calendar
internal class BalanceItemConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val yield: Yield,
) : Converter<BalanceItem, BalanceState?> {
override fun convert(value: BalanceItem): BalanceState? {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val appCurrency = appCurrencyProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val validator = yield.validators.firstOrNull {
value.validatorAddress?.contains(it.address, ignoreCase = true) == true
}
val cryptoAmount = value.amount
val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount)
val title = value.type.getTitle(validator?.name)
return title?.let {
BalanceState(
groupId = value.groupId,
validator = validator,
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(
BigDecimalFormatter.formatFiatAmount(
fiatAmount = fiatAmount,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
),
),
rawCurrencyId = value.rawCurrencyId,
pendingActions = value.pendingActions.toPersistentList(),
isClickable = value.type.isClickable() && !value.isPending,
isPending = value.isPending,
date = value.date ?: DateTime.now(),
)
}
}
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 = yield.metadata.warmupPeriod.days
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: DateTime?): TextReference? {
val unbondingPeriod = yield.metadata.cooldownPeriod?.days ?: 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.millis
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 Calendar.resetHours() {
this[Calendar.HOUR_OF_DAY] = 0
this[Calendar.MINUTE] = 0
this[Calendar.SECOND] = 0
this[Calendar.MILLISECOND] = 0
}
private companion object {
const val DAY_IN_MILLIS = 24 * 60 * 60 * 1000
}
}

View file

@ -0,0 +1,100 @@
package com.tangem.features.staking.impl.presentation.state.converters
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.stakekit.BalanceItem
import com.tangem.domain.staking.model.stakekit.BalanceType
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
import org.joda.time.DateTime
import java.math.BigDecimal
internal class RewardsValidatorStateConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val yield: Yield,
) : Converter<Unit, StakingStates.RewardsValidatorsState> {
override fun convert(value: Unit): StakingStates.RewardsValidatorsState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val yieldBalance = cryptoCurrencyStatus.value.yieldBalance
return if (yieldBalance is YieldBalance.Data) {
val balances = yieldBalance.balance.items
StakingStates.RewardsValidatorsState.Data(
isPrimaryButtonEnabled = true,
rewards = balances
.filter { it.type == BalanceType.REWARDS }
.mapRewardBalances(cryptoCurrencyStatus)
.toPersistentList(),
)
} else {
StakingStates.RewardsValidatorsState.Empty()
}
}
private fun List<BalanceItem>.mapRewardBalances(cryptoCurrencyStatus: CryptoCurrencyStatus) =
this.mapNotNull { balance ->
val validator = yield.validators.firstOrNull {
it.address.contains(balance.validatorAddress.orEmpty(), ignoreCase = true)
}
val cryptoValue = balance.amount
val fiatValue = cryptoCurrencyStatus.value.fiatRate?.times(cryptoValue)
validator?.toBalanceState(
balance = balance,
cryptoCurrencyStatus = cryptoCurrencyStatus,
cryptoValue = cryptoValue,
fiatValue = fiatValue,
)
}
private fun Yield.Validator.toBalanceState(
balance: BalanceItem,
cryptoCurrencyStatus: CryptoCurrencyStatus,
cryptoValue: BigDecimal,
fiatValue: BigDecimal?,
): BalanceState {
val appCurrency = appCurrencyProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val cryptoAmount = stringReference(
cryptoValue.format {
crypto(cryptoCurrency)
},
)
val formattedFiatAmount = stringReference(
BigDecimalFormatter.formatFiatAmount(
fiatAmount = fiatValue,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
),
)
return BalanceState(
groupId = balance.groupId,
validator = this,
title = stringReference(this.name),
subtitle = null,
cryptoValue = cryptoValue.parseBigDecimal(cryptoCurrency.decimals),
cryptoAmount = cryptoValue,
formattedCryptoAmount = cryptoAmount,
fiatAmount = fiatValue,
formattedFiatAmount = formattedFiatAmount,
rawCurrencyId = cryptoCurrency.id.rawCurrencyId?.value,
pendingActions = balance.pendingActions.toPersistentList(),
isClickable = true,
type = balance.type,
isPending = balance.isPending,
date = balance.date ?: DateTime.now(),
)
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.features.staking.impl.presentation.state.converters
import com.tangem.common.extensions.isZero
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.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.stakekit.*
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState
import com.tangem.lib.crypto.BlockchainUtils.isBSC
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
internal class YieldBalancesConverter(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val balancesToShowProvider: Provider<List<BalanceItem>>,
private val yield: Yield,
) : Converter<Unit, InnerYieldBalanceState> {
private val balanceItemConverter by lazy(LazyThreadSafetyMode.NONE) {
BalanceItemConverter(cryptoCurrencyStatusProvider, appCurrencyProvider, yield)
}
override fun convert(value: Unit): InnerYieldBalanceState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val appCurrency = appCurrencyProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val yieldBalance = cryptoCurrencyStatus.value.yieldBalance
val balanceToShowItems = balancesToShowProvider()
return if (yieldBalance is YieldBalance.Data || balanceToShowItems.any { it.isPending }) {
val cryptoRewardsValue = (yieldBalance as? YieldBalance.Data)?.getRewardStakingBalance()
val fiatRate = cryptoCurrencyStatus.value.fiatRate
val fiatRewardsValue = if (fiatRate != null && cryptoRewardsValue != null) {
fiatRate.times(cryptoRewardsValue)
} else {
null
}
val (type, isActionable) = getRewardBlockType()
InnerYieldBalanceState.Data(
rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) },
rewardsFiat = fiatRewardsValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
rewardBlockType = type,
isActionable = isActionable,
balances = balanceToShowItems.mapBalances(),
)
} else {
InnerYieldBalanceState.Empty
}
}
private fun List<BalanceItem>.mapBalances() = asSequence()
.filterNot { it.amount.isZero() || it.type == BalanceType.REWARDS }
.mapNotNull(balanceItemConverter::convert)
.sortedByDescending { it.cryptoAmount }
.sortedBy { it.type.order }
.toPersistentList()
private fun getRewardBlockType(): Pair<RewardBlockType, Boolean> {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val blockchainId = cryptoCurrencyStatus.currency.network.id.value
val yieldBalance = cryptoCurrencyStatus.value.yieldBalance as? YieldBalance.Data
val rewards = yieldBalance?.balance?.items
?.filter { it.type == BalanceType.REWARDS && !it.amount.isZero() }
val isActionable = rewards?.any { it.pendingActions.isNotEmpty() } == true
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
}
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.features.staking.impl.presentation.state.events
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.staking.impl.R
@Immutable
internal sealed class StakingAlertUM : AlertUM {
data class GenericError(
override val onConfirmClick: () -> Unit,
) : StakingAlertUM() {
override val title: TextReference = resourceReference(R.string.common_error)
override val message: TextReference = resourceReference(R.string.common_unknown_error)
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_support)
}
data class StakingError(
val code: String,
override val onConfirmClick: () -> Unit,
) : StakingAlertUM() {
override val title: TextReference = resourceReference(R.string.common_error)
override val message: TextReference = resourceReference(R.string.generic_error_code, wrappedList(code))
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_support)
}
data object NoAvailableValidators : StakingAlertUM() {
override val title = resourceReference(R.string.common_error)
override val message = resourceReference(R.string.staking_no_validators_error_message)
override val confirmButtonText = resourceReference(R.string.common_ok)
override val onConfirmClick = null
}
data class FeeIncreased(
override val onConfirmClick: () -> Unit,
) : StakingAlertUM() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_notification_high_fee_title)
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
}
data object ValidatorsUnavailable : StakingAlertUM() {
override val onConfirmClick: (() -> Unit)? = null
override val title: TextReference = resourceReference(id = R.string.staking_error_no_validators_title)
override val message: TextReference = resourceReference(id = R.string.staking_error_no_validators_message)
override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.staking.impl.presentation.state.events
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class StakingEvent {
data class ShowSnackBar(val text: TextReference) : StakingEvent()
data class ShowAlert(val alert: AlertUM) : StakingEvent()
}

View file

@ -0,0 +1,49 @@
package com.tangem.features.staking.impl.presentation.state.events
import com.tangem.common.ui.alerts.TransactionErrorAlertConverter
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.features.staking.impl.presentation.state.StakingStateController
internal class StakingEventFactory(
private val stateController: StakingStateController,
private val popBackStack: () -> Unit,
private val onFailedTxEmailClick: (String) -> Unit,
) {
fun createGenericErrorAlert(error: String) {
val alert = StakingEvent.ShowAlert(
StakingAlertUM.GenericError(
onConfirmClick = { onFailedTxEmailClick(error) },
),
)
stateController.updateEvent(alert)
}
fun createSendTransactionErrorAlert(error: SendTransactionError?) {
val alert = error?.let {
TransactionErrorAlertConverter(
popBackStack = popBackStack,
onFailedTxEmailClick = onFailedTxEmailClick,
).convert(error)
}?.let {
StakingEvent.ShowAlert(it)
}
stateController.updateEvent(alert)
}
fun createStakingErrorAlert(error: StakingError) {
val alert = StakingEvent.ShowAlert(
StakingAlertUM.StakingError(
code = error.toString(),
onConfirmClick = { onFailedTxEmailClick(error.toString()) },
),
)
stateController.updateEvent(alert)
}
fun createStakingValidatorsUnavailableAlert() {
val alert = StakingEvent.ShowAlert(alert = StakingAlertUM.ValidatorsUnavailable)
stateController.updateEvent(alert)
}
}

View file

@ -0,0 +1,139 @@
package com.tangem.features.staking.impl.presentation.state.helpers
import com.tangem.domain.staking.FetchStakingYieldBalanceUseCase
import com.tangem.domain.staking.FetchActionsUseCase
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus
import com.tangem.domain.tokens.FetchPendingTransactionsUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.utils.coroutines.DelayedWork
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.*
@Suppress("LongParameterList")
internal class StakingBalanceUpdater @AssistedInject constructor(
private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase,
private val updateDelayedNetworkStatusUseCase: UpdateDelayedNetworkStatusUseCase,
private val stakingYieldBalanceUseCase: FetchStakingYieldBalanceUseCase,
private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase,
private val fetchActionsUseCase: FetchActionsUseCase,
@DelayedWork private val coroutineScope: CoroutineScope,
@Assisted private val userWallet: UserWallet,
@Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus,
@Assisted private val yield: Yield,
) {
fun fullUpdate() {
coroutineScope.launch {
listOf(
// we should update network to find pending tx after 1 sec
async {
fetchPendingTransactionsUseCase(
userWalletId = userWallet.walletId,
networks = setOf(cryptoCurrencyStatus.currency.network),
)
},
// we should update tx history and network for new balances
async {
updateStakeBalance()
},
async {
updateTxHistory()
},
async {
updateNetworkStatuses()
},
async {
updateProcessingActions()
},
).awaitAll()
}
}
suspend fun partialUpdate() {
coroutineScope {
listOf(
async {
updateNetworkStatuses(delay = 0)
},
async {
updateProcessingActions()
},
).awaitAll()
}
}
suspend fun initialUpdate() {
coroutineScope {
listOf(
async {
updateStakeBalance()
},
async {
updateProcessingActions()
},
).awaitAll()
}
}
private suspend fun updateNetworkStatuses(delay: Long = BALANCE_UPDATE_DELAY) {
updateDelayedNetworkStatusUseCase(
userWalletId = userWallet.walletId,
network = cryptoCurrencyStatus.currency.network,
delayMillis = delay,
refresh = true,
)
}
private suspend fun updateStakeBalance() {
stakingYieldBalanceUseCase(
userWalletId = userWallet.walletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
refresh = true,
)
}
private suspend fun updateTxHistory() {
delay(BALANCE_UPDATE_DELAY)
val txHistoryItemsCountEither = getTxHistoryItemsCountUseCase(
userWalletId = userWallet.walletId,
currency = cryptoCurrencyStatus.currency,
)
txHistoryItemsCountEither.onRight {
getTxHistoryItemsUseCase(
userWalletId = userWallet.walletId,
currency = cryptoCurrencyStatus.currency,
refresh = true,
)
}
}
private suspend fun updateProcessingActions() {
fetchActionsUseCase(
userWalletId = userWallet.walletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
networkType = yield.token.network,
stakingActionStatus = StakingActionStatus.PROCESSING,
)
}
@AssistedFactory
interface Factory {
fun create(
cryptoCurrencyStatus: CryptoCurrencyStatus,
userWallet: UserWallet,
yield: Yield,
): StakingBalanceUpdater
}
private companion object {
const val BALANCE_UPDATE_DELAY = 11_000L
}
}

View file

@ -0,0 +1,225 @@
package com.tangem.features.staking.impl.presentation.state.helpers
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.extensions.isZero
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.domain.staking.EstimateGasUseCase
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.transaction.ActionParams
import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.staking.getCurrentToken
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.staking.impl.presentation.state.StakingStateController
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions
import com.tangem.utils.extensions.orZero
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class StakingFeeTransactionLoader @AssistedInject constructor(
private val stateController: StakingStateController,
private val getFeeUseCase: GetFeeUseCase,
private val estimateGasUseCase: EstimateGasUseCase,
@Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus,
@Assisted private val userWallet: UserWallet,
@Assisted private val yield: Yield,
) {
suspend fun getFee(
onStakingFee: (Fee) -> Unit,
onStakingFeeError: (StakingError) -> Unit,
onApprovalFee: (TransactionFee) -> Unit,
onFeeError: (GetFeeError) -> Unit,
) {
val state = stateController.value
val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data
?: error("Illegal state")
val validatorState = state.validatorState as? StakingStates.ValidatorState.Data
?: error("No validator provided")
val amount = (state.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value
?: error("No amount provided")
val pendingAction = confirmationState.pendingAction
val pendingActions = confirmationState.pendingActions
val validatorAddress = validatorState.chosenValidator.address
val isEnter = state.actionType == StakingActionCommonType.Enter
val isApprovalNeeded = confirmationState.isApprovalNeeded
val isAllowanceNotEnough = confirmationState.allowance < amount
if (isEnter && isApprovalNeeded && isAllowanceNotEnough) {
getApproveFee(
amount = amount,
validatorAddress = validatorAddress,
onApprovalFee = onApprovalFee,
onApprovalFeeError = onFeeError,
)
} else {
estimateGas(
pendingAction = pendingAction,
pendingActions = pendingActions,
amount = amount,
validatorAddress = validatorAddress,
onStakingFeeError = onStakingFeeError,
onStakingFee = onStakingFee,
)
}
}
private suspend fun estimateGas(
pendingAction: PendingAction?,
pendingActions: ImmutableList<PendingAction>?,
amount: BigDecimal,
validatorAddress: String,
onStakingFeeError: (StakingError) -> Unit,
onStakingFee: (Fee) -> Unit,
) {
val sourceAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
?: error("No available address")
val gasEstimate = if (isCompositePendingActions(
networkId = cryptoCurrencyStatus.currency.network.id.value,
pendingActions = pendingActions,
)
) {
val result = coroutineScope {
pendingActions?.map { action ->
async {
// Simultaneous or quick api calls can sometimes return ZERO fee
estimateFeeRetry {
estimateFee(
amount = amount,
sourceAddress = sourceAddress,
validatorAddress = validatorAddress,
action = action,
)
}.getOrElse {
onStakingFeeError(it)
null
}
}
}?.awaitAll()?.filterNotNull()
}
if (result.isNullOrEmpty()) {
onStakingFeeError(StakingError.DomainError("Error estimating fee"))
return
}
val totalAmount = result.sumOf { it.amount }
val totalGasLimit = result.sumOf { it.gasLimit?.toBigDecimalOrNull().orZero() }
StakingGasEstimate(
amount = totalAmount,
token = result.first().token,
gasLimit = totalGasLimit.toPlainString().orEmpty(),
)
} else {
estimateFee(
amount = amount,
sourceAddress = sourceAddress,
validatorAddress = validatorAddress,
action = pendingAction,
).getOrElse {
onStakingFeeError(it)
return
}
}
onStakingFee(
Fee.Common(
Amount(
currencySymbol = gasEstimate.token.symbol,
value = gasEstimate.amount,
decimals = gasEstimate.token.decimals,
),
),
)
}
private suspend fun estimateFee(
amount: BigDecimal,
sourceAddress: String,
validatorAddress: String,
action: PendingAction?,
) = estimateGasUseCase(
userWalletId = userWallet.walletId,
network = cryptoCurrencyStatus.currency.network,
params = ActionParams(
actionCommonType = stateController.value.actionType,
integrationId = yield.id,
amount = amount,
address = sourceAddress,
validatorAddress = validatorAddress,
token = yield.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId),
passthrough = action?.passthrough,
type = action?.type,
),
)
private suspend fun getApproveFee(
amount: BigDecimal,
validatorAddress: String,
onApprovalFee: (TransactionFee) -> Unit,
onApprovalFeeError: (GetFeeError) -> Unit,
) {
getFeeUseCase(
amount = amount,
destination = validatorAddress,
userWallet = userWallet,
cryptoCurrency = cryptoCurrencyStatus.currency,
).fold(
ifRight = { fee ->
onApprovalFee(fee)
},
ifLeft = { error ->
onApprovalFeeError(error)
},
)
}
private suspend fun estimateFeeRetry(
times: Int = 3,
delay: Long = 1000,
block: suspend () -> Either<StakingError, StakingGasEstimate>,
): Either<StakingError, StakingGasEstimate> {
repeat(times - 1) {
val feeResult = block()
feeResult.fold(
ifLeft = { return feeResult },
ifRight = {
if (!it.amount.isZero()) return feeResult
},
)
delay(delay)
}
return block()
}
@AssistedFactory
interface Factory {
fun create(
cryptoCurrencyStatus: CryptoCurrencyStatus,
userWallet: UserWallet,
yield: Yield,
): StakingFeeTransactionLoader
}
}

View file

@ -0,0 +1,308 @@
package com.tangem.features.staking.impl.presentation.state.helpers
import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionSender
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.domain.staking.*
import com.tangem.domain.staking.model.SubmitHashData
import com.tangem.domain.staking.model.stakekit.NetworkType
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.transaction.ActionParams
import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction
import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionStatus
import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.staking.getCurrentToken
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.staking.impl.presentation.state.FeeState
import com.tangem.features.staking.impl.presentation.state.StakingStateController
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount
import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions
import com.tangem.utils.extensions.orZero
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import timber.log.Timber
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class StakingTransactionSender @AssistedInject constructor(
private val stateController: StakingStateController,
private val stakingBalanceUpdater: StakingBalanceUpdater.Factory,
private val getStakingTransactionsUseCase: GetStakingTransactionsUseCase,
private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val submitHashUseCase: SubmitHashUseCase,
private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase,
@Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus,
@Assisted private val userWallet: UserWallet,
@Assisted private val yield: Yield,
@Assisted private val isAmountSubtractAvailable: Boolean,
) {
private val balanceUpdater: StakingBalanceUpdater
get() = stakingBalanceUpdater.create(cryptoCurrencyStatus, userWallet, yield)
suspend fun constructAndSendTransactions(
onConstructSuccess: (List<StakingTransaction>) -> Unit,
onConstructError: (StakingError) -> Unit,
onSendSuccess: (String) -> Unit,
onSendError: (SendTransactionError?) -> Unit,
onFeeIncreased: (Fee) -> Unit,
) {
val state = stateController.value
val confirmationState = state.confirmationState as? StakingStates.ConfirmationState.Data
?: error("No confirmation state")
val fee = (confirmationState.feeState as? FeeState.Content)?.fee
?: error("No fee provided")
val amountState = state.amountState as? AmountState.Data ?: error("No amount state")
val stakingTransactions = getStakingTransactions(
state = state,
confirmationState = confirmationState,
onConstructError = onConstructError,
)
val fullTransactionsData = getConstructedTransactions(
stakingTransactions = stakingTransactions,
fee = fee,
amount = amountState.amountTextField.cryptoAmount.value.orZero(),
onConstructError = onConstructError,
)
if (fullTransactionsData.isNullOrEmpty()) {
onConstructError(StakingError.DomainError("fullTransactionsData is null or empty"))
return
}
val totalFee = fullTransactionsData.sumOf { it.stakeKitTransaction.gasEstimate?.amount.orZero() }
if (fee.amount.value.orZero() >= totalFee) {
onConstructSuccess(fullTransactionsData.map { it.stakeKitTransaction })
sendStakingTransaction(
fullTransactionsData = fullTransactionsData,
onSendSuccess = onSendSuccess,
onSendError = onSendError,
)
} else {
onFeeIncreased(
Fee.Common(
fee.amount.copy(value = totalFee),
),
)
}
}
private suspend fun getStakingTransactions(
state: StakingUiState,
confirmationState: StakingStates.ConfirmationState.Data,
onConstructError: (StakingError) -> Unit,
) = coroutineScope {
val isComposePendingActions = isCompositePendingActions(
cryptoCurrencyStatus.currency.network.id.value,
confirmationState.pendingActions,
)
if (isComposePendingActions) {
confirmationState.pendingActions?.map { action ->
async {
getStakingTransaction(
state = state,
action = action,
confirmationState = confirmationState,
onConstructError = onConstructError,
)
}
}?.awaitAll()?.flatten()
} else {
getStakingTransaction(
state = state,
confirmationState = confirmationState,
onConstructError = onConstructError,
)
}
}
private suspend fun getConstructedTransactions(
stakingTransactions: List<StakingTransaction>?,
fee: Fee,
amount: BigDecimal,
onConstructError: (StakingError) -> Unit,
) = coroutineScope {
stakingTransactions
?.filterNot {
it.type == StakingTransactionType.APPROVAL || it.status == StakingTransactionStatus.SKIPPED
}
?.map { transaction ->
async {
getConstructedStakingTransactionUseCase(
networkId = cryptoCurrencyStatus.currency.network.id.value,
fee = fee,
amount = amount.convertToSdkAmount(cryptoCurrencyStatus.currency),
transactionId = transaction.id,
).fold(
ifRight = { (constructedTransaction, transactionData) ->
FullTransactionData(
stakeKitTransaction = constructedTransaction,
tangemTransaction = transactionData,
)
},
ifLeft = {
onConstructError(it)
null
},
)
}
}
?.awaitAll()
?.filterNotNull()
}
private suspend fun getStakingTransaction(
state: StakingUiState,
confirmationState: StakingStates.ConfirmationState.Data,
action: PendingAction? = confirmationState.pendingAction,
onConstructError: (StakingError) -> Unit,
): List<StakingTransaction> {
val validatorState = state.validatorState as? StakingStates.ValidatorState.Data
?: error("No validator provided")
val fee = (confirmationState.feeState as? FeeState.Content)?.fee
?: error("No fee provided")
val defaultAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
?: error("No available address")
val amountState = state.amountState as? AmountState.Data
?: error("No amount provided")
val validatorAddress = validatorState.chosenValidator.address
val amount = getAmount(amountState, fee, confirmationState.reduceAmountBy)
return getStakingTransactionsUseCase(
userWalletId = userWallet.walletId,
network = cryptoCurrencyStatus.currency.network,
params = ActionParams(
actionCommonType = state.actionType,
integrationId = yield.id,
amount = amount,
address = defaultAddress,
validatorAddress = validatorAddress,
token = yield.getCurrentToken(cryptoCurrencyStatus.currency.id.rawCurrencyId),
passthrough = action?.passthrough,
type = action?.type,
),
).getOrElse {
onConstructError(it)
return emptyList()
}
}
private suspend fun sendStakingTransaction(
fullTransactionsData: List<FullTransactionData>,
onSendSuccess: (txUrl: String) -> Unit,
onSendError: (SendTransactionError?) -> Unit,
) {
if (fullTransactionsData.isEmpty()) return
val sortedTransactions = fullTransactionsData.sortedBy { it.stakeKitTransaction.stepIndex }
val firstTransaction = sortedTransactions.first()
val network = firstTransaction.stakeKitTransaction.network
val sendMode = if (network == NetworkType.SOLANA &&
firstTransaction.stakeKitTransaction.type == StakingTransactionType.SPLIT
) {
TransactionSender.MultipleTransactionSendMode.WAIT_AFTER_FIRST
} else {
TransactionSender.MultipleTransactionSendMode.DEFAULT
}
sendTransactionUseCase(
txsData = sortedTransactions.map { it.tangemTransaction },
userWallet = userWallet,
network = cryptoCurrencyStatus.currency.network,
sendMode = sendMode,
).fold(
ifLeft = { error ->
onSendError(error)
},
ifRight = { transactionHashes ->
submitHash(
transactions = sortedTransactions.map { it.stakeKitTransaction },
transactionHashes = transactionHashes,
)
val txUrl = getExplorerTransactionUrlUseCase(
txHash = transactionHashes.last(),
networkId = cryptoCurrencyStatus.currency.network.id,
).getOrElse { "" }
balanceUpdater.fullUpdate()
onSendSuccess(txUrl)
},
)
}
private suspend fun submitHash(transactions: List<StakingTransaction>, transactionHashes: List<String>) {
transactions
.zip(transactionHashes)
.forEach { (transaction, transactionHash) ->
submitHashUseCase(
SubmitHashData(
transactionId = transaction.id,
transactionHash = transactionHash,
),
)
.onLeft {
saveUnsubmittedHashUseCase.invoke(
transactionId = transaction.id,
transactionHash = transactionHash,
)
}.onRight {
Timber.d("Successful hash submission")
}
}
}
private fun getAmount(amountState: AmountState.Data, fee: Fee, reduceAmountBy: BigDecimal?): BigDecimal {
val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value")
val feeValue = fee.amount.value ?: error("No fee value")
val isEnterAction = stateController.value.actionType == StakingActionCommonType.Enter
return checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable && isEnterAction,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy.orZero(),
)
}
private data class FullTransactionData(
val stakeKitTransaction: StakingTransaction,
val tangemTransaction: TransactionData.Compiled,
)
@AssistedFactory
interface Factory {
fun create(
cryptoCurrencyStatus: CryptoCurrencyStatus,
userWallet: UserWallet,
yield: Yield,
isAmountSubtractAvailable: Boolean,
): StakingTransactionSender
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.features.staking.impl.presentation.state.previewdata
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType.Coin
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.*
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
internal object ConfirmationStatePreviewData {
private val fee = Fee.Common(
amount = Amount(
currencySymbol = "MATIC",
value = BigDecimal(0.159806),
decimals = 18,
type = Coin,
),
)
val assentStakingState = StakingStates.ConfirmationState.Data(
isPrimaryButtonEnabled = true,
innerState = InnerConfirmationStakingState.ASSENT,
feeState = FeeState.Content(
fee = fee,
rate = BigDecimal.ONE,
appCurrency = AppCurrency.Default,
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
),
footerText = stringReference("You stake \$715.11 and will be receiving ~\$35 monthly"),
notifications = persistentListOf(
StakingNotification.Info.EarnRewards(
subtitleText = resourceReference(
id = R.string.staking_notification_earn_rewards_text_period_day,
formatArgs = wrappedList("Solana"),
),
),
),
transactionDoneState = TransactionDoneState.Empty,
pendingAction = null,
isApprovalNeeded = false,
allowance = BigDecimal.ZERO,
reduceAmountBy = null,
pendingActions = null,
isAmountEditable = true,
)
}

View file

@ -0,0 +1,104 @@
package com.tangem.features.staking.impl.presentation.state.previewdata
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
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 kotlinx.collections.immutable.persistentListOf
import org.joda.time.DateTime
internal object InitialStakingStatePreview {
val defaultState = StakingStates.InitialInfoState.Data(
isPrimaryButtonEnabled = true,
showBanner = true,
aprRange = stringReference("2.54-5.12%"),
infoItems = persistentListOf(
RoundedListWithDividersItemData(
id = R.string.staking_details_available,
startText = TextReference.Res(R.string.staking_details_available),
endText = TextReference.Str("15 SOL"),
isEndTextHideable = true,
),
RoundedListWithDividersItemData(
id = R.string.staking_details_annual_percentage_rate,
startText = TextReference.Res(R.string.staking_details_annual_percentage_rate),
endText = TextReference.Str("2.54-5.12%"),
),
RoundedListWithDividersItemData(
id = R.string.staking_details_unbonding_period,
startText = TextReference.Res(R.string.staking_details_unbonding_period),
endText = TextReference.Str("3d"),
),
RoundedListWithDividersItemData(
id = R.string.staking_details_minimum_requirement,
startText = TextReference.Res(R.string.staking_details_minimum_requirement),
endText = TextReference.Str("12 SOL"),
),
RoundedListWithDividersItemData(
id = R.string.staking_details_reward_claiming,
startText = TextReference.Res(R.string.staking_details_reward_claiming),
endText = TextReference.Str("Auto"),
),
RoundedListWithDividersItemData(
id = R.string.staking_details_warmup_period,
startText = TextReference.Res(R.string.staking_details_warmup_period),
endText = TextReference.Str("Days"),
),
RoundedListWithDividersItemData(
id = R.string.staking_details_reward_schedule,
startText = TextReference.Res(R.string.staking_details_reward_schedule),
endText = TextReference.Str("Block"),
),
),
onInfoClick = {},
yieldBalance = InnerYieldBalanceState.Empty,
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}),
)
val stateWithYield = defaultState.copy(
yieldBalance = InnerYieldBalanceState.Data(
rewardsFiat = "100 $",
rewardsCrypto = "100 SOL",
rewardBlockType = RewardBlockType.RewardUnavailable,
isActionable = true,
balances = persistentListOf(
BalanceState(
groupId = "groupId",
title = stringReference("Binance"),
cryptoValue = "100",
formattedCryptoAmount = stringReference("100 SOL"),
cryptoAmount = "100".toBigDecimal(),
fiatAmount = null,
formattedFiatAmount = stringReference("100 $"),
rawCurrencyId = null,
validator = Yield.Validator(
address = "address",
status = Yield.Validator.ValidatorStatus.ACTIVE,
name = "Binance",
image = null,
website = null,
apr = "5".toBigDecimal(),
commission = null,
stakedBalance = null,
votingPower = null,
preferred = false,
isStrategicPartner = false,
),
pendingActions = persistentListOf(),
isClickable = true,
type = BalanceType.STAKED,
subtitle = null,
isPending = false,
date = DateTime.now(),
),
),
),
)
}

View file

@ -0,0 +1,60 @@
package com.tangem.features.staking.impl.presentation.state.previewdata
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.Yield.Validator.ValidatorStatus
import com.tangem.features.staking.impl.presentation.state.StakingStates
import java.math.BigDecimal
internal object ValidatorStatePreviewData {
private val validatorList = listOf(
Yield.Validator(
address = "0xa6e768fef2d1af36c0cfdb276422e7881a83e951",
status = ValidatorStatus.ACTIVE,
name = "Luganodes",
image = "https://assets.stakek.it/validators/luganodes.png",
apr = BigDecimal("0.054823398040640445"),
commission = 0.1,
stakedBalance = "355544384.45009977",
website = "https://luganodes.com/",
votingPower = 0.09778360195377911,
preferred = true,
isStrategicPartner = false,
),
Yield.Validator(
address = "0x35b1ca0f398905cf752e6fe122b51c88022fca32",
status = ValidatorStatus.ACTIVE,
name = "InfStones",
image = "https://assets.stakek.it/validators/infstones.png",
apr = BigDecimal("0.057786472172836965"),
commission = 0.05,
stakedBalance = "12495684.05643019",
website = "https://infstones.com/",
votingPower = 0.0034366257754399774,
preferred = true,
isStrategicPartner = true,
),
Yield.Validator(
address = "0xd14a87025109013b0a2354a775cb335f926af65a",
status = ValidatorStatus.ACTIVE,
name = "Kiln",
image = "https://assets.stakek.it/validators/kiln.png",
apr = BigDecimal("0.057786472172836965"),
commission = 0.05,
stakedBalance = "85400369.96393165",
website = "https://infstones.com/",
votingPower = 0.023487238579718264,
preferred = true,
isStrategicPartner = false,
),
)
val validatorState = StakingStates.ValidatorState.Data(
availableValidators = validatorList,
chosenValidator = validatorList.first(),
isPrimaryButtonEnabled = true,
activeValidator = null,
isClickable = true,
isVisibleOnConfirmation = true,
)
}

View file

@ -0,0 +1,77 @@
package com.tangem.features.staking.impl.presentation.state.stub
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import java.math.BigDecimal
@Suppress("TooManyFunctions")
internal object StakingClickIntentsStub : StakingClickIntents {
override fun onBackClick() {}
override fun onNextClick(balanceState: BalanceState?) {}
override fun onActionClick() {}
override fun onPrevClick() {}
override fun onRefreshSwipe(isRefreshing: Boolean) {}
override fun onInitialInfoBannerClick() {}
override fun onInfoClick(infoType: InfoType) {}
override fun onAmountEnterClick() {}
override fun onAmountValueChange(value: String) {}
override fun onAmountPasteTriggerDismiss() {}
override fun onMaxValueClick() {}
override fun onCurrencyChangeClick(isFiat: Boolean) {}
override fun onAmountNext() {}
override fun openValidators() {}
override fun onValidatorSelect(validator: Yield.Validator) {}
override fun openRewardsValidators() {}
override fun showApprovalBottomSheet() {}
override fun onApproveTypeChange(approveType: ApproveType) {}
override fun onApprovalClick() {}
override fun onExploreClick() {}
override fun onShareClick() {}
override fun onFailedTxEmailClick(errorMessage: String) {}
override fun onActiveStake(activeStake: BalanceState) {}
override fun getFee() {}
override fun onAmountReduceByClick(
reduceAmountBy: BigDecimal,
reduceAmountByDiff: BigDecimal,
notification: Class<out NotificationUM>,
) {
}
override fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class<out NotificationUM>) {}
override fun onNotificationCancel(notification: Class<out NotificationUM>) {}
override fun openTokenDetails(cryptoCurrency: CryptoCurrency) {}
override fun onActiveStakeAnalytic() {}
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.features.staking.impl.presentation.state.FeeState
import com.tangem.features.staking.impl.presentation.state.StakingNotification
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList
internal class AddStakingErrorTransformer(
private val error: StakingError? = null,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val confirmationState =
prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState
val notifications = buildList {
addAll(confirmationState.notifications)
error?.let { add(convertToNotification(it)) }
}.toPersistentList()
return prevState.copy(
confirmationState = confirmationState.copy(
notifications = notifications,
feeState = FeeState.Error,
),
)
}
private fun convertToNotification(error: StakingError): NotificationUM {
return StakingNotification.Error.Common(
subtitle = stringReference(error.toString()),
)
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal object DismissBottomSheetStateTransformer : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(bottomSheetConfig = null)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal class HideBalanceStateTransformer(
private val isBalanceHidden: Boolean,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(isBalanceHidden = isBalanceHidden)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.common.ui.amountScreen.converters.AmountStateConverter
import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
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.StakingUiState
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.transformer.Transformer
internal class SetAmountDataTransformer(
private val clickIntents: StakingClickIntents,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val userWalletProvider: Provider<UserWallet>,
private val appCurrencyProvider: Provider<AppCurrency>,
) : Transformer<StakingUiState> {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
override fun transform(prevState: StakingUiState): StakingUiState {
val title = if (prevState.actionType is StakingActionCommonType.Exit) {
resourceReference(R.string.staking_staked_amount)
} else {
stringReference(userWalletProvider().name)
}
val cryptoBalanceValue = cryptoCurrencyStatusProvider().value
val (amount, fiatAmount) = if (prevState.actionType != StakingActionCommonType.Enter) {
prevState.balanceState?.cryptoAmount to prevState.balanceState?.fiatAmount
} else {
cryptoBalanceValue.amount to cryptoBalanceValue.fiatAmount
}
val maxEnterAmount = EnterAmountBoundary(
amount = amount,
fiatAmount = fiatAmount,
fiatRate = cryptoBalanceValue.fiatRate,
)
return prevState.copy(
amountState = AmountStateConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
appCurrencyProvider = appCurrencyProvider,
iconStateConverter = iconStateConverter,
maxEnterAmount = maxEnterAmount,
).convert(
AmountParameters(
title = title,
value = "",
),
),
)
}
}

View file

@ -0,0 +1,199 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.features.staking.impl.presentation.state.utils.getPendingActionTitle
import com.tangem.utils.extensions.orZero
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal class SetButtonsStateTransformer(
private val urlOpener: UrlOpener,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val buttonsState = if (prevState.isButtonsVisible()) {
NavigationButtonsState.Data(
primaryButton = getPrimaryButton(prevState),
prevButton = getPrevButton(prevState),
extraButtons = getExtraButtons(prevState),
txUrl = (confirmState?.transactionDoneState as? TransactionDoneState.Content)?.txUrl,
onTextClick = urlOpener::openUrl,
)
} else {
NavigationButtonsState.Empty
}
return prevState.copy(buttonsState = buttonsState)
}
private fun getPrimaryButton(prevState: StakingUiState): NavigationButton {
val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val innerConfirmState = confirmState?.innerState
val isConfirmation = prevState.currentStep == StakingStep.Confirmation
val isInProgress = innerConfirmState == InnerConfirmationStakingState.IN_PROGRESS
val isCompleted = innerConfirmState == InnerConfirmationStakingState.COMPLETED
val isIconVisible = isConfirmation && !isCompleted
return NavigationButton(
textReference = prevState.getButtonText(),
iconRes = R.drawable.ic_tangem_24,
isSecondary = false,
isIconVisible = isIconVisible,
showProgress = isInProgress,
isEnabled = prevState.isButtonEnabled(),
onClick = { prevState.onPrimaryClick() },
)
}
private fun getPrevButton(prevState: StakingUiState): NavigationButton? {
return NavigationButton(
textReference = TextReference.EMPTY,
iconRes = R.drawable.ic_back_24,
isSecondary = true,
isIconVisible = true,
showProgress = false,
isEnabled = true,
onClick = prevState.clickIntents::onPrevClick,
).takeIf { prevState.currentStep.isPrevButtonVisible() }
}
private fun getExtraButtons(prevState: StakingUiState): ImmutableList<NavigationButton> {
return persistentListOf(
NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
isSecondary = true,
isIconVisible = true,
showProgress = false,
isEnabled = true,
onClick = prevState.clickIntents::onExploreClick,
),
NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
isSecondary = true,
isIconVisible = true,
showProgress = false,
isEnabled = true,
onClick = prevState.clickIntents::onShareClick,
),
)
}
private fun StakingUiState.isButtonsVisible(): Boolean = when (currentStep) {
StakingStep.RewardsValidators -> false
else -> true
}
private fun StakingUiState.getButtonText(): TextReference {
return when (currentStep) {
StakingStep.InitialInfo -> {
val initialState = initialInfoState as? StakingStates.InitialInfoState.Data
if (initialState?.yieldBalance is InnerYieldBalanceState.Data) {
resourceReference(R.string.staking_stake_more)
} else {
resourceReference(R.string.common_stake)
}
}
StakingStep.Confirmation -> getConfirmationButtonText()
StakingStep.Validators -> resourceReference(R.string.common_continue)
StakingStep.Amount,
StakingStep.RestakeValidator,
StakingStep.RewardsValidators,
-> resourceReference(R.string.common_next)
}
}
private fun StakingUiState.getConfirmationButtonText(): TextReference {
val confirmationState = confirmationState as? StakingStates.ConfirmationState.Data
val amountState = amountState as? AmountState.Data
return if (confirmationState != null && amountState != null) {
if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) {
resourceReference(R.string.common_close)
} else {
when (actionType) {
StakingActionCommonType.Enter -> {
val amount = amountState.amountTextField.cryptoAmount.value.orZero()
if (confirmationState.isApprovalNeeded && confirmationState.allowance < amount) {
resourceReference(R.string.give_permission_title)
} else {
resourceReference(R.string.common_stake)
}
}
is StakingActionCommonType.Exit -> resourceReference(R.string.common_unstake)
is StakingActionCommonType.Pending -> confirmationState.pendingAction?.type.getPendingActionTitle()
}
}
} else {
resourceReference(R.string.common_close)
}
}
private fun StakingUiState.onPrimaryClick() {
when (currentStep) {
StakingStep.InitialInfo -> clickIntents.onNextClick()
StakingStep.Validators,
StakingStep.RestakeValidator,
-> clickIntents.onNextClick()
StakingStep.Amount -> clickIntents.onAmountEnterClick()
StakingStep.Confirmation -> onConfirmationClick()
StakingStep.RewardsValidators -> Unit
}
}
private fun StakingUiState.onConfirmationClick() {
val confirmationState = confirmationState as? StakingStates.ConfirmationState.Data
val amountState = amountState as? AmountState.Data
if (confirmationState != null && amountState != null) {
if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) {
clickIntents.onNextClick()
} else {
val amount = amountState.amountTextField.cryptoAmount.value.orZero()
val isEnterAction = actionType == StakingActionCommonType.Enter
if (isEnterAction && confirmationState.isApprovalNeeded && confirmationState.allowance < amount) {
clickIntents.showApprovalBottomSheet()
} else {
clickIntents.onActionClick()
}
}
} else {
clickIntents.onBackClick()
}
}
private fun StakingStep.isPrevButtonVisible(): Boolean = when (this) {
StakingStep.InitialInfo,
StakingStep.RewardsValidators,
StakingStep.RestakeValidator,
StakingStep.Confirmation,
StakingStep.Validators,
-> false
StakingStep.Amount,
-> true
}
private fun StakingUiState.isButtonEnabled(): Boolean {
return when (currentStep) {
StakingStep.InitialInfo -> initialInfoState.isPrimaryButtonEnabled
StakingStep.Amount -> amountState.isPrimaryButtonEnabled
StakingStep.Confirmation -> confirmationState.isPrimaryButtonEnabled
StakingStep.RewardsValidators -> rewardsValidatorsState.isPrimaryButtonEnabled
StakingStep.RestakeValidator,
StakingStep.Validators,
-> true
}
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.FeeState
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.Provider
import com.tangem.utils.transformer.Transformer
internal class SetConfirmationStateAssentTransformer(
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
private val fee: Fee,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
confirmationState = prevState.confirmationState.copyWrapped(fee),
)
}
private fun StakingStates.ConfirmationState.copyWrapped(fee: Fee): StakingStates.ConfirmationState {
if (this is StakingStates.ConfirmationState.Data) {
val isFeeConvertibleToFiat = feeCryptoCurrencyStatus?.currency?.network?.hasFiatFeeRate == true
return copy(
feeState = FeeState.Content(
fee = fee,
rate = feeCryptoCurrencyStatus?.value?.fiatRate,
isFeeConvertibleToFiat = isFeeConvertibleToFiat,
appCurrency = appCurrencyProvider(),
isFeeApproximate = false,
),
isPrimaryButtonEnabled = true,
)
} else {
return this
}
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
internal class SetConfirmationStateCompletedTransformer(
private val txUrl: String,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
confirmationState = prevState.confirmationState.copyWrapped(),
)
}
private fun StakingStates.ConfirmationState.copyWrapped(): StakingStates.ConfirmationState {
return if (this is StakingStates.ConfirmationState.Data) {
copy(
isPrimaryButtonEnabled = true,
innerState = InnerConfirmationStakingState.COMPLETED,
footerText = TextReference.EMPTY,
notifications = persistentListOf(),
transactionDoneState = TransactionDoneState.Content(
timestamp = System.currentTimeMillis(),
txUrl = txUrl,
),
)
} else {
this
}
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal object SetConfirmationStateEmptyTransformer : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
actionType = StakingActionCommonType.Enter,
validatorState = StakingStates.ValidatorState.Empty(),
confirmationState = StakingStates.ConfirmationState.Empty(),
balanceState = null,
)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal class SetConfirmationStateInProgressTransformer : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
confirmationState = prevState.confirmationState.copyWrapped(),
)
}
private fun StakingStates.ConfirmationState.copyWrapped(): StakingStates.ConfirmationState {
return if (this is StakingStates.ConfirmationState.Data) {
copy(
isPrimaryButtonEnabled = false,
innerState = InnerConfirmationStakingState.IN_PROGRESS,
)
} else {
this
}
}
}

View file

@ -0,0 +1,97 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.staking.model.StakingApproval
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions
import com.tangem.features.staking.impl.presentation.state.utils.isTronStakedBalance
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.extensions.isPositive
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class SetConfirmationStateInitTransformer(
private val isEnter: Boolean,
private val isExplicitExit: Boolean,
private val balanceState: BalanceState?,
private val stakingApproval: StakingApproval,
private val stakingAllowance: BigDecimal,
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val yieldArgs: Yield.Args,
private val pendingActions: ImmutableList<PendingAction>? = null,
private val pendingAction: PendingAction? = pendingActions?.firstOrNull(),
) : Transformer<StakingUiState> {
private val networkId
get() = cryptoCurrencyStatus.currency.network.id.value
private val isComposePendingActions
get() = isCompositePendingActions(networkId, pendingActions)
private val isTronStakedBalance
get() = isTronStakedBalance(networkId, pendingAction)
private val isImplicitExit: Boolean
get() = pendingAction == null && pendingActions?.isEmpty() == true || isTronStakedBalance
override fun transform(prevState: StakingUiState): StakingUiState {
val actionType = when {
isEnter -> StakingActionCommonType.Enter
isImplicitExit || isExplicitExit -> StakingActionCommonType.Exit(isPartialUnstakeDisabled(prevState))
else -> when (pendingAction?.type) {
StakingActionType.STAKE -> StakingActionCommonType.Enter
StakingActionType.UNSTAKE -> StakingActionCommonType.Exit(isPartialUnstakeDisabled(prevState))
StakingActionType.CLAIM_REWARDS,
StakingActionType.RESTAKE_REWARDS,
-> StakingActionCommonType.Pending.Rewards
StakingActionType.VOTE_LOCKED,
StakingActionType.RESTAKE,
-> StakingActionCommonType.Pending.Restake
else -> StakingActionCommonType.Pending.Other
}
}
return prevState.copy(
actionType = actionType,
balanceState = balanceState,
confirmationState = StakingStates.ConfirmationState.Data(
isPrimaryButtonEnabled = false,
innerState = InnerConfirmationStakingState.ASSENT,
feeState = FeeState.Loading,
notifications = persistentListOf(),
footerText = TextReference.EMPTY,
transactionDoneState = TransactionDoneState.Empty,
isApprovalNeeded = stakingApproval is StakingApproval.Needed,
allowance = stakingAllowance,
isAmountEditable = actionType == StakingActionCommonType.Enter ||
actionType is StakingActionCommonType.Exit &&
!actionType.partiallyUnstakeDisabled,
reduceAmountBy = null,
pendingAction = pendingAction,
pendingActions = pendingActions.takeIf { isComposePendingActions },
),
)
}
private fun isPartialUnstakeDisabled(state: StakingUiState): Boolean {
val isSolana = BlockchainUtils.isSolana(state.cryptoCurrencyBlockchainId)
val isValidatorPreferred = balanceState?.validator?.preferred == true
if (isSolana && !isValidatorPreferred) {
return true
}
val exitArgs = yieldArgs.exit ?: return false
val exitAmount = exitArgs.args[Yield.Args.ArgType.AMOUNT] ?: return false
val min = exitAmount.minimum ?: return false
val max = exitAmount.maximum ?: return false
return !min.isPositive() && !max.isPositive()
}
}

View file

@ -0,0 +1,60 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
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.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.features.staking.impl.presentation.state.utils.getRewardScheduleText
import com.tangem.utils.transformer.Transformer
internal class SetConfirmationStateLoadingTransformer(
private val yield: Yield,
private val appCurrency: AppCurrency,
private val cryptoCurrency: CryptoCurrency,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val possibleConfirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
return prevState.copy(
confirmationState = possibleConfirmationState?.copy(
isPrimaryButtonEnabled = false,
feeState = FeeState.Loading,
footerText = getFooter(prevState),
) ?: prevState.confirmationState,
)
}
private fun getFooter(state: StakingUiState): TextReference {
val amountState = state.amountState as? AmountState.Data
val isEnterAction = state.actionType == StakingActionCommonType.Enter
val amountDecimal = amountState?.amountTextField?.fiatAmount?.value
val amountValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = amountDecimal,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
val rewardSchedule = getRewardScheduleText(
rewardSchedule = yield.metadata.rewardSchedule,
networkId = cryptoCurrency.network.id.value,
decapitalize = true,
)
return if (isEnterAction && amountDecimal != null && rewardSchedule != null) {
resourceReference(
id = R.string.staking_summary_description_text,
formatArgs = wrappedList(amountValue, rewardSchedule),
)
} else {
TextReference.EMPTY
}
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal object SetConfirmationStateResetAssentTransformer : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val confirmationState = prevState.confirmationState
return prevState.copy(
confirmationState = if (confirmationState is StakingStates.ConfirmationState.Data) {
confirmationState.copy(
isPrimaryButtonEnabled = true,
innerState = InnerConfirmationStakingState.ASSENT,
)
} else {
confirmationState
},
)
}
}

View file

@ -0,0 +1,251 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.common.extensions.remove
import com.tangem.common.ui.amountScreen.converters.AmountStateConverter
import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.list.RoundedListWithDividersItemData
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.stakekit.BalanceItem
import com.tangem.domain.staking.model.stakekit.Yield
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.*
import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType
import com.tangem.features.staking.impl.presentation.state.converters.RewardsValidatorStateConverter
import com.tangem.features.staking.impl.presentation.state.converters.YieldBalancesConverter
import com.tangem.features.staking.impl.presentation.state.utils.getRewardScheduleText
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.lib.crypto.BlockchainUtils.isPolkadot
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class SetInitialDataStateTransformer(
private val clickIntents: StakingClickIntents,
private val yield: Yield,
private val isAnyTokenStaked: Boolean,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val userWalletProvider: Provider<UserWallet>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val balancesToShowProvider: Provider<List<BalanceItem>>,
) : Transformer<StakingUiState> {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val rewardsValidatorStateConverter by lazy(LazyThreadSafetyMode.NONE) {
RewardsValidatorStateConverter(cryptoCurrencyStatusProvider, appCurrencyProvider, yield)
}
private val yieldBalancesConverter by lazy(LazyThreadSafetyMode.NONE) {
YieldBalancesConverter(
cryptoCurrencyStatusProvider,
appCurrencyProvider,
balancesToShowProvider,
yield,
)
}
override fun transform(prevState: StakingUiState): StakingUiState {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
return prevState.copy(
title = TextReference.EMPTY,
cryptoCurrencyName = cryptoCurrency.name,
cryptoCurrencySymbol = cryptoCurrency.symbol,
cryptoCurrencyBlockchainId = cryptoCurrency.network.id.value,
clickIntents = clickIntents,
currentStep = StakingStep.InitialInfo,
initialInfoState = createInitialInfoState(),
amountState = createInitialAmountState(),
confirmationState = StakingStates.ConfirmationState.Empty(),
rewardsValidatorsState = rewardsValidatorStateConverter.convert(Unit),
bottomSheetConfig = null,
)
}
private fun createInitialInfoState(): StakingStates.InitialInfoState.Data {
val yieldBalance = yieldBalancesConverter.convert(Unit)
return StakingStates.InitialInfoState.Data(
isPrimaryButtonEnabled = !cryptoCurrencyStatusProvider().value.amount.isNullOrZero(),
showBanner = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty,
aprRange = getAprRange(yield.preferredValidators),
infoItems = getInfoItems(),
onInfoClick = clickIntents::onInfoClick,
yieldBalance = yieldBalance,
pullToRefreshConfig = PullToRefreshConfig(
onRefresh = { clickIntents.onRefreshSwipe(it.value) },
isRefreshing = false,
),
)
}
private fun getInfoItems(): PersistentList<RoundedListWithDividersItemData> {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
return listOfNotNull(
createAnnualPercentageRateItem(),
createAvailableItem(cryptoCurrencyStatus),
createUnbondingPeriodItem(),
createMinimumRequirementItem(cryptoCurrencyStatus),
createRewardClaimingItem(),
createWarmupPeriodItem(),
createRewardScheduleItem(),
).toPersistentList()
}
private fun createAnnualPercentageRateItem(): RoundedListWithDividersItemData {
val validators = yield.preferredValidators
return RoundedListWithDividersItemData(
id = R.string.staking_details_annual_percentage_rate,
startText = TextReference.Res(R.string.staking_details_annual_percentage_rate),
endText = getAprRange(validators),
iconClick = { clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) },
isEndTextHighlighted = true,
)
}
private fun createAvailableItem(cryptoCurrencyStatus: CryptoCurrencyStatus): RoundedListWithDividersItemData {
return RoundedListWithDividersItemData(
id = R.string.staking_details_available,
startText = TextReference.Res(R.string.staking_details_available),
endText = TextReference.Str(
value = cryptoCurrencyStatus.value.amount.format { crypto(cryptoCurrencyStatus.currency) },
),
isEndTextHideable = true,
)
}
private fun createUnbondingPeriodItem(): RoundedListWithDividersItemData? {
val cooldownPeriodDays = yield.metadata.cooldownPeriod?.days ?: return null
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 = cooldownPeriodDays,
formatArgs = wrappedList(cooldownPeriodDays),
),
iconClick = { clickIntents.onInfoClick(InfoType.UNBONDING_PERIOD) },
)
}
private fun createMinimumRequirementItem(
cryptoCurrencyStatus: CryptoCurrencyStatus,
): RoundedListWithDividersItemData? {
val minimumCryptoAmount = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum ?: return null
if (!isPolkadot(cryptoCurrencyStatus.currency.network.id.value)) return null
val formattedAmount = minimumCryptoAmount.format { crypto(cryptoCurrencyStatus.currency) }
return RoundedListWithDividersItemData(
id = R.string.staking_details_minimum_requirement,
startText = TextReference.Res(R.string.staking_details_minimum_requirement),
endText = TextReference.Str(formattedAmount),
)
}
private fun createRewardClaimingItem(): RoundedListWithDividersItemData? {
val rewardClaiming = yield.metadata.rewardClaiming
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.Res(endTextId),
iconClick = { clickIntents.onInfoClick(InfoType.REWARD_CLAIMING) },
)
}
private fun createWarmupPeriodItem(): RoundedListWithDividersItemData? {
val warmupPeriodDays = yield.metadata.warmupPeriod.days
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 = warmupPeriodDays,
formatArgs = wrappedList(warmupPeriodDays),
),
iconClick = { clickIntents.onInfoClick(InfoType.WARMUP_PERIOD) },
)
}
private fun createRewardScheduleItem(): RoundedListWithDividersItemData? {
val endTextReference = getRewardScheduleText(
rewardSchedule = yield.metadata.rewardSchedule,
networkId = cryptoCurrencyStatusProvider().currency.network.id.value,
decapitalize = false,
) ?: return null
return RoundedListWithDividersItemData(
id = R.string.staking_details_reward_schedule,
startText = resourceReference(R.string.staking_details_reward_schedule),
endText = endTextReference,
iconClick = { clickIntents.onInfoClick(InfoType.REWARD_SCHEDULE) },
)
}
private fun createInitialAmountState(): AmountState {
val cryptoBalanceValue = cryptoCurrencyStatusProvider().value
val maxEnterAmount = EnterAmountBoundary(
amount = cryptoBalanceValue.amount,
fiatAmount = cryptoBalanceValue.fiatAmount,
fiatRate = cryptoBalanceValue.fiatRate,
)
return AmountStateConverter(
clickIntents = clickIntents,
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
appCurrencyProvider = appCurrencyProvider,
iconStateConverter = iconStateConverter,
maxEnterAmount = maxEnterAmount,
).convert(
AmountParameters(
title = stringReference(userWalletProvider().name),
value = "",
),
)
}
private fun getAprRange(validators: List<Yield.Validator>): TextReference {
val aprValues = validators
.filter { it.preferred }
.takeIf { it.isNotEmpty() }
?.mapNotNull { it.apr }
?: validators.mapNotNull { it.apr }
val minApr = aprValues.min()
val maxApr = aprValues.max()
val formattedMinApr = minApr.format { percent() }.remove("%")
val formattedMaxApr = maxApr.format { percent() }
if (maxApr - minApr < EQUALITY_THRESHOLD) {
return stringReference("$formattedMinApr%")
}
return resourceReference(R.string.common_range, wrappedList(formattedMinApr, formattedMaxApr))
}
private companion object {
val EQUALITY_THRESHOLD = BigDecimal(1E-10)
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,20 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal class SetInitialLoadingStateTransformer(
private val isRefreshing: Boolean,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val initialState = prevState.initialInfoState as? StakingStates.InitialInfoState.Data
return prevState.copy(
initialInfoState = initialState?.copy(
pullToRefreshConfig = prevState.initialInfoState.pullToRefreshConfig.copy(
isRefreshing = isRefreshing,
),
) ?: prevState.initialInfoState,
)
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.core.ui.extensions.isNullOrEmpty
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
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.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.utils.getPendingActionTitle
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)
StakingStep.RestakeValidator,
StakingStep.Validators,
-> resourceReference(R.string.staking_validators)
StakingStep.RewardsValidators -> resourceReference(R.string.common_claim_rewards)
StakingStep.InitialInfo -> resourceReference(
R.string.staking_title_stake,
wrappedList(prevState.cryptoCurrencyName),
)
StakingStep.Confirmation -> {
when (actionType) {
StakingActionCommonType.Enter -> resourceReference(
R.string.staking_title_stake,
wrappedList(prevState.cryptoCurrencyName),
)
is StakingActionCommonType.Exit -> resourceReference(
R.string.staking_title_unstake,
wrappedList(prevState.cryptoCurrencyName),
)
else -> {
val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val title = confirmationState?.pendingAction?.type?.getPendingActionTitle()
title.takeIf { !it.isNullOrEmpty() }
?: resourceReference(
id = R.string.staking_title_stake,
formatArgs = wrappedList(prevState.cryptoCurrencyName),
)
}
}
}
}
val subtitle = if (currentStep == StakingStep.Confirmation && actionType == StakingActionCommonType.Enter) {
stringReference(prevState.walletName)
} else {
null
}
return prevState.copy(
title = title,
subtitle = subtitle,
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.features.staking.impl.presentation.state.bottomsheet.StakingActionSelectionBottomSheetConfig
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
internal class ShowActionSelectorBottomSheetTransformer(
private val pendingActions: ImmutableList<PendingAction>,
private val onActionSelect: (PendingAction) -> Unit,
private val onDismiss: () -> Unit,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
isShown = true,
content = StakingActionSelectionBottomSheetConfig(
title = resourceReference(R.string.common_choose_action),
actions = pendingActions,
onActionSelect = onActionSelect,
),
onDismissRequest = onDismiss,
),
)
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType
import com.tangem.features.staking.impl.presentation.state.bottomsheet.StakingInfoBottomSheetConfig
import com.tangem.utils.transformer.Transformer
internal class ShowInfoBottomSheetStateTransformer(
private val infoType: InfoType,
private val onDismiss: () -> Unit,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
onDismissRequest = onDismiss,
isShown = true,
content = when (infoType) {
InfoType.ANNUAL_PERCENTAGE_RATE -> StakingInfoBottomSheetConfig(
title = resourceReference(R.string.staking_details_annual_percentage_rate),
text = resourceReference(R.string.staking_details_annual_percentage_rate_info),
)
InfoType.UNBONDING_PERIOD -> StakingInfoBottomSheetConfig(
title = resourceReference(R.string.staking_details_unbonding_period),
text = resourceReference(R.string.staking_details_unbonding_period_info),
)
InfoType.REWARD_CLAIMING -> StakingInfoBottomSheetConfig(
title = resourceReference(R.string.staking_details_reward_claiming),
text = resourceReference(R.string.staking_details_reward_claiming_info),
)
InfoType.WARMUP_PERIOD -> StakingInfoBottomSheetConfig(
title = resourceReference(R.string.staking_details_warmup_period),
text = resourceReference(R.string.staking_details_warmup_period_info),
)
InfoType.REWARD_SCHEDULE -> StakingInfoBottomSheetConfig(
title = resourceReference(R.string.staking_details_reward_schedule),
text = resourceReference(R.string.staking_details_reward_schedule_info),
)
},
),
)
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.features.staking.impl.presentation.state.transformers.amount
import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal class AmountChangeStateTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val minimumTransactionAmount: EnterAmountBoundary?,
private val value: String,
private val yield: Yield,
) : Transformer<StakingUiState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
override fun transform(prevState: StakingUiState): StakingUiState {
val actionType = prevState.actionType
val maxEnterAmount = if (actionType is StakingActionCommonType.Exit) {
EnterAmountBoundary(
amount = prevState.balanceState?.cryptoAmount,
fiatAmount = prevState.balanceState?.fiatAmount,
fiatRate = cryptoCurrencyStatus.value.fiatRate,
)
} else {
maxEnterAmountConverter.convert(cryptoCurrencyStatus)
}
val updatedAmountState = AmountFieldChangeTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxEnterAmount = maxEnterAmount,
minimumTransactionAmount = minimumTransactionAmount,
value = value,
).transform(prevState.amountState)
return prevState.copy(
amountState = AmountRequirementStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
yield = yield,
actionType = prevState.actionType,
).transform(updatedAmountState),
)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.staking.impl.presentation.state.transformers.amount
import com.tangem.common.ui.amountScreen.converters.AmountCurrencyTransformer
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal class AmountCurrencyChangeStateTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val value: Boolean,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
amountState = AmountCurrencyTransformer(cryptoCurrencyStatus, value).transform(prevState.amountState),
)
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.staking.impl.presentation.state.transformers.amount
import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldSetMaxAmountTransformer
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal class AmountMaxValueStateTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val minimumTransactionAmount: EnterAmountBoundary?,
private val actionType: StakingActionCommonType,
private val yield: Yield,
) : Transformer<StakingUiState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
override fun transform(prevState: StakingUiState): StakingUiState {
val maxEnterAmount = if (actionType is StakingActionCommonType.Exit) {
EnterAmountBoundary(
amount = prevState.balanceState?.cryptoAmount,
fiatAmount = prevState.balanceState?.fiatAmount,
fiatRate = cryptoCurrencyStatus.value.fiatRate,
)
} else {
maxEnterAmountConverter.convert(cryptoCurrencyStatus)
}
val updatedAmountState = AmountFieldSetMaxAmountTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxAmount = maxEnterAmount,
minAmount = minimumTransactionAmount,
).transform(prevState.amountState)
return prevState.copy(
amountState = AmountRequirementStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
yield = yield,
actionType = prevState.actionType,
).transform(updatedAmountState),
)
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.staking.impl.presentation.state.transformers.amount
import com.tangem.common.ui.amountScreen.converters.AmountPastedTriggerDismissTransformer
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal class AmountPasteDismissStateTransformer : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
amountState = AmountPastedTriggerDismissTransformer().transform(prevState.amountState),
)
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.staking.impl.presentation.state.transformers.amount
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal class AmountReduceByStateTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val minimumTransactionAmount: EnterAmountBoundary?,
private val value: ReduceByData,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
amountState = AmountReduceByTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
minimumTransactionAmount = minimumTransactionAmount,
value = value,
).transform(prevState.amountState),
)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.staking.impl.presentation.state.transformers.amount
import com.tangem.common.ui.amountScreen.converters.AmountReduceToTransformer
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
import java.math.BigDecimal
internal class AmountReduceToStateTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val minimumTransactionAmount: EnterAmountBoundary?,
private val value: BigDecimal,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
amountState = AmountReduceToTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
minimumTransactionAmount = minimumTransactionAmount,
value = value,
).transform(prevState.amountState),
)
}
}

View file

@ -0,0 +1,136 @@
package com.tangem.features.staking.impl.presentation.state.transformers.amount
import androidx.annotation.StringRes
import androidx.compose.ui.text.input.ImeAction
import com.tangem.common.extensions.isZero
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.staking.model.stakekit.AddressArgument
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.R
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
import java.math.BigDecimal
import java.math.RoundingMode
internal class AmountRequirementStateTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val yield: Yield,
private val actionType: StakingActionCommonType,
) : Transformer<AmountState> {
override fun transform(prevState: AmountState): AmountState {
return if (prevState is AmountState.Data) {
updateWithError(
prevState,
actionType,
)
} else {
prevState
}
}
private fun updateWithError(amountState: AmountState.Data, actionType: StakingActionCommonType): AmountState {
val requirementError = getRequirementError(amountState)
val isIntegerOnlyError = isIntegerOnlyError(amountState, actionType)
val cryptoAmount = amountState.amountTextField.cryptoAmount
val roundedDownCrypto = cryptoAmount.value
?.setScale(0, RoundingMode.DOWN)
?.parseBigDecimal(0)
.orEmpty()
val isAmountZeroOrNull = if (amountState.amountTextField.isFiatValue) {
amountState.amountTextField.fiatAmount.value.isNullOrZero()
} else {
amountState.amountTextField.cryptoAmount.value.isNullOrZero()
}
val errorText = when {
amountState.amountTextField.isError -> amountState.amountTextField.error
requirementError != null -> requirementError
isIntegerOnlyError -> when (actionType) {
StakingActionCommonType.Enter -> resourceReference(
R.string.staking_amount_tron_integer_error,
wrappedList(roundedDownCrypto),
)
is StakingActionCommonType.Exit -> resourceReference(
R.string.staking_amount_tron_integer_error_unstaking,
wrappedList(roundedDownCrypto),
)
else -> null
}
else -> null
}
val isError = amountState.amountTextField.isError || requirementError != null
return amountState.copy(
isPrimaryButtonEnabled = !isAmountZeroOrNull && !isError,
amountTextField = amountState.amountTextField.copy(
isError = isError,
isWarning = isIntegerOnlyError,
error = errorText ?: amountState.amountTextField.error,
keyboardOptions = amountState.amountTextField.keyboardOptions.copy(
imeAction = ImeAction.None,
),
),
)
}
private fun getRequirementError(prevState: AmountState.Data): TextReference? {
val amountDecimal = prevState.amountTextField.cryptoAmount.value ?: return null
val isAlreadyErrorState = prevState.amountTextField.isError
val isAmountZero = amountDecimal.isZero()
if (isAlreadyErrorState || isAmountZero) return null
return when (actionType) {
StakingActionCommonType.Enter -> {
val enterRequirements = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]
enterRequirements?.getError(amountDecimal, R.string.staking_amount_requirement_error)
}
is StakingActionCommonType.Exit -> {
val exitRequirements = yield.args.exit?.args?.get(Yield.Args.ArgType.AMOUNT)
exitRequirements?.getError(amountDecimal, R.string.staking_unstake_amount_requirement_error)
}
else -> null
}
}
private fun isIntegerOnlyError(amountState: AmountState.Data, actionType: StakingActionCommonType): Boolean {
val cryptoAmountValue = amountState.amountTextField.cryptoAmount.value ?: return false
val isEnterOrExit = actionType == StakingActionCommonType.Enter || actionType is StakingActionCommonType.Exit
val isTron = isTron(cryptoCurrencyStatus.currency.network.id.value)
val isIntegerOnly = cryptoAmountValue.isZero() || cryptoAmountValue.remainder(BigDecimal.ONE).isZero()
return isEnterOrExit && isTron && !isIntegerOnly
}
private fun AddressArgument.getError(amount: BigDecimal, @StringRes errorTextRes: Int): TextReference? {
val isExceedsRequirements = maximum?.compareTo(amount) == -1 ||
minimum?.compareTo(amount) == 1
return resourceReference(
errorTextRes,
wrappedList(
minimum.format {
crypto(cryptoCurrencyStatus.currency)
},
),
).takeIf { required && isExceedsRequirements }
}
data class Data(
val amountState: AmountState,
val actionType: StakingActionCommonType,
)
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.staking.impl.presentation.state.transformers.amount
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
import java.math.RoundingMode
internal class AmountRoundToIntegerTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val amountState = prevState.amountState as? AmountState.Data ?: return prevState
val amountTextField = amountState.amountTextField
if (amountTextField.value.isEmpty()) return prevState
val cryptoAmount = amountState.amountTextField.cryptoAmount
val fiatAmount = amountState.amountTextField.fiatAmount
val fiatDecimals = fiatAmount.decimals
val roundedDownCrypto = cryptoAmount.value?.setScale(0, RoundingMode.DOWN)
val roundedDownFiat = roundedDownCrypto?.multiply(cryptoCurrencyStatus.value.fiatRate)
val value = roundedDownCrypto?.parseBigDecimal(0).orEmpty()
val fiatValue = roundedDownFiat?.parseBigDecimal(fiatDecimals, RoundingMode.HALF_UP).orEmpty()
return prevState.copy(
amountState = amountState.copy(
amountTextField = amountState.amountTextField.copy(
cryptoAmount = cryptoAmount.copy(value = roundedDownCrypto),
fiatAmount = fiatAmount.copy(value = roundedDownFiat),
value = value,
fiatValue = fiatValue,
isWarning = false,
),
),
)
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.staking.impl.presentation.state.transformers.approval
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal class SetApprovalBottomSheetInProgressTransformer(
private val onDismiss: () -> Unit,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val approvalBottomSheetConfig = prevState.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig
return prevState.copy(
bottomSheetConfig = prevState.bottomSheetConfig?.copy(
onDismissRequest = onDismiss,
isShown = true,
content = approvalBottomSheetConfig?.let { config ->
config.copy(
data = config.data.copy(
approveButton = config.data.approveButton.copy(
enabled = false,
loading = true,
),
cancelButton = config.data.cancelButton.copy(
enabled = false,
),
),
onCancel = onDismiss,
)
} as TangemBottomSheetConfigContent,
),
)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.features.staking.impl.presentation.state.transformers.approval
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
internal class SetApprovalBottomSheetTypeChangeTransformer(
private val approveType: ApproveType,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val approvalBottomSheetConfig = prevState.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig
return prevState.copy(
bottomSheetConfig = prevState.bottomSheetConfig?.copy(
content = approvalBottomSheetConfig?.copy(
data = approvalBottomSheetConfig.data.copy(approveType = approveType),
) as TangemBottomSheetConfigContent,
),
)
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.staking.impl.presentation.state.transformers.approval
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.StakingNotification
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList
internal object SetApprovalInProgressTransformer : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val state = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val notifications = state?.notifications?.toMutableList() ?: mutableListOf()
notifications.add(
StakingNotification.Warning.TransactionInProgress(
title = resourceReference(R.string.warning_approval_in_progress_title),
description = resourceReference(R.string.warning_approval_in_progress_message),
),
)
val updatedConfirmationState = state?.copy(
notifications = notifications.toPersistentList(),
isPrimaryButtonEnabled = false,
) ?: prevState.confirmationState
return prevState.copy(
confirmationState = updatedConfirmationState,
)
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.staking.impl.presentation.state.transformers.approval
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.presentation.state.FeeState
import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.Provider
import com.tangem.utils.transformer.Transformer
internal class SetConfirmationStateAssentApprovalTransformer(
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
private val fee: TransactionFee,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
confirmationState = prevState.confirmationState.copyWrapped(),
bottomSheetConfig = null,
)
}
private fun StakingStates.ConfirmationState.copyWrapped(): StakingStates.ConfirmationState {
return if (this is StakingStates.ConfirmationState.Data) {
val isFeeConvertibleToFiat = feeCryptoCurrencyStatus?.currency?.network?.hasFiatFeeRate == true
copy(
innerState = InnerConfirmationStakingState.ASSENT,
feeState = FeeState.Content(
fee = fee.normal,
rate = feeCryptoCurrencyStatus?.value?.fiatRate,
isFeeConvertibleToFiat = isFeeConvertibleToFiat,
appCurrency = appCurrencyProvider(),
isFeeApproximate = false,
),
isPrimaryButtonEnabled = true,
isApprovalNeeded = true,
)
} else {
this
}
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.features.staking.impl.presentation.state.transformers.approval
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.bottomsheet.permission.state.*
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.FeeState
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.Provider
import com.tangem.utils.transformer.Transformer
internal class ShowApprovalBottomSheetTransformer(
private val appCurrencyProvider: Provider<AppCurrency>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
private val onDismiss: () -> Unit,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val cryptoCurrencyValue = cryptoCurrencyStatusProvider().value
val amountState = prevState.amountState as? AmountState.Data ?: return prevState
val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState
val validatorState = prevState.validatorState as? StakingStates.ValidatorState.Data ?: return prevState
val feeState = confirmationState.feeState as? FeeState.Content ?: return prevState
val fee = feeState.fee ?: return prevState
val walletAddress = cryptoCurrencyValue.networkAddress?.defaultAddress?.value.orEmpty()
val validatorAddress = validatorState.chosenValidator.address
val feeCryptoValue = fee.amount.value.format {
crypto(fee.amount.currencySymbol, fee.amount.decimals)
}
val feeFiatValue = BigDecimalFormatter.formatFiatAmount(
fiatAmount = feeCryptoCurrencyStatus?.value?.fiatRate?.multiply(fee.amount.value),
fiatCurrencyCode = appCurrencyProvider().code,
fiatCurrencySymbol = appCurrencyProvider().symbol,
)
return prevState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = GiveTxPermissionBottomSheetConfig(
data = GiveTxPermissionState.ReadyForRequest(
currency = cryptoCurrency.symbol,
amount = amountState.amountTextField.value,
approveType = ApproveType.UNLIMITED,
walletAddress = walletAddress,
spenderAddress = validatorAddress,
fee = resourceReference(
R.string.common_crypto_fiat_format,
wrappedList(feeCryptoValue, feeFiatValue),
),
approveButton = ApprovePermissionButton(
enabled = true,
loading = false,
onClick = prevState.clickIntents::onApprovalClick,
),
cancelButton = CancelPermissionButton(
enabled = true,
),
subtitle = resourceReference(
id = R.string.give_permission_staking_subtitle,
formatArgs = wrappedList(cryptoCurrency.symbol),
),
dialogText = resourceReference(R.string.give_permission_staking_footer),
footerText = resourceReference(R.string.staking_give_permission_fee_footer),
onChangeApproveType = prevState.clickIntents::onApproveTypeChange,
),
onCancel = onDismiss,
),
),
)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.staking.impl.presentation.state.transformers.confirmation
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
import java.math.BigDecimal
internal class SetUpdatedAllowanceTransformer(
private val allowance: BigDecimal,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
return prevState.copy(
confirmationState = confirmationState?.copy(allowance = allowance) ?: prevState.confirmationState,
)
}
}

View file

@ -0,0 +1,257 @@
package com.tangem.features.staking.impl.presentation.state.transformers.notifications
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addRentExemptionNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.staking.impl.presentation.state.FeeState
import com.tangem.features.staking.impl.presentation.state.StakingNotification
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount
import com.tangem.features.staking.impl.presentation.state.utils.checkFeeCoverage
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class AddStakingNotificationsTransformer(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
private val currencyWarning: CryptoCurrencyWarning?,
private val validatorError: Throwable?,
private val feeError: GetFeeError?,
private val currencyCheck: CryptoCurrencyCheck,
private val isSubtractAvailable: Boolean,
private val yield: Yield,
) : Transformer<StakingUiState> {
private val stakingInfoNotificationsFactory = StakingInfoNotificationsFactory(
cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider,
yield = yield,
isSubtractAvailable = isSubtractAvailable,
)
override fun transform(prevState: StakingUiState): StakingUiState {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val balance = cryptoCurrencyStatus.value.amount.orZero()
val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState
val amountState = prevState.amountState as? AmountState.Data ?: return prevState
val feeState = confirmationState.feeState as? FeeState.Content
val amountValue = amountState.amountTextField.cryptoAmount.value.orZero()
val feeValue = feeState?.fee?.amount?.value.orZero()
val reduceAmountBy = confirmationState.reduceAmountBy.orZero()
val isEnterAction = prevState.actionType == StakingActionCommonType.Enter
val isFeeCoverage = checkFeeCoverage(
amountValue = amountValue,
feeValue = feeValue,
balance = balance,
isSubtractAvailable = isSubtractAvailable,
reduceAmountBy = reduceAmountBy,
)
val minimumRequirement = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum.orZero()
val sendingAmount = if (isEnterAction) {
checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amountValue = amountValue,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
).max(minimumRequirement)
} else {
// No amount is taken from account balance on exit or pending actions
BigDecimal.ZERO
}
val notifications = buildList {
// errors
addErrorNotifications(
prevState = prevState,
feeError = feeError,
sendingAmount = sendingAmount,
onReload = prevState.clickIntents::getFee,
feeValue = feeValue,
)
// warnings
addWarningNotifications(
prevState = prevState,
amountState = amountState,
feeState = feeState,
sendingAmount = sendingAmount,
isFeeCoverage = isFeeCoverage && isEnterAction && !sendingAmount.equals(minimumRequirement),
)
stakingInfoNotificationsFactory.addInfoNotifications(
notifications = this,
prevState = prevState,
sendingAmount = sendingAmount,
actionAmount = amountValue,
feeValue = feeValue,
)
}.toImmutableList()
return prevState.copy(
confirmationState = confirmationState.copy(
notifications = notifications.toImmutableList(),
isPrimaryButtonEnabled = notifications.none {
it is StakingNotification.Error ||
it is NotificationUM.Error ||
it is NotificationUM.Warning.NetworkFeeUnreachable ||
it is StakingNotification.Warning.TransactionInProgress
},
),
)
}
private fun MutableList<NotificationUM>.addErrorNotifications(
prevState: StakingUiState,
onReload: () -> Unit,
feeError: GetFeeError?,
sendingAmount: BigDecimal,
feeValue: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
val network = cryptoCurrency.network
addFeeUnreachableNotification(
feeError = feeError,
tokenName = cryptoCurrencyStatusProvider().currency.name,
onReload = onReload,
)
addStakeExceedBalanceNotification(
feeAmount = feeValue,
sendingAmount = sendingAmount,
actionType = prevState.actionType,
isSubtractionAvailable = isSubtractAvailable,
cryptoCurrencyStatus = cryptoCurrencyStatus,
onClick = prevState.clickIntents::openTokenDetails,
)
addExceedsBalanceNotification(
cryptoCurrencyWarning = currencyWarning,
cryptoCurrencyStatus = cryptoCurrencyStatus,
shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(network.backendId),
onClick = prevState.clickIntents::openTokenDetails,
onAnalyticsEvent = { /* no-op */ },
)
if (!BlockchainUtils.isCardano(network.id.value)) {
addDustWarningNotification(
dustValue = currencyCheck.dustValue,
feeValue = feeValue,
sendingAmount = sendingAmount,
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCurrencyStatus = feeCryptoCurrencyStatus,
)
}
addTransactionLimitErrorNotification(
currencyCheck = currencyCheck,
sendingAmount = sendingAmount,
cryptoCurrencyStatus = cryptoCurrencyStatus,
feeCurrencyStatus = feeCryptoCurrencyStatus,
feeValue = feeValue,
onReduceClick = prevState.clickIntents::onAmountReduceToClick,
)
addReserveAmountErrorNotification(
reserveAmount = currencyCheck.reserveAmount,
sendingAmount = sendingAmount,
cryptoCurrency = cryptoCurrency,
isAccountFunded = false,
)
}
private fun MutableList<NotificationUM>.addWarningNotifications(
prevState: StakingUiState,
amountState: AmountState.Data,
feeState: FeeState.Content?,
sendingAmount: BigDecimal,
isFeeCoverage: Boolean,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val appCurrency = appCurrencyProvider()
val cryptoCurrency = cryptoCurrencyStatus.currency
addRentExemptionNotification(
rentWarning = currencyCheck.rentWarning,
)
addExistentialWarningNotification(
existentialDeposit = currencyCheck.existentialDeposit,
feeAmount = feeState?.fee?.amount?.value.orZero(),
sendingAmount = sendingAmount,
cryptoCurrencyStatus = cryptoCurrencyStatus,
onReduceClick = prevState.clickIntents::onAmountReduceByClick,
)
addFeeCoverageNotification(
isFeeCoverage = isFeeCoverage,
amountField = amountState.amountTextField,
sendingValue = sendingAmount,
appCurrency = appCurrency,
cryptoCurrencyStatus = cryptoCurrencyStatus,
)
// blockchain specific
addValidateTransactionNotifications(
dustValue = currencyCheck.dustValue.orZero(),
minAdaValue = (feeState?.fee as? Fee.CardanoToken)?.minAdaValue,
validationError = validatorError,
cryptoCurrency = cryptoCurrency,
onReduceClick = prevState.clickIntents::onAmountReduceToClick,
)
}
private fun MutableList<NotificationUM>.addStakeExceedBalanceNotification(
feeAmount: BigDecimal,
sendingAmount: BigDecimal,
actionType: StakingActionCommonType,
isSubtractionAvailable: Boolean,
cryptoCurrencyStatus: CryptoCurrencyStatus,
onClick: (CryptoCurrency) -> Unit,
) {
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
if (!isSubtractionAvailable) return
val showNotification = sendingAmount + feeAmount > balance
if (showNotification) {
val notification = if (actionType == StakingActionCommonType.Enter) {
NotificationUM.Error.TotalExceedsBalance
} else {
with(cryptoCurrencyStatus.currency) {
NotificationUM.Error.ExceedsBalance(
networkIconId = networkIconResId,
networkName = name,
currencyName = name,
feeName = name,
feeSymbol = symbol,
mergeFeeNetworkName = BlockchainUtils.isArbitrum(network.backendId),
onClick = { onClick(this) },
)
}
}
add(notification)
}
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.staking.impl.presentation.state.transformers.notifications
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
internal class DismissStakingNotificationsStateTransformer(
private val notification: Class<out NotificationUM>,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val updatedNotifications = confirmationState?.notifications
?.filterNot { it::class == notification }?.toPersistentList()
?: persistentListOf()
return prevState.copy(
confirmationState = confirmationState?.copy(
notifications = updatedNotifications,
) ?: StakingStates.ConfirmationState.Empty(),
)
}
}

View file

@ -0,0 +1,182 @@
package com.tangem.features.staking.impl.presentation.state.transformers.notifications
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.staking.model.stakekit.BalanceType
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.StakingNotification
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.lib.crypto.BlockchainUtils.isCosmos
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.Provider
import com.tangem.utils.extensions.isZero
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
internal class StakingInfoNotificationsFactory(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val yield: Yield,
private val isSubtractAvailable: Boolean,
) {
/**
* @param notifications current notification to display
* @param prevState current screen state to update
* @param sendingAmount amount being transferred from user account
* @param actionAmount any amount being transferred or used action
* @param feeValue fee amount payed from user account
*/
fun addInfoNotifications(
notifications: MutableList<NotificationUM>,
prevState: StakingUiState,
sendingAmount: BigDecimal,
actionAmount: BigDecimal,
feeValue: BigDecimal,
) = with(notifications) {
addStakingLowBalanceNotification(prevState, actionAmount)
when (prevState.actionType) {
StakingActionCommonType.Enter -> addEnterInfoNotifications(sendingAmount, feeValue)
is StakingActionCommonType.Exit -> addExitInfoNotifications()
is StakingActionCommonType.Pending -> addPendingInfoNotifications(prevState)
}
}
private fun MutableList<NotificationUM>.addExitInfoNotifications() {
val cooldownPeriodDays = yield.metadata.cooldownPeriod?.days
if (cooldownPeriodDays != null) {
add(
StakingNotification.Info.Unstake(
cooldownPeriodDays = cooldownPeriodDays,
subtitleRes = if (isCosmos(cryptoCurrencyStatusProvider().currency.network.id.value)) {
R.string.staking_notification_unstake_cosmos_text
} else {
R.string.staking_notification_unstake_text
},
),
)
}
}
private fun MutableList<NotificationUM>.addEnterInfoNotifications(
sendingAmount: BigDecimal,
feeValue: BigDecimal,
) {
addTronRevoteNotification()
addStakingEntireBalanceNotification(sendingAmount, feeValue)
}
private fun MutableList<NotificationUM>.addPendingInfoNotifications(prevState: StakingUiState) {
val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val pendingActionType = confirmationState?.pendingAction?.type
val (titleReference, textReference) = when (pendingActionType) {
StakingActionType.CLAIM_REWARDS -> {
resourceReference(R.string.common_claim) to
resourceReference(R.string.staking_notification_claim_rewards_text)
}
StakingActionType.RESTAKE_REWARDS -> {
resourceReference(R.string.staking_restake) to
resourceReference(R.string.staking_notification_restake_rewards_text)
}
StakingActionType.CLAIM_UNSTAKED,
StakingActionType.WITHDRAW,
-> {
resourceReference(R.string.staking_withdraw) to
resourceReference(R.string.staking_notification_withdraw_text)
}
StakingActionType.UNLOCK_LOCKED -> {
val cooldownPeriodDays = yield.metadata.cooldownPeriod?.days
if (cooldownPeriodDays != null) {
resourceReference(R.string.staking_unlocked_locked) to resourceReference(
R.string.staking_notification_unlock_text,
wrappedList(
pluralReference(
id = R.plurals.common_days,
count = cooldownPeriodDays,
formatArgs = wrappedList(cooldownPeriodDays),
),
),
)
} else {
null to null
}
}
StakingActionType.VOTE_LOCKED -> {
resourceReference(R.string.staking_revote) to
resourceReference(R.string.staking_notifications_revote_tron_text)
}
StakingActionType.RESTAKE -> {
resourceReference(R.string.staking_restake) to
resourceReference(R.string.staking_notification_restake_text)
}
else -> null to null
}
if (titleReference != null && textReference != null) {
add(
StakingNotification.Info.Ordinary(
title = titleReference,
text = textReference,
),
)
}
}
private fun MutableList<NotificationUM>.addTronRevoteNotification() {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val isTron = isTron(cryptoCurrencyStatus.currency.network.id.value)
val hasStakedBalance = (cryptoCurrencyStatus.value.yieldBalance as? YieldBalance.Data)?.balance
?.items?.any {
it.type == BalanceType.PREPARING ||
it.type == BalanceType.STAKED ||
it.type == BalanceType.LOCKED
} == true
if (isTron && hasStakedBalance) {
add(
StakingNotification.Info.Ordinary(
title = resourceReference(R.string.staking_revote),
text = resourceReference(R.string.staking_notifications_revote_tron_text),
),
)
}
}
private fun MutableList<NotificationUM>.addStakingEntireBalanceNotification(
sendingAmount: BigDecimal,
feeValue: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val balance = cryptoCurrencyStatus.value.amount.orZero()
val isEntireBalance = sendingAmount.plus(feeValue) == balance
if (isEntireBalance && isSubtractAvailable) {
add(StakingNotification.Info.StakeEntireBalance)
}
}
private fun MutableList<NotificationUM>.addStakingLowBalanceNotification(
prevState: StakingUiState,
actionAmount: BigDecimal,
) {
if (prevState.actionType !is StakingActionCommonType.Exit) return
val maxAmount = prevState.balanceState?.cryptoAmount ?: return
val exitRequirements = yield.args.exit?.args?.get(Yield.Args.ArgType.AMOUNT) ?: return
val amountLeft = maxAmount - actionAmount
val isNotEnoughLeft = !amountLeft.isZero() && amountLeft < exitRequirements.minimum.orZero()
if (exitRequirements.required && isNotEnoughLeft) {
add(StakingNotification.Warning.LowStakedBalance)
}
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.features.staking.impl.presentation.state.transformers.validator
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
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.utils.transformer.Transformer
internal class ValidatorSelectChangeTransformer(
private val yield: Yield,
private val selectedValidator: Yield.Validator?,
) : Transformer<StakingUiState> {
override fun transform(prevState: StakingUiState): StakingUiState {
val validatorState = prevState.validatorState as? StakingStates.ValidatorState.Data
val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data
val isRestake = prevState.actionType == StakingActionCommonType.Pending.Restake
val isEnter = prevState.actionType == StakingActionCommonType.Enter
val isFromInfoScreen = prevState.currentStep == StakingStep.InitialInfo
val isVoteLocked = confirmationState?.pendingAction?.type == StakingActionType.VOTE_LOCKED
val activeValidator = selectedValidator.takeIf { isFromInfoScreen && isRestake }
?: validatorState?.activeValidator
val filteredValidators = yield.preferredValidators.filterNot { it == activeValidator }
val selectedValidator = if (isRestake && isFromInfoScreen) {
filteredValidators.firstOrNull()
} else {
selectedValidator
}
return prevState.copy(
validatorState = StakingStates.ValidatorState.Data(
chosenValidator = selectedValidator ?: yield.preferredValidators.first(),
availableValidators = filteredValidators,
isPrimaryButtonEnabled = true,
isClickable = true,
activeValidator = activeValidator,
isVisibleOnConfirmation = isEnter || isRestake || isVoteLocked,
),
)
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.features.staking.impl.presentation.state.utils
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.lib.crypto.BlockchainUtils.isTron
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
/**
* Check and calculates subtracted amount
*/
internal fun checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable: Boolean,
cryptoCurrencyStatus: CryptoCurrencyStatus,
amountValue: BigDecimal,
feeValue: BigDecimal,
reduceAmountBy: BigDecimal,
): BigDecimal {
val balance = cryptoCurrencyStatus.value.amount ?: return amountValue
val isTron = isTron(cryptoCurrencyStatus.currency.network.id.value)
val feeValueRounded = if (isTron) {
feeValue.round(MathContext(0, RoundingMode.UP))
} else {
feeValue
}
val isFeeCoverage = checkFeeCoverage(
isSubtractAvailable = isAmountSubtractAvailable,
balance = balance,
amountValue = amountValue,
feeValue = feeValueRounded,
reduceAmountBy = reduceAmountBy,
)
return if (isFeeCoverage) {
val reducedAmount = balance.minus(reduceAmountBy).minus(feeValueRounded)
if (isTron(cryptoCurrencyStatus.currency.network.id.value)) {
reducedAmount.setScale(0, RoundingMode.DOWN)
} else {
reducedAmount
}
} else {
amountValue
}
}
/**
* Checks if sending amount with fee is greater than balance
*/
internal fun checkFeeCoverage(
isSubtractAvailable: Boolean,
balance: BigDecimal,
amountValue: BigDecimal,
feeValue: BigDecimal,
reduceAmountBy: BigDecimal?,
): Boolean {
if (!isSubtractAvailable) return false
val reducedBy = balance - (reduceAmountBy ?: BigDecimal.ZERO)
return reducedBy < amountValue + feeValue && reducedBy > feeValue && reducedBy >= amountValue
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.staking.impl.presentation.state.utils
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.lib.crypto.BlockchainUtils.isBSC
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.lib.crypto.BlockchainUtils.isTron
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@Suppress("CyclomaticComplexMethod")
internal fun StakingActionType?.getPendingActionTitle(): TextReference = when (this) {
StakingActionType.CLAIM_REWARDS -> resourceReference(R.string.common_claim_rewards)
StakingActionType.RESTAKE_REWARDS -> resourceReference(R.string.staking_restake_rewards)
StakingActionType.CLAIM_UNSTAKED,
StakingActionType.WITHDRAW,
-> resourceReference(R.string.staking_withdraw)
StakingActionType.RESTAKE -> resourceReference(R.string.staking_restake)
StakingActionType.UNLOCK_LOCKED -> resourceReference(R.string.staking_unlocked_locked)
StakingActionType.STAKE_LOCKED -> resourceReference(R.string.staking_stake_locked)
StakingActionType.VOTE -> resourceReference(R.string.staking_vote)
StakingActionType.REVOKE -> resourceReference(R.string.staking_revoke)
StakingActionType.VOTE_LOCKED -> resourceReference(R.string.staking_vote_locked)
StakingActionType.REVOTE -> resourceReference(R.string.staking_revote)
StakingActionType.REBOND -> resourceReference(R.string.staking_rebond)
StakingActionType.MIGRATE -> resourceReference(R.string.staking_migrate)
StakingActionType.STAKE -> resourceReference(R.string.common_stake)
StakingActionType.UNSTAKE -> resourceReference(R.string.common_unstake)
StakingActionType.UNKNOWN -> TextReference.EMPTY
null -> TextReference.EMPTY
}
internal fun isSingleAction(networkId: String, activeStake: BalanceState): Boolean {
val isSingleAction = activeStake.pendingActions.size <= 1 // Either single or none pending actions
val isCompositePendingActions = isCompositePendingActions(networkId, activeStake.pendingActions)
val isBscRestake = isBSC(networkId) && activeStake.pendingActions.any {
it.type == StakingActionType.RESTAKE
}
return isSingleAction && !isBscRestake || isCompositePendingActions
}
internal fun withStubUnstakeAction(networkId: String, activeStake: BalanceState) = if (isBSC(networkId)) {
activeStake.pendingActions.plus(
PendingAction(
type = StakingActionType.UNSTAKE,
passthrough = "",
args = null,
),
).toPersistentList()
} else {
activeStake.pendingActions
}
internal fun isTronStakedBalance(networkId: String, pendingAction: PendingAction?): Boolean {
return isTron(networkId) && pendingAction?.type == StakingActionType.REVOTE
}
internal fun isCompositePendingActions(networkId: String, pendingActions: ImmutableList<PendingAction>?): Boolean {
return when {
isSolana(networkId) -> pendingActions?.any { it.type == StakingActionType.WITHDRAW } == true
else -> false
}
}

View file

@ -0,0 +1,76 @@
package com.tangem.features.staking.impl.presentation.state.utils
import com.tangem.core.ui.extensions.*
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.COSMOS_SCHEDULE
import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.SOLANA_SCHEDULE
import com.tangem.lib.crypto.BlockchainUtils.isCosmos
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.StringsSigns.MINUS
import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE
private data object StakingRewardSchedule {
val COSMOS_SCHEDULE = 5 to 12
val SOLANA_SCHEDULE = 2 to 3
}
internal fun getRewardScheduleText(
rewardSchedule: Yield.Metadata.RewardSchedule,
networkId: String,
decapitalize: Boolean,
): TextReference? {
return when (rewardSchedule) {
Yield.Metadata.RewardSchedule.WEEK -> resourceReference(
id = R.string.staking_reward_schedule_week,
decapitalize = decapitalize,
)
Yield.Metadata.RewardSchedule.HOUR -> resourceReference(
id = R.string.staking_reward_schedule_hour,
decapitalize = decapitalize,
)
Yield.Metadata.RewardSchedule.DAY -> resourceReference(
id = R.string.staking_reward_schedule_day,
decapitalize = decapitalize,
)
Yield.Metadata.RewardSchedule.MONTH -> resourceReference(
id = R.string.staking_reward_schedule_month,
decapitalize = decapitalize,
)
Yield.Metadata.RewardSchedule.BLOCK,
Yield.Metadata.RewardSchedule.EPOCH,
Yield.Metadata.RewardSchedule.ERA,
-> getCustomRewardSchedule(
networkId = networkId,
decapitalize = decapitalize,
)
else -> null
}
}
private fun getCustomRewardSchedule(networkId: String, decapitalize: Boolean = false): TextReference? {
return when {
isSolana(networkId) -> {
combinedReference(
resourceReference(id = R.string.staking_reward_schedule_each_plural, decapitalize = decapitalize),
stringReference(NON_BREAKING_SPACE.toString()),
stringReference("${SOLANA_SCHEDULE.first}$MINUS${SOLANA_SCHEDULE.second}$NON_BREAKING_SPACE"),
pluralReference(
id = R.plurals.common_days_no_param,
count = SOLANA_SCHEDULE.second,
),
)
}
isCosmos(networkId) -> {
combinedReference(
resourceReference(id = R.string.staking_reward_schedule_each_plural, decapitalize = decapitalize),
stringReference(NON_BREAKING_SPACE.toString()),
stringReference("${COSMOS_SCHEDULE.first}$MINUS${COSMOS_SCHEDULE.second}$NON_BREAKING_SPACE"),
resourceReference(R.string.common_second_no_param),
)
}
isTron(networkId) -> resourceReference(id = R.string.staking_reward_schedule_day, decapitalize = decapitalize)
else -> null
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.features.staking.impl.presentation.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.inputrow.InputRowImageInfo
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.res.TangemTheme
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.utils.extensions.orZero
@Composable
internal fun StakingClaimRewardsValidatorContent(
state: StakingStates.RewardsValidatorsState,
clickIntents: StakingClickIntents,
modifier: Modifier = Modifier,
) {
if (state !is StakingStates.RewardsValidatorsState.Data) return
Column(
modifier = Modifier // Do not put fillMaxSize() in here
.background(TangemTheme.colors.background.secondary)
.padding(horizontal = TangemTheme.dimens.spacing12)
.verticalScroll(rememberScrollState()),
) {
state.rewards.forEachIndexed { index, item ->
key(item.title.resolveReference() + index) {
InputRowImageInfo(
subtitle = item.title,
caption = combinedReference(
resourceReference(R.string.staking_details_apr),
annotatedReference {
appendSpace()
appendColored(
text = item.validator?.apr.orZero().format { percent() },
color = TangemTheme.colors.text.accent,
)
},
),
infoTitle = item.formattedFiatAmount,
infoSubtitle = item.formattedCryptoAmount,
imageUrl = item.validator?.image.orEmpty(),
onImageError = { ValidatorImagePlaceholder() },
modifier = modifier
.roundedShapeItemDecoration(index, state.rewards.lastIndex, false)
.background(TangemTheme.colors.background.action)
.clickable(
enabled = item.pendingActions.isNotEmpty(),
onClick = {
clickIntents.onActiveStake(item)
},
),
)
}
}
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.staking.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.common.ui.amountScreen.ui.AmountBlock
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState
import com.tangem.features.staking.impl.presentation.state.StakingNotification
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.TransactionDoneState
import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmationStatePreviewData
import com.tangem.features.staking.impl.presentation.state.previewdata.ValidatorStatePreviewData
import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub
import com.tangem.features.staking.impl.presentation.ui.block.NotificationsBlock
import com.tangem.features.staking.impl.presentation.ui.block.StakingFeeBlock
import com.tangem.features.staking.impl.presentation.ui.block.ValidatorBlock
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
@Suppress("LongParameterList")
@Composable
internal fun StakingConfirmationContent(
amountState: AmountState,
state: StakingStates.ConfirmationState,
validatorState: StakingStates.ValidatorState,
clickIntents: StakingClickIntents,
) {
if (state !is StakingStates.ConfirmationState.Data) return
val isTransactionSent = state.innerState == InnerConfirmationStakingState.COMPLETED
val isTransactionInProgress = state.notifications.any { it is StakingNotification.Warning.TransactionInProgress }
Column(
modifier = Modifier
.background(TangemTheme.colors.background.secondary)
.padding(horizontal = TangemTheme.dimens.spacing16)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
val doneState = state.transactionDoneState
AnimatedVisibility(
visible = doneState is TransactionDoneState.Content,
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12),
) {
TransactionDoneTitle(
title = resourceReference(R.string.common_in_progress),
subtitle = resourceReference(R.string.staking_transaction_in_progress_text),
)
}
AmountBlock(
amountState = amountState,
isClickDisabled = !state.isAmountEditable || isTransactionSent || isTransactionInProgress,
isEditingDisabled = !state.isAmountEditable && state.innerState != InnerConfirmationStakingState.COMPLETED,
onClick = clickIntents::onPrevClick,
)
ValidatorBlock(
validatorState = validatorState,
isClickable = !isTransactionInProgress,
onClick = clickIntents::openValidators,
)
StakingFeeBlock(feeState = state.feeState, isTransactionSent = isTransactionSent)
NotificationsBlock(notifications = state.notifications)
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_StakingConfirmationContent() {
TangemThemePreview {
Column(Modifier.background(TangemTheme.colors.background.primary)) {
StakingConfirmationContent(
amountState = AmountStatePreviewData.amountState,
state = ConfirmationStatePreviewData.assentStakingState,
validatorState = ValidatorStatePreviewData.validatorState,
clickIntents = StakingClickIntentsStub,
)
}
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.features.staking.impl.presentation.ui
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import com.tangem.common.ui.alerts.models.AlertUM
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.events.StakingEvent
@Composable
internal fun StakingEventEffect(event: StateEvent<StakingEvent>, snackbarHostState: SnackbarHostState) {
val resources = LocalContext.current.resources
var alertConfig by remember { mutableStateOf<AlertUM?>(value = null) }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(key1 = alertConfig) {
keyboardController?.hide()
}
alertConfig?.let {
StakingAlert(state = it, onDismiss = { alertConfig = null })
}
EventEffect(
event = event,
onTrigger = { value ->
when (value) {
is StakingEvent.ShowSnackBar -> {
snackbarHostState.showSnackbar(message = value.text.resolveReference(resources))
}
is StakingEvent.ShowAlert -> {
alertConfig = value.alert
}
}
},
)
}
@Composable
internal fun StakingAlert(state: AlertUM, onDismiss: () -> Unit) {
val confirmButton: DialogButtonUM
val dismissButton: DialogButtonUM?
val onActionClick = state.onConfirmClick
if (onActionClick != null) {
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
onClick = {
onActionClick()
onDismiss()
},
)
dismissButton = DialogButtonUM(
title = stringResourceSafe(id = R.string.common_cancel),
onClick = onDismiss,
)
} else {
confirmButton = DialogButtonUM(
title = state.confirmButtonText.resolveReference(),
onClick = onDismiss,
)
dismissButton = null
}
BasicDialog(
message = state.message.resolveReference(),
confirmButton = confirmButton,
onDismissDialog = onDismiss,
title = state.title?.resolveReference(),
dismissButton = dismissButton,
)
}

View file

@ -0,0 +1,378 @@
package com.tangem.features.staking.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
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.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.inputrow.InputRowDefault
import com.tangem.core.ui.components.inputrow.InputRowImageInfo
import com.tangem.core.ui.components.list.roundedListWithDividersItems
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.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
private const val BANNER_BLOCK_KEY = "BannerBlock"
private const val STAKING_REWARD_BLOCK_KEY = "StakingRewardBlock"
private const val ACTIVE_STAKING_BLOCK_KEY = "ActiveStakingBlock"
private const val STAKE_PRIMARY_BUTTON_KEY = "StakePrimaryButton"
@Composable
internal fun StakingInitialInfoContent(
state: StakingStates.InitialInfoState,
buttonState: NavigationButtonsState,
clickIntents: StakingClickIntents,
isBalanceHidden: Boolean,
) {
if (state !is StakingStates.InitialInfoState.Data) return
TangemPullToRefreshContainer(
config = state.pullToRefreshConfig,
) {
LazyColumn(
verticalArrangement = alignLastToBottom(),
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.secondary)
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
if (state.showBanner) {
item(key = BANNER_BLOCK_KEY) {
Column(
modifier = Modifier.animateItem(),
) {
BannerBlock(onClick = clickIntents::onInitialInfoBannerClick)
SpacerH12()
}
}
}
this.roundedListWithDividersItems(
rows = state.infoItems,
footerContent = { SpacerH12() },
hideEndText = isBalanceHidden,
)
activeStakingBlock(
state = state,
clickIntents = clickIntents,
isBalanceHidden = isBalanceHidden,
)
item(STAKE_PRIMARY_BUTTON_KEY) {
SpacerH12()
StakeButtonBlock(buttonState)
}
}
}
}
private fun LazyListScope.activeStakingBlock(
state: StakingStates.InitialInfoState.Data,
clickIntents: StakingClickIntents,
isBalanceHidden: Boolean,
) {
val innerYieldBalanceState = state.yieldBalance as? InnerYieldBalanceState.Data ?: return
item(key = STAKING_REWARD_BLOCK_KEY) {
Column(modifier = Modifier.animateItem()) {
StakingRewardBlock(
yieldBalanceState = state.yieldBalance,
onRewardsClick = clickIntents::openRewardsValidators,
isBalanceHidden = isBalanceHidden,
)
SpacerH12()
}
}
if (innerYieldBalanceState.balances.isNotEmpty()) {
item(ACTIVE_STAKING_BLOCK_KEY) {
Text(
text = stringResourceSafe(id = R.string.staking_your_stakes),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = 0,
lastIndex = 1 + state.yieldBalance.balances.lastIndex,
addDefaultPadding = false,
)
.fillMaxWidth()
.background(TangemTheme.colors.background.action)
.padding(
top = TangemTheme.dimens.spacing12,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing4,
),
)
}
itemsIndexed(
items = state.yieldBalance.balances,
key = { index, balance ->
// Staked balance does not have unique identifier.
buildString {
append(balance.hashCode())
append("_")
append(index)
}
},
) { index, balance ->
ActiveStakingBlock(
balance = balance,
isBalanceHidden = isBalanceHidden,
onClick = clickIntents::onActiveStake,
onAnalytic = clickIntents::onActiveStakeAnalytic,
modifier = Modifier
.animateItem()
.roundedShapeItemDecoration(
currentIndex = index + 1,
lastIndex = state.yieldBalance.balances.lastIndex + 1,
addDefaultPadding = false,
),
)
}
}
}
@Composable
private fun BannerBlock(onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(size = TangemTheme.dimens.radius14))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
),
) {
Image(
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.FillWidth,
painter = painterResource(R.drawable.img_staking_banner),
contentDescription = null,
)
Text(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(TangemTheme.dimens.spacing16),
text = buildAnnotatedString {
withStyle(SpanStyle(Brush.linearGradient(textGradientColors))) {
append(stringResourceSafe(R.string.staking_details_banner_text))
}
},
style = TangemTheme.typography.h2,
)
}
}
@Composable
private fun StakingRewardBlock(
yieldBalanceState: InnerYieldBalanceState.Data,
onRewardsClick: () -> Unit,
isBalanceHidden: Boolean,
) {
val (text, textColor) = when (yieldBalanceState.rewardBlockType) {
RewardBlockType.Rewards -> {
annotatedReference {
append(yieldBalanceState.rewardsFiat.orMaskWithStars(isBalanceHidden))
appendSpace()
append(DOT)
appendSpace()
append(yieldBalanceState.rewardsCrypto.orMaskWithStars(isBalanceHidden))
} to TangemTheme.colors.text.primary1
}
RewardBlockType.RewardUnavailable -> {
resourceReference(R.string.staking_details_auto_claiming_rewards_daily_text) to
TangemTheme.colors.text.tertiary
}
RewardBlockType.NoRewards -> {
resourceReference(R.string.staking_details_no_rewards_to_claim) to TangemTheme.colors.text.tertiary
}
}
val isShowIcon = yieldBalanceState.rewardBlockType == RewardBlockType.Rewards && yieldBalanceState.isActionable
InputRowDefault(
title = resourceReference(R.string.staking_rewards),
text = text,
iconRes = R.drawable.ic_chevron_right_24.takeIf { isShowIcon },
textColor = textColor,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
enabled = yieldBalanceState.isActionable,
onClick = onRewardsClick,
),
)
}
@Composable
private fun ActiveStakingBlock(
balance: BalanceState,
isBalanceHidden: Boolean,
onClick: (BalanceState) -> Unit,
onAnalytic: () -> Unit,
modifier: Modifier = Modifier,
) {
val (icon, iconTint) = balance.type.getIcon()
InputRowImageInfo(
subtitle = balance.title,
caption = balance.subtitle ?: balance.getAprText(),
infoTitle = balance.formattedFiatAmount.orMaskWithStars(isBalanceHidden),
infoSubtitle = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden),
imageUrl = balance.getImage(),
iconRes = icon,
iconTint = iconTint,
subtitleEndIconRes = R.drawable.ic_staking_pending_transaction.takeIf { balance.isPending },
onImageError = { ValidatorImagePlaceholder() },
modifier = modifier
.background(TangemTheme.colors.background.action)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
enabled = balance.isClickable,
onClick = {
onAnalytic()
onClick(balance)
},
),
)
}
@Composable
private fun StakeButtonBlock(buttonState: NavigationButtonsState) {
val state = buttonState as? NavigationButtonsState.Data
val primaryButton = state?.primaryButton
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
state?.onTextClick?.let { StakingTosText(it) }
NavigationPrimaryButton(primaryButton = primaryButton)
}
}
@Composable
private fun BalanceState.getAprText() = combinedReference(
resourceReference(R.string.staking_details_apr),
annotatedReference {
appendSpace()
appendColored(
text = validator?.apr.orZero().format { percent() },
color = TangemTheme.colors.text.accent,
)
},
)
@Composable
private fun BalanceType.getIcon() = when (this) {
BalanceType.UNSTAKING -> R.drawable.ic_connection_18 to TangemTheme.colors.icon.accent
BalanceType.UNSTAKED -> R.drawable.ic_connection_18 to TangemTheme.colors.icon.informative
BalanceType.LOCKED -> R.drawable.ic_lock_24 to TangemTheme.colors.icon.informative
else -> null to TangemTheme.colors.icon.informative
}
@Composable
private fun BalanceState.getImage() = when (type) {
BalanceType.UNSTAKING,
BalanceType.UNSTAKED,
BalanceType.LOCKED,
-> null
else -> validator?.image
}
private val textGradientColors = listOf(
TangemColorPalette.White,
Color(0xff8fb4df),
)
@Composable
private fun alignLastToBottom() = remember {
object : Arrangement.Vertical {
override fun Density.arrange(totalSize: Int, sizes: IntArray, outPositions: IntArray) {
var currentOffset = 0
sizes.forEachIndexed { index, size ->
if (index == sizes.lastIndex) {
outPositions[index] = totalSize - size
} else {
outPositions[index] = currentOffset
currentOffset += size
}
}
}
}
}
// region preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun StakingInitialInfoContent_Preview(
@PreviewParameter(StakingInitialInfoContentPreviewProvider::class) feeState: StakingStates.InitialInfoState.Data,
) {
TangemThemePreview {
StakingInitialInfoContent(
state = feeState,
buttonState = NavigationButtonsState.Empty,
clickIntents = StakingClickIntentsStub,
isBalanceHidden = false,
)
}
}
private class StakingInitialInfoContentPreviewProvider : PreviewParameterProvider<StakingStates.InitialInfoState.Data> {
override val values: Sequence<StakingStates.InitialInfoState.Data>
get() = sequenceOf(
InitialStakingStatePreview.defaultState,
InitialStakingStatePreview.stateWithYield,
)
}
// endregion

View file

@ -0,0 +1,182 @@
package com.tangem.features.staking.impl.presentation.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.common.ui.amountScreen.AmountScreenContent
import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig
import com.tangem.common.ui.navigationButtons.NavigationButtonsBlock
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
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.bottomsheet.StakingActionSelectionBottomSheetConfig
import com.tangem.features.staking.impl.presentation.state.bottomsheet.StakingInfoBottomSheetConfig
import com.tangem.features.staking.impl.presentation.ui.bottomsheet.StakingActionSelectorBottomSheet
import com.tangem.features.staking.impl.presentation.ui.bottomsheet.StakingInfoBottomSheet
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.withIndex
@Composable
internal fun StakingScreen(uiState: StakingUiState) {
val snackbarHostState = remember { SnackbarHostState() }
val confirmationState = uiState.confirmationState as? StakingStates.ConfirmationState.Data
BackHandler(onBack = uiState.clickIntents::onPrevClick)
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.secondary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
StakingAppBar(
uiState = uiState,
)
StakingScreenContent(
uiState = uiState,
modifier = Modifier.weight(1f),
)
NavigationButtonsBlock(
buttonState = uiState.buttonsState.takeUnless { uiState.currentStep == StakingStep.InitialInfo }
?: NavigationButtonsState.Empty,
footerText = confirmationState?.footerText.takeIf { uiState.currentStep == StakingStep.Confirmation },
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
)
StakingBottomSheet(bottomSheetConfig = uiState.bottomSheetConfig)
}
StakingEventEffect(
event = uiState.event,
snackbarHostState = snackbarHostState,
)
}
@Composable
fun StakingBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) {
if (bottomSheetConfig == null) return
when (bottomSheetConfig.content) {
is StakingInfoBottomSheetConfig -> StakingInfoBottomSheet(bottomSheetConfig)
is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(bottomSheetConfig)
is StakingActionSelectionBottomSheetConfig -> StakingActionSelectorBottomSheet(bottomSheetConfig)
}
}
@Composable
private fun StakingAppBar(uiState: StakingUiState) {
val (backIcon, click) = when (uiState.currentStep) {
StakingStep.Amount,
StakingStep.Confirmation,
-> R.drawable.ic_close_24 to uiState.clickIntents::onBackClick
StakingStep.Validators,
StakingStep.RewardsValidators,
StakingStep.RestakeValidator,
StakingStep.InitialInfo,
-> R.drawable.ic_back_24 to uiState.clickIntents::onPrevClick
}
AppBarWithBackButtonAndIcon(
text = uiState.title.resolveReference(),
subtitle = uiState.subtitle?.resolveReference(),
backIconRes = backIcon,
onBackClick = click,
backgroundColor = TangemTheme.colors.background.secondary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@Composable
private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = Modifier) {
val currentScreen = uiState.currentStep
var currentStateProxy by remember { mutableStateOf(currentScreen) }
var isTransitionAnimationRunning by remember { mutableStateOf(false) }
// Prevent quick screen changes to avoid some of the transition animation distortions
LaunchedEffect(currentScreen) {
snapshotFlow { isTransitionAnimationRunning }
.withIndex()
.map { (index, running) ->
if (running && index != 0) delay(timeMillis = 200)
running
}
.first { !it }
currentStateProxy = currentScreen
}
// Restrict pressing the back button while screen transition is running to avoid most of the animation distortions
BackHandler(enabled = isTransitionAnimationRunning) {}
// Box is needed to fix animation with resizing of AnimatedContent
Box(modifier = modifier.fillMaxSize()) {
AnimatedContent(
targetState = currentStateProxy,
contentAlignment = Alignment.TopCenter,
label = "Staking Screen Navigation",
transitionSpec = {
val direction = if (initialState.ordinal < targetState.ordinal) {
AnimatedContentTransitionScope.SlideDirection.Start
} else {
AnimatedContentTransitionScope.SlideDirection.End
}
slideIntoContainer(towards = direction, animationSpec = tween())
.togetherWith(slideOutOfContainer(towards = direction, animationSpec = tween()))
},
) { state ->
isTransitionAnimationRunning = transition.targetState != transition.currentState
when (state) {
StakingStep.InitialInfo -> StakingInitialInfoContent(
state = uiState.initialInfoState,
buttonState = uiState.buttonsState,
clickIntents = uiState.clickIntents,
isBalanceHidden = uiState.isBalanceHidden,
)
StakingStep.RewardsValidators -> {
StakingClaimRewardsValidatorContent(
state = uiState.rewardsValidatorsState,
clickIntents = uiState.clickIntents,
)
}
StakingStep.Amount -> AmountScreenContent(
amountState = uiState.amountState,
isBalanceHidden = uiState.isBalanceHidden,
clickIntents = uiState.clickIntents,
modifier = Modifier.background(TangemTheme.colors.background.secondary),
)
StakingStep.Confirmation -> StakingConfirmationContent(
amountState = uiState.amountState,
state = uiState.confirmationState,
validatorState = uiState.validatorState,
clickIntents = uiState.clickIntents,
)
StakingStep.RestakeValidator,
StakingStep.Validators,
-> StakingValidatorListContent(
state = uiState.validatorState,
clickIntents = uiState.clickIntents,
)
}
}
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.features.staking.impl.presentation.ui
import androidx.compose.foundation.text.ClickableText
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import com.tangem.core.ui.extensions.appendColored
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.staking.impl.R
private const val TERMS_OF_USE_KEY = "termsOfUse"
private const val PRIVACY_POLICY_KEY = "privacyPolicy"
private const val TERMS_OF_USE_URL = "https://docs.stakek.it/docs/terms-of-use"
private const val PRIVACY_POLICY_URL = "https://docs.stakek.it/docs/privacy-policy"
@Composable
internal fun StakingTosText(onTextClick: (String) -> Unit) {
val termsOfUse = stringResourceSafe(R.string.common_terms_of_use)
val privacyPolicy = stringResourceSafe(R.string.common_privacy_policy)
val tosText = stringResourceSafe(R.string.staking_legal, termsOfUse, privacyPolicy)
val clickableAnnotation = buildAnnotatedString {
append(tosText.substringBefore(termsOfUse))
pushStringAnnotation(TERMS_OF_USE_KEY, "")
appendColored(termsOfUse, TangemTheme.colors.text.accent)
pop()
append(tosText.substringAfter(termsOfUse).substringBefore(privacyPolicy))
pushStringAnnotation(PRIVACY_POLICY_KEY, "")
appendColored(privacyPolicy, TangemTheme.colors.text.accent)
pop()
}
ClickableText(
text = clickableAnnotation,
style = TangemTheme.typography.caption2.copy(
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
),
onClick = { offset ->
clickableAnnotation.getStringAnnotations(
tag = TERMS_OF_USE_KEY,
start = offset,
end = offset,
).firstOrNull()?.let {
onTextClick(TERMS_OF_USE_URL)
}
clickableAnnotation.getStringAnnotations(
tag = PRIVACY_POLICY_KEY,
start = offset,
end = offset,
).firstOrNull()?.let {
onTextClick(PRIVACY_POLICY_URL)
}
},
)
}

View file

@ -0,0 +1,148 @@
package com.tangem.features.staking.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
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.inputrow.InputRowImageSelector
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.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.previewdata.ValidatorStatePreviewData
import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.utils.extensions.orZero
/**
* Staking screen with validators
*/
@Composable
internal fun StakingValidatorListContent(
state: StakingStates.ValidatorState,
clickIntents: StakingClickIntents,
modifier: Modifier = Modifier,
) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
LazyColumn(
contentPadding = PaddingValues(bottom = bottomBarHeight),
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
if (state is StakingStates.ValidatorState.Data) {
val validators = state.availableValidators
items(
count = validators.size,
key = { validators[it].address },
contentType = { validators[it]::class.java },
) { index ->
val item = validators[index]
InputRowImageSelector(
subtitle = stringReference(item.name),
caption = combinedReference(
resourceReference(R.string.staking_details_annual_percentage_rate),
annotatedReference {
appendSpace()
appendColored(
text = item.apr.orZero().format { percent() },
color = TangemTheme.colors.text.accent,
)
},
),
imageUrl = item.image.orEmpty(),
isSelected = item == state.chosenValidator,
onSelect = { clickIntents.onValidatorSelect(item) },
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = validators.lastIndex,
radius = TangemTheme.dimens.radius12,
addDefaultPadding =
false,
)
.background(TangemTheme.colors.background.action),
subtitleExtraContent = {
ValidatorLabel(item.isStrategicPartner)
},
selectorContent = { checked, _, _ ->
AnimatedVisibility(
modifier = Modifier,
visible = checked,
) {
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(id = R.drawable.ic_check_24),
),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
}
},
onImageError = { ValidatorImagePlaceholder() },
)
}
}
}
}
@Composable
private fun RowScope.ValidatorLabel(isStrategicPartner: Boolean) {
if (isStrategicPartner) {
Text(
text = stringResourceSafe(R.string.staking_validators_label),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.constant,
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(horizontal = 6.dp)
.clip(RoundedCornerShape(6.dp))
.background(TangemTheme.colors.text.accent)
.padding(horizontal = 8.dp),
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun StakingValidatorListContent_Preview(
@PreviewParameter(StakingValidatorListContentPreviewProvider::class)
data: StakingStates.ValidatorState,
) {
TangemThemePreview {
StakingValidatorListContent(
state = data,
clickIntents = StakingClickIntentsStub,
)
}
}
private class StakingValidatorListContentPreviewProvider : PreviewParameterProvider<StakingStates.ValidatorState> {
override val values: Sequence<StakingStates.ValidatorState>
get() = sequenceOf(ValidatorStatePreviewData.validatorState)
}
// endregion

View file

@ -0,0 +1,25 @@
package com.tangem.features.staking.impl.presentation.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.staking.impl.R
@Composable
internal fun ValidatorImagePlaceholder() {
Icon(
painter = rememberVectorPainter(ImageVector.vectorResource(R.drawable.ic_staking_filled_18)),
contentDescription = null,
tint = TangemTheme.colors.icon.inactive,
modifier = Modifier
.background(TangemTheme.colors.icon.primary1, CircleShape)
.padding(TangemTheme.dimens.size9),
)
}

View file

@ -0,0 +1,54 @@
package com.tangem.features.staking.impl.presentation.ui.block
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.CardWithIcon
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.staking.impl.presentation.state.StakingNotification
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun NotificationsBlock(notifications: ImmutableList<NotificationUM>) {
notifications.forEach { notification ->
key(notification) {
if (notification is StakingNotification.Warning.TransactionInProgress) {
CardWithIcon(
title = notification.title.resolveReference(),
description = notification.description.resolveReference(),
icon = {
CircularProgressIndicator(
modifier = Modifier.size(TangemTheme.dimens.size16),
color = TangemTheme.colors.icon.primary1,
strokeWidth = TangemTheme.dimens.size2,
)
},
)
} else {
Notification(
config = notification.config,
iconTint = when (notification) {
is StakingNotification.Info,
is NotificationUM.Info,
-> TangemTheme.colors.icon.accent
is StakingNotification.Warning,
is NotificationUM.Error.TokenExceedsBalance,
is NotificationUM.Error.ExceedsBalance,
is NotificationUM.Warning,
-> null
is StakingNotification.Error,
is NotificationUM.Error,
-> TangemTheme.colors.icon.warning
},
)
}
}
}
}

View file

@ -0,0 +1,181 @@
package com.tangem.features.staking.impl.presentation.ui.block
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.R
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.rows.SelectorRowItem
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.features.staking.impl.presentation.state.FeeState
import java.math.BigDecimal
@Composable
internal fun StakingFeeBlock(feeState: FeeState, isTransactionSent: Boolean) {
val backgroundColor = if (isTransactionSent) {
TangemTheme.colors.background.action
} else {
TangemTheme.colors.button.disabled
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(backgroundColor)
.padding(TangemTheme.dimens.spacing12),
) {
Text(
text = stringResourceSafe(R.string.common_network_fee_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) {
when (feeState) {
is FeeState.Content -> {
val feeAmount = feeState.fee?.amount
SelectorRowItem(
titleRes = R.string.common_fee_selector_option_market,
iconRes = R.drawable.ic_bird_24,
preDot = stringReference(
feeAmount?.value.format {
crypto(
symbol = feeAmount?.currencySymbol.orEmpty(),
decimals = feeAmount?.decimals ?: 0,
).fee(canBeLower = feeState.isFeeApproximate)
},
),
postDot = if (feeState.isFeeConvertibleToFiat) {
getFiatReference(feeAmount?.value, feeState.rate, feeState.appCurrency)
} else {
null
},
ellipsizeOffset = feeAmount?.currencySymbol?.length,
isSelected = true,
showDivider = false,
showSelectedAppearance = false,
paddingValues = PaddingValues(),
)
}
is FeeState.Loading -> {
SelectorRowItem(
titleRes = R.string.common_fee_selector_option_market,
iconRes = R.drawable.ic_bird_24,
isSelected = true,
paddingValues = PaddingValues(),
showDivider = false,
)
FeeLoading(feeState)
}
is FeeState.Error -> {
SelectorRowItem(
titleRes = R.string.common_fee_selector_option_market,
iconRes = R.drawable.ic_bird_24,
isSelected = true,
paddingValues = PaddingValues(),
)
FeeError(feeState)
}
}
}
}
}
@Composable
private fun BoxScope.FeeLoading(feeState: FeeState) {
AnimatedContent(
targetState = feeState,
label = "Fee Loading State Change",
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (it == FeeState.Loading) {
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier.size(
height = TangemTheme.dimens.size24,
width = TangemTheme.dimens.size90,
),
)
}
}
}
@Composable
private fun BoxScope.FeeError(feeState: FeeState) {
AnimatedContent(
targetState = feeState,
label = "Fee Error State Change",
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (it == FeeState.Error) {
Text(
text = BigDecimalFormatter.EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body1,
)
}
}
}
// region Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) value: FeeState) {
TangemThemePreview {
StakingFeeBlock(
feeState = value,
isTransactionSent = false,
)
}
}
private class FeeBlockPreviewProvider : PreviewParameterProvider<FeeState> {
override val values: Sequence<FeeState>
get() = sequenceOf(
contentState,
FeeState.Loading,
FeeState.Error,
)
private val fee = Fee.Common(
amount = Amount(
currencySymbol = "MATIC",
value = BigDecimal(0.159806),
decimals = 18,
type = AmountType.Coin,
),
)
private val contentState = FeeState.Content(
fee = fee,
rate = BigDecimal.ONE,
appCurrency = AppCurrency.Default,
isFeeApproximate = false,
isFeeConvertibleToFiat = true,
)
}
// endregion

View file

@ -0,0 +1,55 @@
package com.tangem.features.staking.impl.presentation.ui.block
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import com.tangem.core.ui.components.inputrow.InputRowImageInfo
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.res.TangemTheme
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.ui.ValidatorImagePlaceholder
import com.tangem.utils.extensions.orZero
@Composable
internal fun ValidatorBlock(validatorState: StakingStates.ValidatorState, isClickable: Boolean, onClick: () -> Unit) {
val state = validatorState as? StakingStates.ValidatorState.Data ?: return
if (!state.isVisibleOnConfirmation) return
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(
enabled = state.isClickable && isClickable,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
),
) {
InputRowImageInfo(
title = resourceReference(R.string.staking_validator),
subtitle = stringReference(state.chosenValidator.name),
infoTitle = annotatedReference {
append(resourceReference(R.string.staking_details_apr).resolveReference())
appendSpace()
appendColored(
text = state.chosenValidator.apr.orZero().format { percent() },
color = TangemTheme.colors.text.accent,
)
},
imageUrl = state.chosenValidator.image.orEmpty(),
onImageError = { ValidatorImagePlaceholder() },
)
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.staking.impl.presentation.ui.bottomsheet
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle
import com.tangem.core.ui.components.inputrow.InputRowDefault
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.bottomsheet.StakingActionSelectionBottomSheetConfig
import com.tangem.features.staking.impl.presentation.state.utils.getPendingActionTitle
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun StakingActionSelectorBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<StakingActionSelectionBottomSheetConfig>(
config = config,
title = { content ->
TangemBottomSheetTitle(title = content.title)
},
containerColor = TangemTheme.colors.background.tertiary,
) { content ->
Column(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing32,
)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
) {
content.actions.forEachIndexed { index, action ->
InputRowDefault(
text = action.type.getPendingActionTitle(),
textColor = TangemTheme.colors.text.primary1,
showDivider = index != content.actions.lastIndex,
modifier = Modifier
.fillMaxWidth()
.clickable {
content.onActionSelect(action)
},
)
}
}
}
}
// region Preview
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_StakingActionSelectorBottomSheet() {
TangemThemePreview {
StakingActionSelectorBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = StakingActionSelectionBottomSheetConfig(
title = resourceReference(R.string.common_select_action),
actions = persistentListOf(
PendingAction(
type = StakingActionType.CLAIM_REWARDS,
passthrough = "",
args = null,
),
PendingAction(
type = StakingActionType.RESTAKE_REWARDS,
passthrough = "",
args = null,
),
),
onActionSelect = {},
),
),
)
}
}
// endregion Preview

View file

@ -0,0 +1,74 @@
package com.tangem.features.staking.impl.presentation.ui.bottomsheet
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.staking.impl.presentation.state.bottomsheet.StakingInfoBottomSheetConfig
@Composable
fun StakingInfoBottomSheet(config: TangemBottomSheetConfig) {
val scrollState = rememberScrollState()
TangemBottomSheet<StakingInfoBottomSheetConfig>(
config = config,
title = { content ->
TangemBottomSheetTitle(title = content.title)
},
) { content ->
Column(
modifier = Modifier.verticalScroll(scrollState),
) {
Text(
text = content.text.resolveReference(),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
modifier = Modifier.padding(
horizontal = TangemTheme.dimens.spacing28,
vertical = TangemTheme.dimens.spacing16,
),
)
}
}
}
// region Preview
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_StakingInfoBottomSheet() {
TangemThemePreview {
StakingInfoBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = StakingInfoBottomSheetConfig(
title = stringReference("Title"),
text = stringReference(
"""
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce varius neque vel ligula
tincidunt, nec faucibus nulla ultricies. Maecenas euismod arcu in nunc volutpat,
at bibendum eros lacinia. Proin hendrerit massa non velit congue,
in volutpat nisi consequat. Sed vitae justo nec orci tincidunt malesuada.
Nullam feugiat purus vel lectus efficitur, vel fringilla urna volutpat.
Donec sagittis enim in metus lacinia, vel tempor nunc bibendum.
""".trimIndent(),
),
),
),
)
}
}
// endregion Preview