Updated on 2026-08-14

This commit is contained in:
Tangem 2024-09-08 11:16:31 +05:00
parent db9990920e
commit 4dc4053869
12 changed files with 231 additions and 127 deletions

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.text.TextStyle
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 androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
@ -95,8 +96,7 @@ enum class CornersToRound {
@Suppress("TopLevelComposableFunctions")
@Composable
fun getShape(): RoundedCornerShape {
val radius = TangemTheme.dimens.radius12
fun getShape(radius: Dp = TangemTheme.dimens.radius12): RoundedCornerShape {
return when (this) {
ALL_4 -> RoundedCornerShape(radius)
TOP_2 -> RoundedCornerShape(topStart = radius, topEnd = radius)

View file

@ -21,7 +21,7 @@ import javax.inject.Singleton
@Singleton
internal class StakingStateController @Inject constructor(
private val urlOpener: UrlOpener,
urlOpener: UrlOpener,
) {
val value: StakingUiState get() = uiState.value

View file

@ -8,6 +8,7 @@ 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.pullToRefresh.PullToRefreshConfig
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType
@ -62,6 +63,7 @@ internal sealed class StakingStates {
val aprRange: TextReference,
val onInfoClick: (InfoType) -> Unit,
val yieldBalance: InnerYieldBalanceState,
val pullToRefreshConfig: PullToRefreshConfig,
) : InitialInfoState()
data class Empty(

View file

@ -3,6 +3,7 @@ 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.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
@ -57,6 +58,7 @@ internal object InitialStakingStatePreview {
),
onInfoClick = {},
yieldBalance = InnerYieldBalanceState.Empty,
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}),
)
val stateWithYield = defaultState.copy(

View file

@ -21,6 +21,8 @@ internal object StakingClickIntentsStub : StakingClickIntents {
override fun onPrevClick() {}
override fun onRefreshSwipe(isRefreshing: Boolean) {}
override fun onInitialInfoBannerClick() {}
override fun onInfoClick(infoType: InfoType) {}
@ -59,7 +61,8 @@ internal object StakingClickIntentsStub : StakingClickIntents {
reduceAmountBy: BigDecimal,
reduceAmountByDiff: BigDecimal,
notification: Class<out NotificationUM>,
) {}
) {
}
override fun onAmountReduceToClick(reduceAmountTo: BigDecimal, notification: Class<out NotificationUM>) {}

View file

@ -6,6 +6,7 @@ import com.tangem.common.ui.amountScreen.models.AmountState
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.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.core.serialization.SerializedBigDecimal
@ -14,10 +15,6 @@ 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.StakingStates
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.features.staking.impl.presentation.state.TransactionDoneState
import com.tangem.features.staking.impl.presentation.state.ValidatorState
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
@ -88,6 +85,10 @@ internal class SetInitialDataStateTransformer(
infoItems = getInfoItems(),
onInfoClick = clickIntents::onInfoClick,
yieldBalance = yieldBalance,
pullToRefreshConfig = PullToRefreshConfig(
onRefresh = { clickIntents.onRefreshSwipe(it.value) },
isRefreshing = false,
),
)
}

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

@ -8,11 +8,16 @@ 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.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -35,7 +40,9 @@ 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.components.rows.CornersToRound
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -52,14 +59,13 @@ import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickInten
import com.tangem.utils.StringsSigns.DOT
import com.tangem.utils.StringsSigns.PLUS
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
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"
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
@Composable
internal fun StakingInitialInfoContent(
state: StakingStates.InitialInfoState,
@ -69,60 +75,116 @@ internal fun StakingInitialInfoContent(
) {
if (state !is StakingStates.InitialInfoState.Data) return
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.animateItemPlacement(),
) {
BannerBlock(onClick = clickIntents::onInitialInfoBannerClick)
SpacerH12()
val pullRefreshState = rememberPullRefreshState(
refreshing = state.pullToRefreshConfig.isRefreshing,
onRefresh = { state.pullToRefreshConfig.onRefresh(PullToRefreshConfig.ShowRefreshState()) },
)
Box(modifier = Modifier.pullRefresh(pullRefreshState)) {
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.animateItemPlacement(),
) {
BannerBlock(onClick = clickIntents::onInitialInfoBannerClick)
SpacerH12()
}
}
}
this.roundedListWithDividersItems(
rows = state.infoItems,
footerContent = { SpacerH12() },
hideEndText = isBalanceHidden,
)
if (state.yieldBalance is InnerYieldBalanceState.Data) {
item(key = STAKING_REWARD_BLOCK_KEY) {
Column(modifier = Modifier.animateItemPlacement()) {
StakingRewardBlock(
rewardCrypto = state.yieldBalance.rewardsCrypto,
rewardFiat = state.yieldBalance.rewardsFiat,
rewardBlockType = state.yieldBalance.rewardBlockType,
onRewardsClick = clickIntents::openRewardsValidators,
isBalanceHidden = isBalanceHidden,
)
SpacerH12()
}
}
}
activeStakingBlock(
state = state,
clickIntents = clickIntents,
isBalanceHidden = isBalanceHidden,
)
item(STAKE_PRIMARY_BUTTON_KEY) {
SpacerH12()
StakeButtonBlock(buttonState)
}
}
this.roundedListWithDividersItems(
rows = state.infoItems,
footerContent = { SpacerH12() },
hideEndText = isBalanceHidden,
PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter),
refreshing = state.pullToRefreshConfig.isRefreshing,
state = pullRefreshState,
)
}
}
if (state.yieldBalance is InnerYieldBalanceState.Data) {
item(key = STAKING_REWARD_BLOCK_KEY) {
Column(modifier = Modifier.animateItemPlacement()) {
StakingRewardBlock(
rewardCrypto = state.yieldBalance.rewardsCrypto,
rewardFiat = state.yieldBalance.rewardsFiat,
rewardBlockType = state.yieldBalance.rewardBlockType,
onRewardsClick = clickIntents::openRewardsValidators,
isBalanceHidden = isBalanceHidden,
)
SpacerH12()
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.activeStakingBlock(
state: StakingStates.InitialInfoState.Data,
clickIntents: StakingClickIntents,
isBalanceHidden: Boolean,
) {
if (state.yieldBalance is InnerYieldBalanceState.Data) {
item(ACTIVE_STAKING_BLOCK_KEY) {
Text(
text = stringResource(id = R.string.staking_your_stakes),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.clip(CornersToRound.TOP_2.getShape())
.background(TangemTheme.colors.background.action)
.padding(
top = TangemTheme.dimens.spacing12,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing4,
),
)
}
if (state.yieldBalance is InnerYieldBalanceState.Data) {
item(key = ACTIVE_STAKING_BLOCK_KEY) {
Column(modifier = Modifier.animateItemPlacement()) {
ActiveStakingBlock(
balances = state.yieldBalance.balance,
onClick = clickIntents::onActiveStake,
isBalanceHidden = isBalanceHidden,
)
SpacerH12()
}
}
}
item(STAKE_PRIMARY_BUTTON_KEY) {
StakeButtonBlock(buttonState)
items(
items = state.yieldBalance.balance,
key = {
// Staked balance does not have unique identifier.
// Balance type and group id is used to identify item.
it.id + it.type
},
) { balance ->
ActiveStakingBlock(
balance = balance,
onClick = clickIntents::onActiveStake,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItemPlacement()
.then(
if (state.yieldBalance.balance.last() == balance) {
Modifier.clip(CornersToRound.BOTTOM_2.getShape())
} else {
Modifier
},
),
)
}
}
}
@ -205,49 +267,29 @@ private fun StakingRewardBlock(
@Composable
private fun ActiveStakingBlock(
balances: ImmutableList<BalanceState>,
balance: BalanceState,
onClick: (BalanceState) -> Unit,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
) {
Text(
text = stringResource(id = R.string.staking_your_stakes),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(
top = TangemTheme.dimens.spacing12,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing4,
val (icon, iconTint) = balance.type.getIcon()
InputRowImageInfo(
subtitle = balance.title,
caption = balance.subtitle ?: balance.getAprText(),
infoTitle = balance.fiatAmount.orMaskWithStars(isBalanceHidden),
infoSubtitle = balance.cryptoAmount.orMaskWithStars(isBalanceHidden),
imageUrl = balance.getImage(),
iconRes = icon,
iconTint = iconTint,
modifier = modifier
.background(TangemTheme.colors.background.action)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(),
enabled = balance.isClickable,
onClick = { onClick(balance) },
),
)
balances.forEach { balance ->
key(balance.id) {
val (icon, iconTint) = balance.type.getIcon()
InputRowImageInfo(
subtitle = balance.title,
caption = balance.subtitle ?: balance.getAprText(),
isGrayscaleImage = !balance.isClickable,
infoTitle = balance.fiatAmount.orMaskWithStars(isBalanceHidden),
infoSubtitle = balance.cryptoAmount.orMaskWithStars(isBalanceHidden),
imageUrl = balance.getImage(),
iconRes = icon,
iconTint = iconTint,
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(),
enabled = balance.isClickable,
onClick = { onClick(balance) },
),
)
}
}
}
)
}
@Composable

View file

@ -21,6 +21,8 @@ internal interface StakingClickIntents : AmountScreenClickIntents {
fun onPrevClick()
fun onRefreshSwipe(isRefreshing: Boolean)
fun onInitialInfoBannerClick()
fun onInfoClick(infoType: InfoType)

View file

@ -76,7 +76,7 @@ internal class StakingViewModel @Inject constructor(
private val stateController: StakingStateController,
private val dispatchers: CoroutineDispatcherProvider,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
@ -149,6 +149,7 @@ internal class StakingViewModel @Inject constructor(
private val transactionsInProgress: CopyOnWriteArrayList<StakingTransaction> = CopyOnWriteArrayList()
private var approvalJobHolder: JobHolder = JobHolder()
private var updateBalancesJobHolder: JobHolder = JobHolder()
init {
subscribeOnSelectedAppCurrency()
@ -156,6 +157,12 @@ internal class StakingViewModel @Inject constructor(
subscribeOnCurrencyStatusUpdates()
}
override fun onCleared() {
super.onCleared()
updateBalancesJobHolder.cancel()
approvalJobHolder.cancel()
}
override fun onBackClick() {
stakingStateRouter.onBackClick()
}
@ -173,7 +180,10 @@ internal class StakingViewModel @Inject constructor(
}
stakingStateRouter.onNextClick()
when {
isInitState() -> stateController.update(SetConfirmationStateLoadingTransformer(yield, appCurrency))
isInitState() -> {
stateController.update(SetConfirmationStateLoadingTransformer(yield, appCurrency))
onRefreshSwipe(isRefreshing = false)
}
isAssentState() -> getFee(pendingAction)
}
}
@ -364,6 +374,19 @@ internal class StakingViewModel @Inject constructor(
stakingStateRouter.onPrevClick()
}
override fun onRefreshSwipe(isRefreshing: Boolean) {
stateController.update(SetInitialLoadingStateTransformer(isRefreshing))
viewModelScope.launch {
val balancesUpdate = async { updateStakeBalance() }
val networkUpdate = async { updateNetworkStatuses(0) }
awaitAll(balancesUpdate, networkUpdate)
}.saveIn(updateBalancesJobHolder)
.invokeOnCompletion {
stateController.update(SetInitialLoadingStateTransformer(false))
}
}
override fun onInitialInfoBannerClick() {
innerRouter.openUrl(WHAT_IS_STAKING_ARTICLE_URL)
}
@ -711,33 +734,42 @@ internal class StakingViewModel @Inject constructor(
stateController.update(SetConfirmationStateResetAssentTransformer)
},
)
getCryptoCurrencyStatusSyncUseCase(userWalletId, cryptoCurrencyId).fold(
ifRight = {
feeCryptoCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase(userWalletId, it).getOrNull()
val isAnyTokenStaked = isAnyTokenStakedUseCase(userWalletId).getOrNull() ?: false
cryptoCurrencyStatus = it
getCurrencyStatusUpdatesUseCase(userWalletId, cryptoCurrencyId, false)
.conflate()
.distinctUntilChanged()
.filter { value.currentStep == StakingStep.InitialInfo }
.onEach { maybeStatus ->
maybeStatus.fold(
ifRight = { status ->
feeCryptoCurrencyStatus =
getFeePaidCryptoCurrencyStatusSyncUseCase(userWalletId, status).getOrNull()
cryptoCurrencyStatus = status
val isAnyTokenStaked = isAnyTokenStakedUseCase(userWalletId).getOrNull() ?: false
setupApprovalNeeded()
checkIfSubtractAvailable()
setupApprovalNeeded()
checkIfSubtractAvailable()
stateController.update(
transformer = SetInitialDataStateTransformer(
clickIntents = this@StakingViewModel,
yield = yield,
isApprovalNeeded = stakingApproval is StakingApproval.Needed,
isAnyTokenStaked = isAnyTokenStaked,
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
userWalletProvider = Provider { userWallet },
appCurrencyProvider = Provider { appCurrency },
),
stateController.update(
transformer = SetInitialDataStateTransformer(
clickIntents = this@StakingViewModel,
yield = yield,
isAnyTokenStaked = isAnyTokenStaked,
isApprovalNeeded = stakingApproval is StakingApproval.Needed,
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
userWalletProvider = Provider { userWallet },
appCurrencyProvider = Provider { appCurrency },
),
)
},
ifLeft = { error ->
Timber.e(error.toString())
stakingEventFactory.createGenericErrorAlert(error.toString())
stateController.update(SetConfirmationStateResetAssentTransformer)
},
)
},
ifLeft = {
Timber.e(it.toString())
stakingEventFactory.createGenericErrorAlert(it.toString())
stateController.update(SetConfirmationStateResetAssentTransformer)
},
)
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
}
}
@ -850,11 +882,11 @@ internal class StakingViewModel @Inject constructor(
}
}
private suspend fun updateNetworkStatuses() {
private suspend fun updateNetworkStatuses(delay: Long = BALANCE_UPDATE_DELAY) {
updateDelayedNetworkStatusUseCase(
userWalletId = userWalletId,
network = cryptoCurrencyStatus.currency.network,
delayMillis = BALANCE_UPDATE_DELAY,
delayMillis = delay,
refresh = true,
)
}

View file

@ -139,6 +139,6 @@ internal class TokenDetailsSkeletonStateConverter(
private fun createPullToRefresh(): PullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
onRefresh = { clickIntents.onRefreshSwipe(it.value) } ,
onRefresh = { clickIntents.onRefreshSwipe(it.value) },
)
}

View file

@ -81,7 +81,7 @@ internal fun TokenDetailsScreen(state: TokenDetailsState, tokenMarketBlockCompon
) { scaffoldPaddings ->
val pullRefreshState = rememberPullRefreshState(
refreshing = state.pullToRefreshConfig.isRefreshing,
onRefresh = { state.pullToRefreshConfig.onRefresh(PullToRefreshConfig.ShowRefreshState()) } ,
onRefresh = { state.pullToRefreshConfig.onRefresh(PullToRefreshConfig.ShowRefreshState()) },
)
val txHistoryItems = if (state.txHistoryState is TxHistoryState.Content) {