Updated on 2026-08-14

This commit is contained in:
Tangem 2024-06-13 17:08:24 +03:00
parent 6446a08369
commit 820379bb78
36 changed files with 765 additions and 181 deletions

View file

@ -13,6 +13,7 @@ import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.features.staking.api.navigation.StakingRouter
import com.tangem.features.staking.impl.navigation.InnerStakingRouter
import com.tangem.features.staking.impl.presentation.state.StakingStateRouter
import com.tangem.features.staking.impl.presentation.ui.StakingScreen
import com.tangem.features.staking.impl.presentation.viewmodel.StakingViewModel
import dagger.hilt.android.AndroidEntryPoint
import java.lang.ref.WeakReference
@ -57,8 +58,8 @@ internal class StakingFragment : ComposeFragment() {
SystemBarsEffect {
setSystemBarsColor(systemBarsColor)
}
val currentState = viewModel.stakingStateRouter.currentState.collectAsStateWithLifecycle()
// StakingScreen(viewModel.uiState, currentState.value)
val currentState = viewModel.uiState.collectAsStateWithLifecycle()
StakingScreen(currentState.value)
}
override fun onDestroy() {

View file

@ -1,8 +1,8 @@
package com.tangem.features.staking.impl.presentation.state
import com.tangem.core.ui.event.consumedEvent
import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub
import com.tangem.features.staking.impl.presentation.state.transformers.StakingScreenStateTransformer
import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -33,7 +33,7 @@ internal class StakingStateController @Inject constructor() {
private fun getInitialState(): StakingUiState {
return StakingUiState(
clickIntents = object : StakingClickIntents {},
clickIntents = StakingClickIntentsStub,
cryptoCurrencyName = "",
currentScreen = StakingUiStateType.InitialInfo,
initialInfoState = StakingStates.InitialInfoState.Empty(),

View file

@ -2,6 +2,7 @@ package com.tangem.features.staking.impl.presentation.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents
/**
@ -41,6 +42,14 @@ internal sealed class StakingStates {
sealed class InitialInfoState : StakingStates() {
data class Data(
override val isPrimaryButtonEnabled: Boolean,
val available: String,
val onStake: String,
val aprRange: TextReference,
val unbondingPeriod: String,
val minimumRequirement: String,
val rewardClaiming: String,
val warmupPeriod: String,
val rewardSchedule: String,
) : InitialInfoState()
data class Empty(

View file

@ -0,0 +1,8 @@
package com.tangem.features.staking.impl.presentation.state.stub
import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents
object StakingClickIntentsStub : StakingClickIntents {
override fun onBackClick() {}
}

View file

@ -0,0 +1,77 @@
package com.tangem.features.staking.impl.presentation.state.transformers
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.staking.model.Yield
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.staking.impl.R
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.StakingUiStateType
import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents
import com.tangem.utils.Provider
import java.math.BigDecimal
internal class SetInitialDataStateTransformer(
private val clickIntents: StakingClickIntents,
private val yield: Yield,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) : StakingScreenStateTransformer {
override fun transform(prevState: StakingUiState): StakingUiState {
return prevState.copy(
clickIntents = clickIntents,
currentScreen = StakingUiStateType.InitialInfo,
initialInfoState = createInitialInfoState(),
)
}
private fun createInitialInfoState(): StakingStates.InitialInfoState.Data {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
return StakingStates.InitialInfoState.Data(
isPrimaryButtonEnabled = true,
available = BigDecimalFormatter.formatCryptoAmount(
cryptoCurrencyStatus.value.amount,
cryptoCurrency = cryptoCurrencyStatus.currency.symbol,
decimals = cryptoCurrencyStatus.currency.decimals,
),
onStake = "0 SOL", // TODO staking add after adding /balances request
aprRange = getAprRange(),
unbondingPeriod = yield.metadata.cooldownPeriod.days.toString(),
minimumRequirement = yield.metadata.minimumStake.toString(),
rewardClaiming = yield.metadata.rewardClaiming,
warmupPeriod = yield.metadata.warmupPeriod.days.toString(),
rewardSchedule = yield.metadata.rewardSchedule,
)
}
private fun getAprRange(): TextReference {
val aprValues = yield.validators.mapNotNull { it.apr }
val minApr = aprValues.min()
val maxApr = aprValues.max()
val formattedMinApr = BigDecimalFormatter.formatPercent(
percent = minApr,
useAbsoluteValue = true,
)
val formattedMaxApr = BigDecimalFormatter.formatPercent(
percent = maxApr,
useAbsoluteValue = true,
)
if (maxApr - minApr < EQUALITY_THRESHOLD) {
return stringReference("$formattedMinApr%")
}
return resourceReference(R.string.common_percent_range, wrappedList(formattedMinApr, formattedMaxApr))
}
companion object {
private val EQUALITY_THRESHOLD = BigDecimal(1E-10)
}
}

View file

@ -0,0 +1,200 @@
package com.tangem.features.staking.impl.presentation.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.PrimaryButton
import com.tangem.core.ui.components.rows.CornersToRound
import com.tangem.core.ui.components.rows.RoundableCornersRow
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.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
@Composable
internal fun StakingInitialInfoContent(state: StakingStates.InitialInfoState) {
if (state !is StakingStates.InitialInfoState.Data) return
Column(
modifier = Modifier // Do not put fillMaxSize() in here
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing16),
) {
MetricsBlock(state)
Spacer(modifier = Modifier.height(8.dp))
StakingDetailsRows(state)
Spacer(modifier = Modifier.height(8.dp))
PrimaryButton(
text = stringResource(id = R.string.common_stake),
modifier = Modifier.fillMaxWidth(),
onClick = {
// TODO staking
},
)
}
}
@Composable
private fun MetricsBlock(state: StakingStates.InitialInfoState.Data) {
Column(
modifier = Modifier
.background(
color = TangemTheme.colors.background.primary,
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
)
.padding(16.dp)
.fillMaxWidth(),
) {
Text(
text = stringResource(id = R.string.staking_details_metrics_block_header),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1F)) {
Text(
text = stringResource(id = R.string.staking_details_apr),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
text = state.aprRange.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.accent,
)
}
Column(modifier = Modifier.weight(1F)) {
Row {
Text(
modifier = Modifier.padding(end = TangemTheme.dimens.spacing4),
text = stringResource(id = R.string.staking_details_market_rating),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.align(Alignment.CenterVertically),
painter = painterResource(id = R.drawable.ic_alert_24),
contentDescription = null,
tint = TangemTheme.colors.text.tertiary,
)
}
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
text = "1", // TODO staking
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.accent,
)
}
}
}
}
@Composable
internal fun StakingDetailsRows(state: StakingStates.InitialInfoState.Data) {
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_available),
endText = state.available,
cornersToRound = CornersToRound.TOP_2,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_on_stake),
endText = state.onStake,
cornersToRound = CornersToRound.ZERO,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_apy),
endText = state.aprRange.resolveReference(),
cornersToRound = CornersToRound.ZERO,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_unbonding_period),
endText = state.unbondingPeriod,
cornersToRound = CornersToRound.ZERO,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_minimum_requirement),
endText = state.minimumRequirement,
cornersToRound = CornersToRound.ZERO,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_reward_claiming),
endText = state.rewardClaiming,
cornersToRound = CornersToRound.ZERO,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_warmup_period),
endText = state.warmupPeriod,
cornersToRound = CornersToRound.ZERO,
)
InitialInfoContentRow(
startText = stringResource(id = R.string.staking_details_reward_schedule),
endText = state.rewardSchedule,
cornersToRound = CornersToRound.BOTTOM_2,
)
}
@Composable
private fun InitialInfoContentRow(startText: String, endText: String, cornersToRound: CornersToRound) {
RoundableCornersRow(
startText = startText,
startTextColor = TangemTheme.colors.text.primary1,
startTextStyle = TangemTheme.typography.body2,
endText = endText,
endTextColor = TangemTheme.colors.text.tertiary,
endTextStyle = TangemTheme.typography.body2,
cornersToRound = cornersToRound,
iconResId = null, // TODO staking add bottom sheets when text will be available
)
}
@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,
)
}
}
private class StakingInitialInfoContentPreviewProvider : PreviewParameterProvider<StakingStates.InitialInfoState.Data> {
override val values: Sequence<StakingStates.InitialInfoState.Data>
get() = sequenceOf(
StakingStates.InitialInfoState.Data(
isPrimaryButtonEnabled = false,
available = "15 SOL",
onStake = "0 SOL",
aprRange = stringReference("2.54-5.12%"),
unbondingPeriod = "3d",
minimumRequirement = "12 SOL",
rewardClaiming = "Auto",
warmupPeriod = "Days",
rewardSchedule = "Block",
),
)
}
// endregion

View file

@ -0,0 +1,125 @@
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.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.features.staking.impl.presentation.state.StakingUiStateType
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) {
BackHandler(onBack = uiState.clickIntents::onBackClick)
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.background(color = TangemTheme.colors.background.tertiary),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SendAppBar(
uiState = uiState,
)
StakingScreenContent(
uiState = uiState,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun SendAppBar(uiState: StakingUiState) {
val titleRes = when (uiState.currentScreen) {
StakingUiStateType.InitialInfo -> stringResource(id = R.string.common_stake)
StakingUiStateType.Amount -> stringResource(id = R.string.send_amount_label)
StakingUiStateType.ValidatorAndFee -> stringResource(id = R.string.common_stake)
StakingUiStateType.Confirm -> ""
}
val backIcon = when (uiState.currentScreen) {
StakingUiStateType.Amount,
StakingUiStateType.ValidatorAndFee,
StakingUiStateType.Confirm,
-> {
R.drawable.ic_close_24
}
StakingUiStateType.InitialInfo -> {
R.drawable.ic_back_24
}
}
AppBarWithBackButtonAndIcon(
text = titleRes,
backIconRes = backIcon,
onBackClick = uiState.clickIntents::onBackClick,
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = Modifier) {
val currentScreen = uiState.currentScreen
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) {
StakingUiStateType.InitialInfo -> StakingInitialInfoContent(
state = uiState.initialInfoState,
)
else -> Unit
}
}
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.staking.impl.presentation.viewmodel
internal interface StakingClickIntents {
// TODO staking
fun onBackClick()
}

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.staking.model.Yield
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -15,6 +16,8 @@ import com.tangem.features.staking.impl.presentation.state.StakingStateControlle
import com.tangem.features.staking.impl.presentation.state.StakingUiState
import com.tangem.features.staking.impl.presentation.state.StakingStateRouter
import com.tangem.features.staking.impl.presentation.state.transformers.HideBalanceStateTransformer
import com.tangem.features.staking.impl.presentation.state.transformers.SetInitialDataStateTransformer
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
@ -37,12 +40,15 @@ internal class StakingViewModel @Inject constructor(
var stakingStateRouter: StakingStateRouter by Delegates.notNull()
private set
private val cryptoCurrency: CryptoCurrency = savedStateHandle[StakingRouter.CRYPTO_CURRENCY_KEY]
?: error("This screen can't open without `CryptoCurrency`")
private val cryptoCurrencyId: CryptoCurrency.ID = savedStateHandle[StakingRouter.CRYPTO_CURRENCY_ID_KEY]
?: error("This screen can't be opened without `CryptoCurrency.ID`")
private val userWalletId: UserWalletId = savedStateHandle.get<String>(StakingRouter.USER_WALLET_ID_KEY)
?.let { stringValue -> UserWalletId(stringValue) }
?: error("This screen can't open without `UserWalletId`")
?: error("This screen can't be opened without `UserWalletId`")
private val yield: Yield = savedStateHandle[StakingRouter.YIELD_KEY]
?: error("This screen can't be opened without `Yield`")
private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
@ -53,16 +59,27 @@ internal class StakingViewModel @Inject constructor(
subscribeOnCurrencyStatusUpdates()
}
fun setRouter(router: InnerStakingRouter, stakingStateRouter: StakingStateRouter) {
override fun onBackClick() {
stakingStateRouter.onBackClick()
}
fun setRouter(router: InnerStakingRouter, stateRouter: StakingStateRouter) {
innerRouter = router
this.stakingStateRouter = stakingStateRouter
this.stakingStateRouter = stateRouter
}
private fun subscribeOnCurrencyStatusUpdates() {
viewModelScope.launch {
getCryptoCurrencyStatusSyncUseCase(userWalletId, cryptoCurrency.id).fold(
getCryptoCurrencyStatusSyncUseCase(userWalletId, cryptoCurrencyId).fold(
ifRight = {
cryptoCurrencyStatus = it
stateController.update(
transformer = SetInitialDataStateTransformer(
clickIntents = this@StakingViewModel,
yield = yield,
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
),
)
},
ifLeft = {
// TODO staking error