Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-07 10:48:16 +03:00
commit 714eb51ce5
34 changed files with 345 additions and 114 deletions

View file

@ -296,6 +296,7 @@ internal class ChildFactory @Inject constructor(
params = TokenDetailsComponent.Params(
userWalletId = route.userWalletId,
currency = route.currency,
navigationAction = route.navigationAction,
),
componentFactory = tokenDetailsComponentFactory,
)

View file

@ -20,6 +20,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.tokens.model.details.NavigationAction
import kotlinx.serialization.Serializable
@SuppressLint("UnsafeOptInUsageError")
@ -55,6 +56,7 @@ sealed class AppRoute(val path: String) : Route {
data class CurrencyDetails(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val navigationAction: NavigationAction? = null,
) : AppRoute(path = "/currency_details/${userWalletId.stringValue}/${currency.id.value}")
@Serializable

View file

@ -48,8 +48,11 @@ class TokenItemStateConverter(
private val iconStateProvider: (CryptoCurrencyStatus) -> CurrencyIconState = {
CryptoCurrencyToIconStateConverter().convert(it)
},
private val onApyLabelClick: ((CryptoCurrencyStatus) -> Unit)? = null,
private val titleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.TitleState = {
createTitleState(it, yieldModuleApyMap, stakingApyMap)
createTitleState(it, yieldModuleApyMap, stakingApyMap, {
onApyLabelClick?.invoke(it)
})
},
private val subtitleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.SubtitleState? = {
createSubtitleState(it, appCurrency)
@ -102,6 +105,9 @@ class TokenItemStateConverter(
onItemLongClick = onItemLongClick?.let { onItemLongClick ->
{ onItemLongClick(it, this) }
},
onApyLabelClick = onApyLabelClick?.let { onApyLabelClick ->
{ onApyLabelClick(this) }
},
)
}
@ -160,6 +166,7 @@ class TokenItemStateConverter(
currencyStatus: CryptoCurrencyStatus,
yieldModuleApyMap: Map<String, String>,
stakingApyMap: Map<String, List<Yield.Validator>>,
onApyLabelClick: () -> Unit,
): TokenItemState.TitleState {
return when (val value = currencyStatus.value) {
is CryptoCurrencyStatus.Loading,
@ -184,6 +191,7 @@ class TokenItemStateConverter(
hasPending = value.hasCurrentNetworkTransactions,
earnApy = earnApyText,
earnApyIsActive = isActive,
onApyLabelClick = onApyLabelClick,
)
}
}

View file

@ -2,7 +2,10 @@ package com.tangem.core.analytics.models.event
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACTION
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.STATE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
/**
@ -108,6 +111,21 @@ sealed class MainScreenAnalyticsEvent(
event = "Hot Token Error",
params = mapOf(ERROR_CODE to errorCode),
)
data class ApyClicked(
val token: String,
val blockchain: String,
val action: String,
val state: String,
) : MainScreenAnalyticsEvent(
event = "APY Clicked",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
ACTION to action,
STATE to state,
),
)
// endregion
companion object {

View file

@ -8,6 +8,7 @@ import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
@ -34,6 +35,7 @@ interface YieldSupplyApi {
@POST("api/v1/module/activate")
suspend fun activateYieldModule(
@Body body: YieldSupplyChangeTokenStatusBody,
@Header("userWalletId") userWalletId: String,
): ApiResponse<YieldModuleStatusResponse>
@POST("api/v1/module/deactivate")

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.components.token.internal
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -60,7 +61,11 @@ private fun ContentTitle(state: TokenTitleState.Content, modifier: Modifier = Mo
YieldSupplyApyLabel(
apy = state.earnApy,
isActive = state.earnApyIsActive,
modifier = Modifier.align(alignment = Alignment.CenterVertically),
modifier = Modifier
.align(alignment = Alignment.CenterVertically)
.clickable {
state.onApyLabelClick?.invoke()
},
)
}
}

View file

@ -40,6 +40,9 @@ sealed class TokenItemState {
/** Callback which will be called when an item is long clicked */
abstract val onItemLongClick: ((TokenItemState) -> Unit)?
/** Callback which will be called when an apy label is clicked */
abstract val onApyLabelClick: ((TokenItemState) -> Unit)?
/**
* Loading token state
*
@ -58,6 +61,7 @@ sealed class TokenItemState {
override val subtitle2State: Subtitle2State = Subtitle2State.Loading
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onItemLongClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
}
/**
@ -73,6 +77,7 @@ sealed class TokenItemState {
override val subtitle2State: Subtitle2State = Subtitle2State.Locked
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onItemLongClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
}
/**
@ -96,6 +101,7 @@ sealed class TokenItemState {
override val subtitle2State: Subtitle2State?,
override val onItemClick: ((TokenItemState) -> Unit)?,
override val onItemLongClick: ((TokenItemState) -> Unit)?,
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null,
) : TokenItemState()
/**
@ -116,6 +122,7 @@ sealed class TokenItemState {
override val fiatAmountState: FiatAmountState? = null
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onItemLongClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
}
/**
@ -133,6 +140,7 @@ sealed class TokenItemState {
override val iconState: CurrencyIconState,
override val titleState: TitleState,
override val subtitleState: SubtitleState? = null,
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null,
override val onItemClick: ((TokenItemState) -> Unit)?,
override val onItemLongClick: ((TokenItemState) -> Unit)?,
) : TokenItemState() {
@ -159,6 +167,7 @@ sealed class TokenItemState {
override val fiatAmountState: FiatAmountState? = null
override val subtitle2State: Subtitle2State? = null
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
}
@Immutable
@ -170,6 +179,7 @@ sealed class TokenItemState {
val isAvailable: Boolean = true,
val earnApy: TextReference? = null,
val earnApyIsActive: Boolean = false,
val onApyLabelClick: (() -> Unit)? = null,
) : TitleState()
data object Loading : TitleState()

View file

@ -76,18 +76,22 @@ internal class DefaultYieldSupplyRepository(
(walletManager as? YieldSupplyProvider)?.isSupported() ?: false
}
override suspend fun activateProtocol(cryptoCurrencyToken: CryptoCurrency.Token, address: String): Boolean =
withContext(dispatchers.io) {
val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId()
?: error("Chain id is required for evm's")
yieldSupplyApi.activateYieldModule(
YieldSupplyChangeTokenStatusBody(
tokenAddress = cryptoCurrencyToken.contractAddress,
chainId = chainId,
userAddress = address,
),
).getOrThrow().isActive
}
override suspend fun activateProtocol(
userWalletId: UserWalletId,
cryptoCurrencyToken: CryptoCurrency.Token,
address: String,
): Boolean = withContext(dispatchers.io) {
val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId()
?: error("Chain id is required for evm's")
yieldSupplyApi.activateYieldModule(
body = YieldSupplyChangeTokenStatusBody(
tokenAddress = cryptoCurrencyToken.contractAddress,
chainId = chainId,
userAddress = address,
),
userWalletId = userWalletId.stringValue,
).getOrThrow().isActive
}
override suspend fun deactivateProtocol(cryptoCurrencyToken: CryptoCurrency.Token, address: String): Boolean =
withContext(dispatchers.io) {
@ -103,14 +107,18 @@ internal class DefaultYieldSupplyRepository(
}
override suspend fun saveTokenProtocolStatus(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
yieldSupplyEnterStatus: YieldSupplyEnterStatus,
) {
statusMap[cryptoCurrency.id.value] = yieldSupplyEnterStatus
statusMap["${userWalletId}_${cryptoCurrency.id.value}"] = yieldSupplyEnterStatus
}
override fun getTokenProtocolStatus(cryptoCurrency: CryptoCurrency): YieldSupplyEnterStatus? {
return statusMap[cryptoCurrency.id.value]
override fun getTokenProtocolStatus(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): YieldSupplyEnterStatus? {
return statusMap["${userWalletId}_${cryptoCurrency.id.value}"]
}
private fun List<YieldMarketToken>.enrichNetworkIds(): List<YieldMarketToken> {

View file

@ -106,6 +106,10 @@ internal class FeedbackDataBuilder {
fun addBlockchainError(info: BlockchainInfo, error: BlockchainErrorInfo) {
builder.appendKeyValue("Blockchain", info.blockchain)
builder.appendAddresses(
key = "Explorer link${info.explorerLinks.isMultiple(suffix = "s")}",
addresses = info.explorerLinks,
)
builder.appendKeyValue("Derivation path", info.derivationPath)
builder.appendKeyValue("Host", info.host)
builder.appendKeyValue("Token", error.tokenSymbol)

View file

@ -0,0 +1,9 @@
package com.tangem.domain.tokens.model.details
import kotlinx.serialization.Serializable
@Serializable
sealed class NavigationAction {
data object Staking : NavigationAction()
data class YieldSupply(val isActive: Boolean) : NavigationAction()
}

View file

@ -0,0 +1,9 @@
package com.tangem.domain.yield.supply.models
import java.math.BigDecimal
data class YieldSupplyMaxFee(
val nativeMaxFee: BigDecimal,
val tokenMaxFee: BigDecimal,
val fiatMaxFee: BigDecimal,
)

View file

@ -46,7 +46,11 @@ interface YieldSupplyRepository {
* May throw on network/backend errors or if required chain id cannot be resolved.
*/
@Throws
suspend fun activateProtocol(cryptoCurrencyToken: CryptoCurrency.Token, address: String): Boolean
suspend fun activateProtocol(
userWalletId: UserWalletId,
cryptoCurrencyToken: CryptoCurrency.Token,
address: String,
): Boolean
/**
* Deactivate yield protocol for the specified token.
@ -59,25 +63,32 @@ interface YieldSupplyRepository {
suspend fun deactivateProtocol(cryptoCurrencyToken: CryptoCurrency.Token, address: String): Boolean
/**
* Save the last user-initiated yield protocol action for the given currency.
* Save the last userinitiated yield protocol action for the given wallet and currency.
*
* The saved value helps the UI render an intermediate "processing" state while waiting
* for the definitive protocol status to be fetched from the network. This information
* is transient and not intended to be persisted across app restarts.
* is transient (inmemory only) and is not persisted across app restarts.
*
* @param userWalletId the wallet the action was performed with
* @param cryptoCurrency the currency or token the action relates to
* @param yieldSupplyEnterStatus the last action intent: [YieldSupplyEnterStatus.Enter] or [YieldSupplyEnterStatus.Exit]
*/
suspend fun saveTokenProtocolStatus(cryptoCurrency: CryptoCurrency, yieldSupplyEnterStatus: YieldSupplyEnterStatus)
suspend fun saveTokenProtocolStatus(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
yieldSupplyEnterStatus: YieldSupplyEnterStatus,
)
/**
* Get the last saved user-initiated yield protocol action for the given currency, if any.
* Get the last saved userinitiated yield protocol action for the given wallet and currency, if any.
*
* Used to determine whether the UI should display an intermediate "processing" state
* until the protocol status retrieved from backend reflects the change.
* until the protocol status retrieved from backend reflects the change. The value is
* transient (inmemory only) and is not persisted across app restarts.
*
* @param userWalletId the wallet to query
* @param cryptoCurrency the currency or token to query
* @return the last action intent or null if nothing has been recorded
*/
fun getTokenProtocolStatus(cryptoCurrency: CryptoCurrency): YieldSupplyEnterStatus?
fun getTokenProtocolStatus(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): YieldSupplyEnterStatus?
}

View file

@ -2,15 +2,23 @@ package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.YieldSupplyRepository
class YieldSupplyActivateUseCase(
private val yieldSupplyRepository: YieldSupplyRepository,
) {
suspend operator fun invoke(cryptoCurrency: CryptoCurrency, address: String): Either<Throwable, Boolean> =
Either.catch {
val token = cryptoCurrency as? CryptoCurrency.Token ?: error("Token expected")
yieldSupplyRepository.activateProtocol(token, address)
}
suspend operator fun invoke(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
address: String,
): Either<Throwable, Boolean> = Either.catch {
val token = cryptoCurrency as? CryptoCurrency.Token ?: error("Token expected")
yieldSupplyRepository.activateProtocol(
userWalletId = userWalletId,
cryptoCurrencyToken = token,
address = address,
)
}
}

View file

@ -10,16 +10,17 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Calculates the max allowed network fee for a Yield Supply transaction.
*
* Result is a Pair<BigDecimal, BigDecimal> where:
* - first: fee in native coin units (maxFeeNative)
* - second: the same fee converted to token units using the fiat-rate ratio
* (nativeFiatRate / tokenFiatRate).
* Returns [Either] of [YieldSupplyMaxFee] with:
* - nativeMaxFee: fee in native coin units
* - tokenMaxFee: fee converted to token units using the native/token rate ratio
* - tokenFiatMaxFee: fee value in fiat
*
* Uses YieldMarketToken.maxFeeNative and the same conversion logic as
* [YieldSupplyGetCurrentFeeUseCase].
@ -33,7 +34,7 @@ class YieldSupplyGetMaxFeeUseCase(
suspend operator fun invoke(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): Either<Throwable, Pair<BigDecimal, BigDecimal>> = catch {
): Either<Throwable, YieldSupplyMaxFee> = catch {
val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token
?: error("CryptoCurrency must be token for max fee calculation")
@ -60,6 +61,7 @@ class YieldSupplyGetMaxFeeUseCase(
.firstOrNull { it.yieldSupplyKey == token.yieldSupplyKey() }
val marketToken = cachedMarketToken ?: yieldSupplyRepository.getTokenStatus(token)
val maxFeeNative = marketToken.maxFeeNative
val maxFeeToken = maxFeeNative.multiply(nativeFiatRate)
val rateRatio = nativeFiatRate.divide(
fiatRate,
@ -69,6 +71,10 @@ class YieldSupplyGetMaxFeeUseCase(
val tokenValue = rateRatio.multiply(maxFeeNative)
maxFeeNative to tokenValue.stripTrailingZeros()
YieldSupplyMaxFee(
nativeMaxFee = maxFeeNative,
tokenMaxFee = maxFeeToken,
fiatMaxFee = tokenValue.stripTrailingZeros(),
)
}
}

View file

@ -4,12 +4,14 @@ import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.details.NavigationAction
interface TokenDetailsComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val navigationAction: NavigationAction? = null,
)
interface Factory : ComponentFactory<Params, TokenDetailsComponent>

View file

@ -16,6 +16,7 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.NavigationBar3ButtonsScrim
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsModel
import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDetailsBottomSheetConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen
@ -76,6 +77,8 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
params = YieldSupplyComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrency = params.currency,
handleNavigation = (params.navigationAction as? NavigationAction.YieldSupply)
?.isActive,
),
)

View file

@ -63,6 +63,7 @@ import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.tokens.model.analytics.*
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.Companion.toReasonAnalyticsText
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent.DetailsScreenOpened.TokenBalance
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.domain.transaction.error.AssociateAssetError
import com.tangem.domain.transaction.error.IncompleteTransactionError
@ -222,6 +223,7 @@ internal class TokenDetailsModel @Inject constructor(
updateContent()
handleBalanceHiding()
checkForActionUpdates()
handleNavigationParam()
}
fun onResume() {
@ -1246,6 +1248,12 @@ internal class TokenDetailsModel @Inject constructor(
}
}
private fun handleNavigationParam() {
if (params.navigationAction is NavigationAction.Staking) {
openStaking()
}
}
private companion object {
const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L
}

View file

@ -3,9 +3,12 @@ package com.tangem.feature.wallet.child.wallet.model.intents
import arrow.core.getOrElse
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
@ -14,6 +17,7 @@ import com.tangem.domain.settings.ShouldShowMarketsTooltipUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.wallet.presentation.account.AccountDependencies
@ -45,6 +49,8 @@ internal interface WalletContentClickIntents {
fun onTokenItemLongClick(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus)
fun onApyLabelClick(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus)
fun onAccountExpandClick(account: Account)
fun onAccountCollapseClick(account: Account)
@ -154,6 +160,42 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
}
}
override fun onApyLabelClick(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus) {
val navigationAction = if (currencyStatus.currency is CryptoCurrency.Token) {
NavigationAction.YieldSupply(currencyStatus.value.yieldSupplyStatus?.isActive == true)
} else {
NavigationAction.Staking
}
val event = when (navigationAction) {
is NavigationAction.YieldSupply -> {
MainScreenAnalyticsEvent.ApyClicked(
token = currencyStatus.currency.symbol,
blockchain = currencyStatus.currency.network.name,
action = "Earning",
state = if (currencyStatus.value.yieldSupplyStatus?.isActive == true) {
"Enabled"
} else {
"Disabled"
},
)
}
is NavigationAction.Staking -> {
MainScreenAnalyticsEvent.ApyClicked(
token = currencyStatus.currency.symbol,
blockchain = currencyStatus.currency.network.name,
action = "Staking",
state = if (currencyStatus.value.yieldBalance is YieldBalance.Data) {
"Enabled"
} else {
"Disabled"
},
)
}
}
analyticsEventHandler.send(event)
router.openTokenDetails(userWalletId, currencyStatus, navigationAction)
}
override fun onAccountExpandClick(account: Account) {
val userWalletId = stateHolder.getSelectedWalletId()
accountDependencies.expandedAccountsHolder.expandAccount(userWalletId, account.accountId)

View file

@ -15,6 +15,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.redux.StateDialog
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.feature.wallet.navigation.WalletRoute
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig
@ -66,13 +67,18 @@ internal class DefaultWalletRouter @Inject constructor(
urlOpener.openUrl(url)
}
override fun openTokenDetails(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus) {
override fun openTokenDetails(
userWalletId: UserWalletId,
currencyStatus: CryptoCurrencyStatus,
navigationAction: NavigationAction?,
) {
val networkAddress = currencyStatus.value.networkAddress
if (networkAddress != null && networkAddress.defaultAddress.value.isNotEmpty()) {
router.push(
AppRoute.CurrencyDetails(
userWalletId = userWalletId,
currency = currencyStatus.currency,
navigationAction = navigationAction,
),
)
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.feature.wallet.navigation.WalletRoute
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig
@ -42,7 +43,11 @@ internal interface InnerWalletRouter {
fun openUrl(url: String)
/** Open token details screen */
fun openTokenDetails(userWalletId: UserWalletId, currencyStatus: CryptoCurrencyStatus)
fun openTokenDetails(
userWalletId: UserWalletId,
currencyStatus: CryptoCurrencyStatus,
navigationAction: NavigationAction? = null,
)
/** Open stories screen */
fun openStoriesScreen()

View file

@ -49,12 +49,17 @@ internal class TokenListStateConverter(
clickIntents.onTokenItemLongClick(selectedWallet.walletId, currencyStatus)
}
private val onApyLabelClick: (currencyStatus: CryptoCurrencyStatus) -> Unit = { currencyStatus ->
clickIntents.onApyLabelClick(selectedWallet.walletId, currencyStatus)
}
private fun tokenStatusConverter(accountId: AccountId? = null) = TokenItemStateConverter(
appCurrency = appCurrency,
yieldModuleApyMap = yieldModuleApyMap,
stakingApyMap = stakingApyMap,
onItemClick = { _, status -> onTokenClick(accountId, status) },
onItemLongClick = { _, status -> onTokenLongClick(accountId, status) },
onApyLabelClick = { status -> onApyLabelClick(status) },
)
override fun convert(value: WalletTokensListState): WalletTokensListState {

View file

@ -10,6 +10,7 @@ interface YieldSupplyComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val handleNavigation: Boolean? = null,
)
interface Factory : ComponentFactory<Params, YieldSupplyComponent>

View file

@ -99,8 +99,15 @@ sealed class YieldSupplyAnalytics(
),
)
data object FundsEarned : YieldSupplyAnalytics(
data class FundsEarned(
val token: String,
val blockchain: String,
) : YieldSupplyAnalytics(
event = "Funds Earned",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
),
)
data class FundsWithdrawn(

View file

@ -13,6 +13,8 @@ internal sealed class YieldSupplyFeeUM {
data class Content(
val transactionDataList: ImmutableList<TransactionData.Uncompiled>,
val feeFiatValue: TextReference,
// TODO move to FeePolicyUM
val estimatedFiatValue: TextReference,
val tokenFeeFiatValue: TextReference,
val maxNetworkFeeFiatValue: TextReference,
val minTopUpFiatValue: TextReference,

View file

@ -149,6 +149,7 @@ private class YieldSupplyActionContentPreviewProvider : PreviewParameterProvider
maxNetworkFeeFiatValue = stringReference("$8.50"),
minTopUpFiatValue = stringReference("$50"),
feeNoteValue = TextReference.EMPTY,
estimatedFiatValue = TextReference.EMPTY,
),
isPrimaryButtonEnabled = false,
isTransactionSending = false,

View file

@ -91,6 +91,16 @@ internal class YieldSupplyModel @Inject constructor(
init {
checkIfYieldSupplyIsAvailable()
params.handleNavigation?.let { handle ->
if (handle) {
modelScope.launch {
delay(timeMillis = 1000)
bottomSheetNavigation.activate(Unit)
}
} else {
onStartEarningClick()
}
}
}
private fun checkIfYieldSupplyIsAvailable() {
@ -185,7 +195,10 @@ internal class YieldSupplyModel @Inject constructor(
@Suppress("MaximumLineLength")
private fun onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus: CryptoCurrencyStatus) {
val yieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus
val tokenProtocolStatus = yieldSupplyRepository.getTokenProtocolStatus(cryptoCurrency)
val tokenProtocolStatus = yieldSupplyRepository.getTokenProtocolStatus(
userWallet.walletId,
cryptoCurrency,
)
val isActive = yieldSupplyStatus?.isActive == true
val isCryptoCurrencyStatusFromCache = cryptoCurrencyStatus.value.sources.networkSource != StatusSource.ACTUAL
val processing = uiState.value is YieldSupplyUM.Processing
@ -298,7 +311,11 @@ internal class YieldSupplyModel @Inject constructor(
val address = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value ?: return
modelScope.launch(dispatchers.default) {
if (cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true) {
yieldSupplyActivateUseCase(token, address).onRight {
yieldSupplyActivateUseCase(
userWalletId = userWallet.walletId,
cryptoCurrency = token,
address = address,
).onRight {
lastYieldSupplyStatus = cryptoCurrencyStatus.value.yieldSupplyStatus
}
} else {

View file

@ -248,7 +248,7 @@ internal class YieldSupplyActiveModel @Inject constructor(
cryptoCurrencyStatus = cryptoStatus,
appCurrency = appCurrency,
feeValue = currentFee,
maxNetworkFee = maxFee.second,
maxNetworkFee = maxFee,
analyticsHandler = analyticsHandler,
),
)

View file

@ -10,6 +10,7 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.subcomponents.active.entity.YieldSupplyActiveContentUM
@ -23,7 +24,7 @@ internal class YieldSupplyActiveFeeContentTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val feeValue: BigDecimal,
private val maxNetworkFee: BigDecimal,
private val maxNetworkFee: YieldSupplyMaxFee,
private val analyticsHandler: AnalyticsEventHandler,
) : Transformer<YieldSupplyActiveContentUM> {
@ -35,9 +36,12 @@ internal class YieldSupplyActiveFeeContentTransformer(
val tokenFiatFee = tokenFiatRate?.let(feeValue::multiply)
val tokenFiatFeeValueText = tokenFiatFee.format { fiat(appCurrency.code, appCurrency.symbol) }
val maxFeeCryptoValueText = maxNetworkFee.format { crypto(cryptoCurrency) }
val maxFiatFee = tokenFiatRate?.let { rate -> maxNetworkFee.multiply(rate) }
val maxFiatFeeValueText = maxFiatFee.format { fiat(appCurrency.code, appCurrency.symbol) }
val maxFeeCryptoValueText = maxNetworkFee.tokenMaxFee.format { crypto(cryptoCurrency) }
val maxFiatFeeValueText = maxNetworkFee.fiatMaxFee.format { fiat(
appCurrency.code,
appCurrency
.symbol,
) }
val feeNoteValue: TextReference = resourceReference(
id = R.string.yield_module_fee_policy_sheet_fee_note,
@ -49,7 +53,7 @@ internal class YieldSupplyActiveFeeContentTransformer(
),
)
val isHighFee = feeValue > maxNetworkFee
val isHighFee = feeValue > maxNetworkFee.tokenMaxFee
if (isHighFee) {
analyticsHandler.send(

View file

@ -255,6 +255,7 @@ internal class YieldSupplyApproveModel @Inject constructor(
maxNetworkFeeFiatValue = TextReference.EMPTY,
minTopUpFiatValue = TextReference.EMPTY,
feeNoteValue = TextReference.EMPTY,
estimatedFiatValue = TextReference.EMPTY,
),
)
}

View file

@ -86,7 +86,7 @@ internal fun YieldSupplyFeePolicyContent(
.padding(horizontal = 16.dp),
) {
val currentFee = when (yieldSupplyFeeUM) {
is YieldSupplyFeeUM.Content -> yieldSupplyFeeUM.tokenFeeFiatValue
is YieldSupplyFeeUM.Content -> yieldSupplyFeeUM.estimatedFiatValue
YieldSupplyFeeUM.Error -> stringReference(StringsSigns.DASH_SIGN)
YieldSupplyFeeUM.Loading -> null
}
@ -184,6 +184,7 @@ private fun YieldSupplyFeePolicyContent_Preview() {
stringReference("2.46 USDT"),
),
),
estimatedFiatValue = stringReference("$8.50"),
),
tokenSymbol = "USDT",
modifier = Modifier.background(TangemTheme.colors.background.primary),

View file

@ -63,6 +63,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor(
private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase,
private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase,
private val yieldSupplyGetMaxFeeUseCase: YieldSupplyGetMaxFeeUseCase,
private val yieldSupplyGetCurrentFeeUseCase: YieldSupplyGetCurrentFeeUseCase,
private val yieldSupplyRepository: YieldSupplyRepository,
) : Model(), YieldSupplyNotificationsComponent.ModelCallback {
@ -130,10 +131,6 @@ internal class YieldSupplyStartEarningModel @Inject constructor(
}
}
private suspend fun getMaxFeePair(): Pair<BigDecimal, BigDecimal>? {
return yieldSupplyGetMaxFeeUseCase(userWallet, cryptoCurrencyStatus).getOrNull()
}
private suspend fun onLoadFee() {
if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.Loading || uiState.value.isTransactionSending) return
@ -141,15 +138,13 @@ internal class YieldSupplyStartEarningModel @Inject constructor(
it.copy(yieldSupplyFeeUM = YieldSupplyFeeUM.Loading)
}
val maxFeePair = getMaxFeePair() ?: return
val maxFee = maxFeePair.first
val maxFeeFiat = maxFeePair.second
val maxFee = yieldSupplyGetMaxFeeUseCase(userWallet, cryptoCurrencyStatus).getOrNull() ?: return
val estimatedFee = yieldSupplyGetCurrentFeeUseCase(userWallet, cryptoCurrencyStatus).getOrNull() ?: return
val transactionListData = yieldSupplyStartEarningUseCase(
userWalletId = userWallet.walletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
maxNetworkFee = maxFee,
maxNetworkFee = maxFee.nativeMaxFee,
).getOrNull()
if (transactionListData == null) {
@ -194,7 +189,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor(
updatedTransactionList = updatedTransactionList,
feeValue = feeSum,
maxNetworkFee = maxFee,
maxNetworkFeeFiat = maxFeeFiat,
estimatedFeeValue = estimatedFee,
minAmount = minAmount,
),
)
@ -249,35 +244,52 @@ internal class YieldSupplyStartEarningModel @Inject constructor(
)
},
ifRight = {
yieldSupplyRepository.saveTokenProtocolStatus(cryptoCurrency, YieldSupplyEnterStatus.Enter)
val event = AnalyticsParam.TxSentFrom.Earning(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
feeType = AnalyticsParam.FeeType.Normal,
)
analytics.send(YieldSupplyAnalytics.FundsEarned)
yieldSupplyFeeUM.transactionDataList.forEach {
analytics.send(
Basic.TransactionSent(
sentFrom = event,
memoType = Basic.TransactionSent.MemoType.Null,
),
)
}
val address = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
if (address != null) {
yieldSupplyActivateUseCase(cryptoCurrency, address)
}
modelScope.launch {
params.callback.onTransactionSent()
}
onStartEarningTransactionSuccess(yieldSupplyFeeUM)
},
)
}
}
private suspend fun onStartEarningTransactionSuccess(yieldSupplyFeeUM: YieldSupplyFeeUM.Content) {
yieldSupplyRepository.saveTokenProtocolStatus(
userWallet.walletId,
cryptoCurrency,
YieldSupplyEnterStatus.Enter,
)
val event = AnalyticsParam.TxSentFrom.Earning(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
feeType = AnalyticsParam.FeeType.Normal,
)
analytics.send(
YieldSupplyAnalytics.FundsEarned(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
),
)
yieldSupplyFeeUM.transactionDataList.forEach {
analytics.send(
Basic.TransactionSent(
sentFrom = event,
memoType = Basic.TransactionSent.MemoType.Null,
),
)
}
val address = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
if (address != null) {
yieldSupplyActivateUseCase(
userWalletId = userWallet.walletId,
cryptoCurrency = cryptoCurrency,
address = address,
)
}
modelScope.launch {
params.callback.onTransactionSent()
}
}
private fun subscribeOnCurrencyStatusUpdates() {
modelScope.launch {
getUserWalletUseCase(params.userWalletId).fold(

View file

@ -9,6 +9,7 @@ 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.models.currency.CryptoCurrencyStatus
import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee
import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
@ -24,8 +25,8 @@ internal class YieldSupplyStartEarningFeeContentTransformer(
private val appCurrency: AppCurrency,
private val updatedTransactionList: List<TransactionData.Uncompiled>,
private val feeValue: BigDecimal,
private val maxNetworkFee: BigDecimal,
private val maxNetworkFeeFiat: BigDecimal,
private val estimatedFeeValue: BigDecimal,
private val maxNetworkFee: YieldSupplyMaxFee,
private val minAmount: BigDecimal,
) : Transformer<YieldSupplyActionUM> {
override fun transform(prevState: YieldSupplyActionUM): YieldSupplyActionUM {
@ -36,15 +37,17 @@ internal class YieldSupplyStartEarningFeeContentTransformer(
val feeFiat = feeFiatRate?.let(feeValue::multiply)
val feeFiatValueText = feeFiat.format { fiat(appCurrency.code, appCurrency.symbol) }
val estimatedFeeFiat = estimatedFeeValue
val estimatedFeeFiatValueText = estimatedFeeFiat.format { fiat(appCurrency.code, appCurrency.symbol) }
val tokenCryptoFee = tokenFiatRate?.let { rate ->
feeFiat?.divide(rate, cryptoCurrency.decimals, RoundingMode.HALF_UP)
estimatedFeeFiat.divide(rate, cryptoCurrency.decimals, RoundingMode.HALF_UP)
}
val tokenCryptoFeeValueText = tokenCryptoFee.format { crypto(cryptoCurrency) }
val tokenFiatFee = feeFiat // same fiat amount
val tokenFiatFeeValueText = tokenFiatFee.format { fiat(appCurrency.code, appCurrency.symbol) }
val tokenFiatFeeValueText = estimatedFeeFiat.format { fiat(appCurrency.code, appCurrency.symbol) }
val maxFeeCryptoValueText = maxNetworkFee.format { crypto(cryptoCurrency) }
val maxFiatFeeValueText = maxNetworkFeeFiat.format { fiat(appCurrency.code, appCurrency.symbol) }
val maxFeeCryptoValueText = maxNetworkFee.tokenMaxFee.format { crypto(cryptoCurrency) }
val maxFiatFeeValueText = maxNetworkFee.fiatMaxFee.format { fiat(appCurrency.code, appCurrency.symbol) }
val minAmountCryptoText = minAmount.format { crypto(cryptoCurrency) }
val minAmountFiat = tokenFiatRate?.let(minAmount::multiply)
@ -80,6 +83,7 @@ internal class YieldSupplyStartEarningFeeContentTransformer(
minTopUpFiatValue = stringReference(minAmountFiatText),
feeNoteValue = feeNoteValue,
minFeeNoteValue = minFeeNoteValue,
estimatedFiatValue = stringReference(estimatedFeeFiatValueText),
),
)
}

View file

@ -165,37 +165,45 @@ internal class YieldSupplyStopEarningModel @Inject constructor(
)
},
ifRight = {
yieldSupplyRepository.saveTokenProtocolStatus(cryptoCurrency, YieldSupplyEnterStatus.Exit)
analytics.send(
YieldSupplyAnalytics.FundsWithdrawn(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
),
)
val event = AnalyticsParam.TxSentFrom.Earning(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
feeType = AnalyticsParam.FeeType.Normal,
)
analytics.send(
Basic.TransactionSent(
sentFrom = event,
memoType = Basic.TransactionSent.MemoType.Null,
),
)
val address = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
if (address != null) {
yieldSupplyDeactivateUseCase(cryptoCurrency, address)
}
modelScope.launch {
params.callback.onTransactionSent()
}
onStopEarningTransactionSuccess()
},
)
}
}
private suspend fun onStopEarningTransactionSuccess() {
yieldSupplyRepository.saveTokenProtocolStatus(
userWallet.walletId,
cryptoCurrency,
YieldSupplyEnterStatus.Exit,
)
analytics.send(
YieldSupplyAnalytics.FundsWithdrawn(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
),
)
val event = AnalyticsParam.TxSentFrom.Earning(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
feeType = AnalyticsParam.FeeType.Normal,
)
analytics.send(
Basic.TransactionSent(
sentFrom = event,
memoType = Basic.TransactionSent.MemoType.Null,
),
)
val address = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
if (address != null) {
yieldSupplyDeactivateUseCase(cryptoCurrency, address)
}
modelScope.launch {
params.callback.onTransactionSent()
}
}
private fun subscribeOnCurrencyStatusUpdates() {
modelScope.launch {
feeCryptoCurrencyStatusFlow.update {

View file

@ -38,6 +38,7 @@ internal class YieldSupplyStopEarningFeeContentTransformer(
maxNetworkFeeFiatValue = TextReference.EMPTY,
minTopUpFiatValue = TextReference.EMPTY,
feeNoteValue = TextReference.EMPTY,
estimatedFiatValue = TextReference.EMPTY,
),
)
}