Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-12 14:18:29 +03:00
commit d44fcb3218
61 changed files with 1333 additions and 232 deletions

View file

@ -199,4 +199,30 @@ internal object YieldSupplyDomainModule {
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyGetShouldShowMainPromoUseCase(
yieldSupplyRepository: YieldSupplyRepository,
): YieldSupplyGetShouldShowMainPromoUseCase {
return YieldSupplyGetShouldShowMainPromoUseCase(
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplySetShouldShowMainPromoUseCase(
yieldSupplyRepository: YieldSupplyRepository,
): YieldSupplySetShouldShowMainPromoUseCase {
return YieldSupplySetShouldShowMainPromoUseCase(
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyGetDustMinAmountUseCase(): YieldSupplyGetDustMinAmountUseCase {
return YieldSupplyGetDustMinAmountUseCase()
}
}

View file

@ -43,12 +43,14 @@ import java.math.BigDecimal
*/
class TokenItemStateConverter(
private val appCurrency: AppCurrency,
private val yieldModuleApyMap: Map<String, String> = emptyMap(),
private val yieldModuleApyMap: Map<String, BigDecimal> = emptyMap(),
private val stakingApyMap: Map<String, List<Yield.Validator>> = emptyMap(),
private val yieldSupplyPromoBannerKey: String? = null,
private val iconStateProvider: (CryptoCurrencyStatus) -> CurrencyIconState = {
CryptoCurrencyToIconStateConverter().convert(it)
},
private val onApyLabelClick: ((CryptoCurrencyStatus, ApySource, String) -> Unit)? = null,
private val onYieldPromoCloseClick: (() -> Unit)? = null,
private val titleStateProvider: (CryptoCurrencyStatus) -> TokenItemState.TitleState = { currencyStatus ->
createTitleState(
currencyStatus = currencyStatus,
@ -66,6 +68,15 @@ class TokenItemStateConverter(
private val fiatAmountStateProvider: (CryptoCurrencyStatus) -> TokenItemState.FiatAmountState? = {
createFiatAmountState(status = it, appCurrency = appCurrency)
},
private val promoBannerProvider: (CryptoCurrencyStatus) -> TokenItemState.PromoBannerState = { status ->
createPromoBannerState(
status = status,
yieldModuleApyMap = yieldModuleApyMap,
yieldSupplyPromoBannerKey = yieldSupplyPromoBannerKey,
onApyLabelClick = onApyLabelClick,
onYieldPromoCloseClick = onYieldPromoCloseClick,
)
},
private val onItemClick: ((TokenItemState, CryptoCurrencyStatus) -> Unit)? = null,
private val onItemLongClick: ((TokenItemState, CryptoCurrencyStatus) -> Unit)? = null,
) : Converter<CryptoCurrencyStatus, TokenItemState> {
@ -102,6 +113,7 @@ class TokenItemStateConverter(
subtitleState = requireNotNull(subtitleStateProvider(this)),
fiatAmountState = requireNotNull(fiatAmountStateProvider(this)),
subtitle2State = requireNotNull(subtitle2StateProvider(this)),
promoBannerState = promoBannerProvider(this),
onItemClick = onItemClick?.let { onItemClick ->
{ onItemClick(it, this) }
},
@ -164,7 +176,7 @@ class TokenItemStateConverter(
private fun createTitleState(
currencyStatus: CryptoCurrencyStatus,
yieldModuleApyMap: Map<String, String>,
yieldModuleApyMap: Map<String, BigDecimal>,
stakingApyMap: Map<String, List<Yield.Validator>>,
onApyLabelClick: ((CryptoCurrencyStatus, ApySource, String) -> Unit)?,
): TokenItemState.TitleState {
@ -204,7 +216,7 @@ class TokenItemStateConverter(
// polygon-pos_0xc2132d05d31c914a87c6611c10748aeb04b58e8f
private fun resolveEarnApy(
cryptoCurrencyStatus: CryptoCurrencyStatus,
yieldModuleApyMap: Map<String, String>,
yieldModuleApyMap: Map<String, BigDecimal>,
stakingApyMap: Map<String, List<Yield.Validator>>,
): EarnApyInfo? {
val token = cryptoCurrencyStatus.currency as? CryptoCurrency.Token
@ -223,7 +235,7 @@ class TokenItemStateConverter(
wrappedList(yieldSupplyApy),
),
isActive = isActive,
apy = yieldSupplyApy,
apy = yieldSupplyApy.toString(),
source = ApySource.YIELD_SUPPLY,
)
}
@ -381,6 +393,36 @@ class TokenItemStateConverter(
}
}
private fun createPromoBannerState(
status: CryptoCurrencyStatus,
yieldModuleApyMap: Map<String, BigDecimal>,
yieldSupplyPromoBannerKey: String?,
onApyLabelClick: ((CryptoCurrencyStatus, ApySource, String) -> Unit)?,
onYieldPromoCloseClick: (() -> Unit)?,
): TokenItemState.PromoBannerState {
val token = status.currency as? CryptoCurrency.Token ?: return TokenItemState.PromoBannerState.Empty
if (yieldSupplyPromoBannerKey == null || yieldSupplyPromoBannerKey != token.yieldSupplyKey() ||
yieldModuleApyMap[token.yieldSupplyKey()] == null
) {
return TokenItemState.PromoBannerState.Empty
}
val yieldSupplyApy =
yieldModuleApyMap[token.yieldSupplyKey()] ?: return TokenItemState.PromoBannerState.Empty
return TokenItemState.PromoBannerState.Content(
title = resourceReference(
R.string.yield_module_main_screen_promo_banner_message,
wrappedList(yieldSupplyApy),
),
onPromoBannerClick = {
onApyLabelClick?.invoke(status, ApySource.YIELD_SUPPLY, yieldSupplyApy.toString())
},
onCloseClick = {
onYieldPromoCloseClick?.invoke()
},
)
}
private fun CryptoCurrencyStatus.getCryptoPriceState(appCurrency: AppCurrency): TokenItemState.SubtitleState {
val fiatRate = value.fiatRate
val priceChange = value.priceChange

View file

@ -122,6 +122,10 @@ object PreferencesKeys {
val YIELD_SUPPLY_WARNINGS_STATES_KEY by lazy { stringPreferencesKey(name = "yieldSupplyWarningsStates") }
val YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY by lazy {
booleanPreferencesKey(name = "yieldSupplyShouldShowMainPromo")
}
val ACCESS_CODE_SKIPPED_STATES_KEY by lazy { stringPreferencesKey(name = "accessCodeSkippedStates") }
// region Notifications

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.components.token
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -18,6 +19,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
@ -28,6 +30,7 @@ import com.tangem.core.ui.components.token.internal.*
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
import com.tangem.core.ui.components.token.state.TokenItemState.Subtitle2State
import com.tangem.core.ui.components.token.state.TokenItemState.PromoBannerState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.extensions.stringReference
@ -44,7 +47,7 @@ private const val TITLE_MIN_WIDTH_COEFFICIENT = 0.3
private const val PRICE_MIN_WIDTH_COEFFICIENT = 0.32
private enum class LayoutId {
ICON, TITLE, FIAT_AMOUNT, CRYPTO_AMOUNT, CRYPTO_PRICE, NON_FIAT_CONTENT
ICON, TITLE, FIAT_AMOUNT, CRYPTO_AMOUNT, CRYPTO_PRICE, NON_FIAT_CONTENT, PROMO_BANNER
}
/**
@ -109,6 +112,14 @@ fun TokenItem(
.testTag(TokenElementsTestTags.TOKEN_ICON),
)
YieldSupplyPromoBanner(
state = state.promoBannerState,
modifier = Modifier
.layoutId(layoutId = LayoutId.PROMO_BANNER)
.testTag(TokenElementsTestTags.TOKEN_YIELD_PROMO_BANNER)
.fillMaxWidth(),
)
TokenTitle(
state = state.titleState,
modifier = Modifier
@ -220,6 +231,13 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
val nonFiatContent = measurables.measure(layoutId = LayoutId.NON_FIAT_CONTENT, constraints = constraints)
val promoBanner = when (state.promoBannerState) {
is PromoBannerState.Content -> measurables.measure(
layoutId = LayoutId.PROMO_BANNER,
constraints = constraints,
)
else -> null
}
var firstRowRemainingFreeSpace: Int? = null
var secondRowRemainingFreeSpace: Int? = null
@ -283,10 +301,18 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
)
}
val promoBannerHeight = promoBanner?.height ?: 0
val promoOffset = if (promoBannerHeight > 0) {
promoBannerHeight - 8.dp.roundToPx()
} else {
0
}
val layoutHeight = calculateLayoutHeight(
state = state,
minLayoutHeight = with(density) { dimens.size68.roundToPx() },
layoutPadding = verticalPadding,
promoOffset = promoOffset,
title = title,
fiatAmount = fiatAmount,
cryptoAmount = cryptoAmount,
@ -294,16 +320,22 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
)
layout(width = constraints.maxWidth, height = layoutHeight) {
icon.placeRelative(x = 0, y = (layoutHeight - icon.height).div(other = 2))
promoBanner?.placeRelative(x = 0, y = 0)
icon.placeRelative(
x = 0,
y = promoOffset + (layoutHeight - promoOffset - icon.height)
.div(other = 2),
)
title.placeRelative(
x = icon.width,
y = when (state) {
y = promoOffset + when (state) {
is TokenItemState.NoAddress,
is TokenItemState.Unreachable,
-> {
if (state.subtitleState == null) {
(layoutHeight - title.height).div(other = 2)
(layoutHeight - promoOffset - title.height).div(other = 2)
} else {
verticalPadding
}
@ -314,8 +346,8 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
fiatAmount?.placeRelative(
x = layoutWidth - fiatAmount.width,
y = when (state.subtitle2State) {
null -> (layoutHeight - fiatAmount.height).div(other = 2)
y = promoOffset + when (state.subtitle2State) {
null -> (layoutHeight - promoOffset - fiatAmount.height).div(other = 2)
else -> verticalPadding
},
)
@ -335,7 +367,7 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
nonFiatContent.placeRelative(
x = layoutWidth - nonFiatContent.width,
y = (layoutHeight - nonFiatContent.height).div(other = 2),
y = promoOffset + (layoutHeight - promoOffset - nonFiatContent.height).div(other = 2),
)
}
}
@ -443,6 +475,7 @@ private fun calculateLayoutHeight(
state: TokenItemState,
minLayoutHeight: Int,
layoutPadding: Int,
promoOffset: Int,
title: Placeable,
fiatAmount: Placeable?,
cryptoAmount: Placeable?,
@ -468,7 +501,7 @@ private fun calculateLayoutHeight(
}
}
return max(firstColumnHeight, secondColumnHeight).coerceAtLeast(minLayoutHeight)
return (promoOffset + max(firstColumnHeight, secondColumnHeight)).coerceAtLeast(promoOffset + minLayoutHeight)
}
@Preview(widthDp = 360, showBackground = true)
@ -587,6 +620,11 @@ private class TokenItemStateProvider : CollectionPreviewParameterProvider<TokenI
),
onItemClick = {},
onItemLongClick = {},
promoBannerState = PromoBannerState.Content(
title = TextReference.Str(value = "Trusted"),
onPromoBannerClick = {},
onCloseClick = {},
),
),
TokenItemState.Loading(
id = "Loading#1",

View file

@ -0,0 +1,110 @@
package com.tangem.core.ui.components.token.internal
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import android.content.res.Configuration
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ripple
import androidx.compose.runtime.remember
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.token.state.TokenItemState.PromoBannerState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
internal fun YieldSupplyPromoBanner(state: PromoBannerState, modifier: Modifier = Modifier) {
when (state) {
is PromoBannerState.Content -> YieldSupplyPromoBanner(state = state, modifier = modifier)
is PromoBannerState.Empty -> Unit
}
}
@Composable
internal fun YieldSupplyPromoBanner(state: PromoBannerState.Content, modifier: Modifier = Modifier) {
val bgColor = TangemTheme.colors.control.unchecked
Column(modifier = modifier) {
Row(
modifier = Modifier
.background(color = bgColor, shape = TangemTheme.shapes.roundedCornersXMedium)
.padding(horizontal = 12.dp, vertical = 8.dp)
.clickable(onClick = state.onPromoBannerClick)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_analytics_up_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing8)
.size(TangemTheme.dimens.size16),
)
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.secondary,
modifier = Modifier
.weight(1f)
.padding(end = TangemTheme.dimens.spacing8),
)
Icon(
painter = painterResource(id = R.drawable.ic_close_24),
contentDescription = null,
tint = TangemTheme.colors.text.secondary,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = { state.onCloseClick() },
),
)
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_rectangle_bottom),
contentDescription = null,
tint = bgColor,
modifier = Modifier
.size(width = 12.dp, height = 8.dp),
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_YieldSupplyPromoBanner() {
TangemThemePreview {
YieldSupplyPromoBanner(
state = PromoBannerState.Content(
title = TextReference.Str(value = "Earn up to 5% APY"),
onPromoBannerClick = {},
onCloseClick = {},
),
modifier = Modifier.fillMaxWidth(),
)
}
}

View file

@ -34,6 +34,8 @@ sealed class TokenItemState {
*/
abstract val subtitle2State: Subtitle2State?
abstract val promoBannerState: PromoBannerState
/** Callback which will be called when an item is clicked */
abstract val onItemClick: ((TokenItemState) -> Unit)?
@ -59,6 +61,7 @@ sealed class TokenItemState {
) : TokenItemState() {
override val fiatAmountState: FiatAmountState = FiatAmountState.Loading
override val subtitle2State: Subtitle2State = Subtitle2State.Loading
override val promoBannerState: PromoBannerState = PromoBannerState.Empty
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onItemLongClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
@ -75,6 +78,7 @@ sealed class TokenItemState {
override val subtitleState: SubtitleState = SubtitleState.Locked
override val fiatAmountState: FiatAmountState = FiatAmountState.Locked
override val subtitle2State: Subtitle2State = Subtitle2State.Locked
override val promoBannerState: PromoBannerState = PromoBannerState.Empty
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onItemLongClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
@ -99,6 +103,7 @@ sealed class TokenItemState {
override val subtitleState: SubtitleState,
override val fiatAmountState: FiatAmountState?,
override val subtitle2State: Subtitle2State?,
override val promoBannerState: PromoBannerState = PromoBannerState.Empty,
override val onItemClick: ((TokenItemState) -> Unit)?,
override val onItemLongClick: ((TokenItemState) -> Unit)?,
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null,
@ -120,6 +125,7 @@ sealed class TokenItemState {
) : TokenItemState() {
override val subtitleState: SubtitleState? = null
override val fiatAmountState: FiatAmountState? = null
override val promoBannerState: PromoBannerState = PromoBannerState.Empty
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onItemLongClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
@ -146,6 +152,7 @@ sealed class TokenItemState {
) : TokenItemState() {
override val fiatAmountState: FiatAmountState? = null
override val subtitle2State: Subtitle2State? = null
override val promoBannerState: PromoBannerState = PromoBannerState.Empty
}
/**
@ -168,6 +175,7 @@ sealed class TokenItemState {
override val subtitle2State: Subtitle2State? = null
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
override val promoBannerState: PromoBannerState = PromoBannerState.Empty
}
@Immutable
@ -254,4 +262,15 @@ sealed class TokenItemState {
data object Locked : Subtitle2State()
}
@Immutable
sealed class PromoBannerState {
data class Content(
val title: TextReference,
val onPromoBannerClick: () -> Unit,
val onCloseClick: () -> Unit,
) : PromoBannerState()
data object Empty : PromoBannerState()
}
}

View file

@ -19,10 +19,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ChainStyle
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.constraintlayout.compose.Visibility
import androidx.constraintlayout.compose.atLeast
import com.tangem.core.ui.R
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
@ -94,7 +96,7 @@ fun Transaction(state: TransactionState, isBalanceHidden: Boolean, modifier: Mod
bottom.linkTo(subtitleItem.top)
start.linkTo(iconItem.end)
end.linkTo(amountItem.start)
width = Dimension.fillToConstraints
width = Dimension.fillToConstraints.atLeast(50.dp)
},
)
@ -122,7 +124,6 @@ fun Transaction(state: TransactionState, isBalanceHidden: Boolean, modifier: Mod
visibility = state.isGoneIf { amount.isEmpty() }
top.linkTo(parent.top)
bottom.linkTo(timestampItem.top)
start.linkTo(titleItem.end)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
},
@ -436,7 +437,7 @@ private class TransactionItemStateProvider : CollectionPreviewParameterProvider<
),
TransactionState.Content(
txHash = UUID.randomUUID().toString(),
amount = "0.625 USDT",
amount = "0.62521313 USDT",
time = "€0.50",
status = Status.Confirmed,
direction = Direction.OUTGOING,

View file

@ -7,4 +7,5 @@ object TokenElementsTestTags {
const val TOKEN_FIAT_AMOUNT = "TOKEN_FIAT_AMOUNT"
const val TOKEN_CRYPTO_AMOUNT = "TOKEN_CRYPTO_AMOUNT"
const val TOKEN_NON_FIAT_BLOCK = "TOKEN_NON_FIAT_BLOCK"
const val TOKEN_YIELD_PROMO_BANNER = "TOKEN_YIELD_PROMO_BANNER"
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M11.799,8.899C12.047,8.469 12.597,8.322 13.028,8.57C14.276,9.291 15.268,10.385 15.863,11.698C16.458,13.011 16.627,14.478 16.345,15.892C16.064,17.306 15.347,18.597 14.295,19.582C13.242,20.567 11.908,21.199 10.478,21.387C9.049,21.575 7.596,21.31 6.325,20.631C5.054,19.951 4.027,18.891 3.389,17.598C2.752,16.305 2.535,14.844 2.77,13.422C3.005,12 3.68,10.686 4.699,9.667C5.05,9.316 5.62,9.316 5.971,9.667C6.323,10.019 6.323,10.588 5.971,10.939C5.218,11.693 4.719,12.664 4.546,13.715C4.372,14.766 4.532,15.845 5.004,16.801C5.475,17.756 6.234,18.541 7.173,19.043C8.113,19.545 9.187,19.741 10.244,19.602C11.3,19.462 12.286,18.996 13.064,18.268C13.842,17.539 14.372,16.585 14.58,15.54C14.788,14.495 14.663,13.411 14.223,12.441C13.783,11.47 13.05,10.662 12.128,10.129C11.697,9.88 11.55,9.33 11.799,8.899ZM13.766,2.615C15.196,2.427 16.648,2.692 17.92,3.371C19.191,4.051 20.218,5.111 20.856,6.404C21.494,7.697 21.71,9.158 21.475,10.58C21.24,12.002 20.566,13.316 19.546,14.335C19.195,14.686 18.625,14.686 18.273,14.335C17.922,13.984 17.922,13.414 18.273,13.063C19.027,12.309 19.525,11.338 19.699,10.287C19.872,9.236 19.712,8.156 19.241,7.2C18.77,6.245 18.011,5.46 17.071,4.958C16.131,4.456 15.058,4.26 14.002,4.399C12.945,4.539 11.959,5.005 11.181,5.733C10.404,6.462 9.874,7.416 9.666,8.461C9.458,9.506 9.582,10.59 10.022,11.561C10.462,12.531 11.194,13.34 12.117,13.873C12.547,14.122 12.695,14.672 12.447,15.103C12.198,15.533 11.648,15.68 11.217,15.432C9.969,14.711 8.977,13.617 8.382,12.304C7.788,10.991 7.619,9.524 7.9,8.11C8.181,6.696 8.899,5.406 9.951,4.42C11.003,3.435 12.337,2.804 13.766,2.615Z"
android:fillColor="#1E1E1E"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M5.91,11.001C5.191,11.745 4.714,12.691 4.546,13.714C4.372,14.764 4.533,15.844 5.004,16.799C5.475,17.755 6.234,18.539 7.173,19.042C8.113,19.544 9.188,19.739 10.244,19.6C11.3,19.461 12.286,18.994 13.064,18.266C13.083,18.249 13.1,18.229 13.119,18.212L14.392,19.485C14.36,19.517 14.328,19.549 14.295,19.581C13.242,20.566 11.907,21.197 10.478,21.385C9.049,21.573 7.596,21.309 6.325,20.629C5.054,19.95 4.027,18.889 3.389,17.596C2.752,16.303 2.535,14.843 2.77,13.42C3,12.027 3.652,10.738 4.637,9.728L5.91,11.001Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M4.164,4.164C4.515,3.812 5.086,3.812 5.437,4.164L19.837,18.564C20.188,18.916 20.188,19.485 19.837,19.837C19.485,20.188 18.916,20.188 18.564,19.837L4.164,5.437C3.812,5.086 3.812,4.515 4.164,4.164Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M13.766,2.614C15.195,2.426 16.648,2.69 17.92,3.37C19.191,4.049 20.219,5.11 20.856,6.403C21.493,7.696 21.71,9.157 21.475,10.579C21.24,12.001 20.566,13.314 19.546,14.334C19.526,14.354 19.503,14.373 19.481,14.391L18.215,13.125C18.233,13.103 18.253,13.082 18.273,13.061C19.027,12.308 19.525,11.337 19.699,10.286C19.872,9.235 19.712,8.154 19.241,7.199C18.77,6.244 18.01,5.459 17.071,4.957C16.131,4.455 15.058,4.259 14.002,4.398C12.945,4.537 11.959,5.004 11.181,5.732C11.12,5.79 11.059,5.848 11.001,5.909L9.728,4.636C9.801,4.562 9.875,4.49 9.951,4.419C11.003,3.433 12.337,2.802 13.766,2.614Z"
android:fillColor="#1E1E1E"/>
</vector>

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,7.6C14.43,7.6 16.4,9.57 16.4,12C16.4,14.43 14.43,16.4 12,16.4C9.57,16.4 7.6,14.43 7.6,12C7.6,9.57 9.57,7.6 12,7.6ZM12,9.4C10.564,9.4 9.4,10.564 9.4,12C9.4,13.436 10.564,14.6 12,14.6C13.436,14.6 14.6,13.436 14.6,12C14.6,10.564 13.436,9.4 12,9.4Z"
android:fillColor="#1E1E1E"
android:fillType="evenOdd"/>
<path
android:pathData="M13.938,2.1C14.721,2.1 15.374,2.099 15.911,2.151C16.468,2.206 16.972,2.324 17.451,2.6C17.931,2.876 18.285,3.252 18.611,3.706C18.926,4.144 19.253,4.708 19.646,5.385L21.569,8.7C21.964,9.38 22.292,9.946 22.517,10.439C22.749,10.949 22.9,11.445 22.9,12C22.9,12.555 22.749,13.051 22.517,13.561C22.292,14.054 21.964,14.62 21.569,15.3L19.646,18.615C19.253,19.292 18.926,19.856 18.611,20.294C18.285,20.748 17.931,21.125 17.451,21.4C16.972,21.676 16.468,21.794 15.911,21.849C15.374,21.901 14.721,21.9 13.938,21.9H10.063C9.279,21.9 8.626,21.901 8.089,21.849C7.532,21.794 7.028,21.676 6.549,21.4C6.069,21.125 5.715,20.748 5.389,20.294C5.074,19.856 4.747,19.292 4.354,18.615L2.431,15.3C2.036,14.62 1.708,14.054 1.483,13.561C1.251,13.051 1.1,12.555 1.1,12C1.1,11.445 1.251,10.949 1.483,10.439C1.708,9.946 2.036,9.38 2.431,8.7L4.354,5.385C4.747,4.708 5.074,4.144 5.389,3.706C5.715,3.252 6.069,2.876 6.549,2.6C7.028,2.324 7.532,2.206 8.089,2.151C8.626,2.099 9.279,2.1 10.063,2.1H13.938ZM10.063,3.9C9.244,3.9 8.694,3.901 8.265,3.943C7.855,3.984 7.628,4.056 7.447,4.16C7.267,4.264 7.089,4.424 6.85,4.757C6.598,5.106 6.321,5.581 5.911,6.288L3.988,9.604C3.576,10.314 3.3,10.79 3.121,11.184C2.95,11.559 2.9,11.792 2.9,12C2.9,12.208 2.95,12.441 3.121,12.816C3.3,13.21 3.576,13.686 3.988,14.396L5.911,17.712C6.321,18.419 6.598,18.894 6.85,19.243C7.089,19.576 7.267,19.736 7.447,19.84C7.628,19.944 7.855,20.016 8.265,20.057C8.694,20.099 9.244,20.1 10.063,20.1H13.938C14.756,20.1 15.306,20.099 15.735,20.057C16.145,20.016 16.372,19.944 16.553,19.84C16.733,19.736 16.911,19.576 17.15,19.243C17.402,18.894 17.678,18.419 18.089,17.712L20.012,14.396C20.424,13.686 20.7,13.21 20.879,12.816C21.05,12.441 21.1,12.208 21.1,12C21.1,11.792 21.05,11.559 20.879,11.184C20.7,10.79 20.424,10.314 20.012,9.604L18.089,6.288C17.678,5.581 17.402,5.106 17.15,4.757C16.911,4.424 16.733,4.264 16.553,4.16C16.372,4.056 16.145,3.984 15.735,3.943C15.306,3.901 14.756,3.9 13.938,3.9H10.063Z"
android:fillColor="#1E1E1E"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="12dp"
android:height="8dp"
android:viewportWidth="12"
android:viewportHeight="8">
<path
android:pathData="M0.198,1.178C0.034,0.976 -0.028,0.695 0.012,0.433C0.058,0.135 0.373,0 0.675,0H6.183H11.325C11.627,0 11.943,0.135 11.988,0.433C12.028,0.695 11.966,0.976 11.802,1.178L6.477,7.756C6.214,8.081 5.786,8.081 5.523,7.756L0.198,1.178Z"
android:fillColor="#EBEBEB"/>
</vector>

View file

@ -69,15 +69,16 @@ fun CoroutineScope.launchOnCancellation(block: suspend () -> Unit) {
}
@Suppress("LongParameterList", "MagicNumber")
inline fun <T1, T2, T3, T4, T5, T6, R> combine6(
inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine7(
flow1: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine(flow1, flow2, flow3, flow4, flow5, flow6) { arr ->
flow7: Flow<T7>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R,
): Flow<R> = combine(flow1, flow2, flow3, flow4, flow5, flow6, flow7) { arr ->
@Suppress("UNCHECKED_CAST")
transform(
arr[0] as T1,
@ -86,5 +87,6 @@ inline fun <T1, T2, T3, T4, T5, T6, R> combine6(
arr[3] as T4,
arr[4] as T5,
arr[5] as T6,
arr[6] as T7,
)
}

View file

@ -24,7 +24,7 @@ internal class SdkTransactionHistoryItemConverter(
SdkTransactionHistoryItem.TransactionStatus.Failed -> TxInfo.TransactionStatus.Failed
SdkTransactionHistoryItem.TransactionStatus.Unconfirmed -> TxInfo.TransactionStatus.Unconfirmed
},
type = typeConverter.convert(value.type),
type = typeConverter.convert(value.type to value.destinationType.toDomain()),
amount = requireNotNull(value.amount.value) { "Transaction amount value must not be null" },
)

View file

@ -1,21 +1,27 @@
package com.tangem.data.walletmanager.utils
import com.tangem.blockchain.transactionhistory.models.TransactionHistoryItem.TransactionType
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplyEnterCallData
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplyExitCallData
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplyInitTokenCallData
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplyReactivateTokenCallData
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.walletmanager.model.SmartContractMethod
import com.tangem.utils.converter.Converter
internal class SdkTransactionTypeConverter(
private val smartContractMethods: Map<String, SmartContractMethod>,
) : Converter<TransactionType, TxInfo.TransactionType> {
) : Converter<Pair<TransactionType, TxInfo.DestinationType>, TxInfo.TransactionType> {
override fun convert(value: TransactionType): TxInfo.TransactionType {
return when (value) {
override fun convert(value: Pair<TransactionType, TxInfo.DestinationType>): TxInfo.TransactionType {
val (type, destination) = value
return when (type) {
is TransactionType.ContractMethod -> {
getTransactionType(methodName = smartContractMethods[value.id]?.name)
getTransactionType(methodName = smartContractMethods[type.id]?.name, type.callData, destination)
}
is TransactionType.ContractMethodName -> {
getTransactionType(methodName = value.name)
getTransactionType(methodName = type.name, type.callData, destination)
}
is TransactionType.Transfer -> {
TxInfo.TransactionType.Transfer
@ -27,7 +33,7 @@ internal class SdkTransactionTypeConverter(
TxInfo.TransactionType.Staking.Unstake
}
is TransactionType.TronStakingTransactionType.VoteWitnessContract -> {
TxInfo.TransactionType.Staking.Vote(value.validatorAddress)
TxInfo.TransactionType.Staking.Vote(type.validatorAddress)
}
is TransactionType.TronStakingTransactionType.WithdrawBalanceContract -> {
TxInfo.TransactionType.Staking.ClaimRewards
@ -38,7 +44,12 @@ internal class SdkTransactionTypeConverter(
}
}
private fun getTransactionType(methodName: String?): TxInfo.TransactionType {
@Suppress("CyclomaticComplexMethod")
private fun getTransactionType(
methodName: String?,
callData: String?,
destination: TxInfo.DestinationType,
): TxInfo.TransactionType {
return when (methodName) {
"transfer" -> TxInfo.TransactionType.Transfer
"approve" -> TxInfo.TransactionType.Approve
@ -59,11 +70,33 @@ internal class SdkTransactionTypeConverter(
"withdrawRewardsPOL",
-> TxInfo.TransactionType.Staking.ClaimRewards
"redelegate" -> TxInfo.TransactionType.Staking.Restake
"supplyEnter" -> TxInfo.TransactionType.YieldSupply.Enter
"supplyExit" -> TxInfo.TransactionType.YieldSupply.Exit
"yieldSend" -> TxInfo.TransactionType.YieldSupply.Send
"enterProtocolByOwner" -> callData?.let { data ->
TxInfo.TransactionType.YieldSupply.Enter(
EthereumYieldSupplyEnterCallData.decode(data)?.tokenContractAddress.orEmpty(),
)
}
"withdrawAndDeactivate" -> callData?.let { data ->
TxInfo.TransactionType.YieldSupply.Exit(
EthereumYieldSupplyExitCallData.decode(data)?.tokenContractAddress.orEmpty(),
)
}
"deployYieldModule" -> TxInfo.TransactionType.YieldSupply.DeployContract(
(destination as? TxInfo.DestinationType.Single)?.addressType?.address.orEmpty(),
)
"initYieldToken" -> callData?.let { data ->
TxInfo.TransactionType.YieldSupply.InitializeToken(
EthereumYieldSupplyInitTokenCallData.decode(data)?.tokenContractAddress.orEmpty(),
)
}
"reactivateToken" -> callData?.let { data ->
TxInfo.TransactionType.YieldSupply.ReactivateToken(
EthereumYieldSupplyReactivateTokenCallData.decode(data)?.tokenContractAddress.orEmpty(),
)
}
"supplyTopUp" -> TxInfo.TransactionType.YieldSupply.Topup
null -> TxInfo.TransactionType.UnknownOperation
else -> TxInfo.TransactionType.Operation(name = methodName.replaceFirstChar { it.titlecase() })
}
} ?: TxInfo.TransactionType.Operation(name = methodName?.replaceFirstChar { it.titlecase() }.orEmpty())
}
}

View file

@ -45,7 +45,7 @@ internal class TransactionDataToTxHistoryItemConverter(
TransactionStatus.Confirmed -> TxInfo.TransactionStatus.Confirmed
TransactionStatus.Unconfirmed -> TxInfo.TransactionStatus.Unconfirmed
},
type = getTransactionType(value.extras),
type = getTransactionType(value),
amount = amount,
)
}
@ -99,16 +99,25 @@ internal class TransactionDataToTxHistoryItemConverter(
)
}
private fun getTransactionType(extras: TransactionExtras?): TxInfo.TransactionType {
return when (extras) {
private fun getTransactionType(transactionData: TransactionData.Uncompiled?): TxInfo.TransactionType {
return when (val extras = transactionData?.extras) {
is EthereumTransactionExtras -> {
when (extras.callData) {
is EthereumYieldSupplyDeployCallData,
is EthereumYieldSupplyReactivateTokenCallData,
is EthereumYieldSupplyInitTokenCallData,
is EthereumYieldSupplyEnterCallData,
-> TxInfo.TransactionType.YieldSupply.Enter
is EthereumYieldSupplyExitCallData -> TxInfo.TransactionType.YieldSupply.Exit
when (val callData = extras.callData) {
is EthereumYieldSupplyDeployCallData -> TxInfo.TransactionType.YieldSupply.DeployContract(
transactionData.destinationAddress,
)
is EthereumYieldSupplyReactivateTokenCallData -> TxInfo.TransactionType.YieldSupply.ReactivateToken(
callData.tokenContractAddress,
)
is EthereumYieldSupplyInitTokenCallData -> TxInfo.TransactionType.YieldSupply.InitializeToken(
callData.tokenContractAddress,
)
is EthereumYieldSupplyEnterCallData -> TxInfo.TransactionType.YieldSupply.Enter(
callData.tokenContractAddress,
)
is EthereumYieldSupplyExitCallData -> TxInfo.TransactionType.YieldSupply.Exit(
callData.tokenContractAddress,
)
is ApprovalERC20TokenCallData -> TxInfo.TransactionType.Approve
else -> TxInfo.TransactionType.Transfer
}

View file

@ -19,6 +19,10 @@ dependencies {
/** Tangem SDKs */
implementation(tangemDeps.blockchain)
// region AndroidX libraries
implementation(deps.androidx.datastore)
// endregion
/** Core */
implementation(projects.core.datasource)
implementation(projects.core.utils)

View file

@ -12,6 +12,10 @@ import com.tangem.data.yield.supply.converters.YieldTokenChartConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.datasource.api.tangemTech.models.YieldSupplyChangeTokenStatusBody
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -35,6 +39,7 @@ internal class DefaultYieldSupplyRepository(
private val walletManagersFacade: WalletManagersFacade,
private val dispatchers: CoroutineDispatcherProvider,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val appPreferencesStore: AppPreferencesStore,
) : YieldSupplyRepository {
private val statusMap: MutableMap<String, YieldSupplyEnterStatus> = ConcurrentHashMap()
@ -174,6 +179,14 @@ internal class DefaultYieldSupplyRepository(
null
}
override fun getShouldShowYieldPromoBanner(): Flow<Boolean> {
return appPreferencesStore.get(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, true)
}
override suspend fun setShouldShowYieldPromoBanner(shouldShow: Boolean) {
appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, shouldShow)
}
private fun Set<TxInfo>.hasYieldEnterTransactions(yieldAddress: String) = any {
it.type == TxInfo.TransactionType.YieldSupply.Enter ||
it.type == TxInfo.TransactionType.Approve &&

View file

@ -5,6 +5,7 @@ import com.tangem.data.yield.supply.DefaultYieldSupplyRepository
import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver
import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldSupplyRepository
@ -41,6 +42,7 @@ internal object YieldSupplyDataModule {
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
analyticsExceptionHandler: AnalyticsExceptionHandler,
appPreferencesStore: AppPreferencesStore,
): YieldSupplyRepository {
return DefaultYieldSupplyRepository(
yieldSupplyApi = yieldSupplyApi,
@ -48,6 +50,7 @@ internal object YieldSupplyDataModule {
dispatchers = dispatchers,
walletManagersFacade = walletManagersFacade,
analyticsExceptionHandler = analyticsExceptionHandler,
appPreferencesStore = appPreferencesStore,
)
}

View file

@ -205,32 +205,32 @@
"0xcbeda14c": {
"info": "yieldModule",
"source": "https://github.com/tangem/tangem-yield-module-contracts/",
"name": "supply"
"name": "deployYieldModule"
},
"0x79be55f7": {
"info": "yieldModule",
"source": "https://github.com/tangem/tangem-yield-module-contracts/",
"name": "supplyEnter"
"name": "enterProtocolByOwner"
},
"0xc65e6dcf": {
"info": "yieldModule",
"source": "https://github.com/tangem/tangem-yield-module-contracts/",
"name": "supplyExit"
"name": "withdrawAndDeactivate"
},
"0xebd4b81c": {
"info": "yieldModule",
"source": "https://github.com/tangem/tangem-yield-module-contracts/",
"name": "supply"
"name": "initYieldToken"
},
"0xc478e956": {
"info": "yieldModule",
"source": "https://github.com/tangem/tangem-yield-module-contracts/",
"name": "supply"
"name": "reactivateToken"
},
"0x0779afe6": {
"info": "yieldModule",
"source": "https://github.com/tangem/tangem-yield-module-contracts/",
"name": "transfer"
"name": "yieldSend"
},
"0xb9de6a93": {
"info": "yieldModule",

View file

@ -11,9 +11,9 @@ fun CryptoCurrencyStatus.hasNotSuppliedAmount(): Boolean {
return notSupplied > BigDecimal.ZERO
}
fun CryptoCurrencyStatus.shouldShowNotSuppliedInfoIcon(minAmount: BigDecimal): Boolean {
fun CryptoCurrencyStatus.shouldShowNotSuppliedInfoIcon(dustAmount: BigDecimal): Boolean {
val notSupplied = notSuppliedAmountOrNull() ?: return false
return notSupplied >= minAmount
return notSupplied >= dustAmount
}
fun CryptoCurrencyStatus.notSuppliedAmountOrNull(): BigDecimal? {

View file

@ -110,14 +110,32 @@ data class TxInfo(
@Serializable
sealed interface YieldSupply : TransactionType {
@Serializable
data object Enter : YieldSupply
val address: String?
@Serializable
data object Exit : YieldSupply
data class Enter(override val address: String) : YieldSupply
@Serializable
data object Topup : YieldSupply
data class Exit(override val address: String) : YieldSupply
@Serializable
data object Topup : YieldSupply {
override val address: String? = null
}
@Serializable
data object Send : YieldSupply {
override val address: String? = null
}
@Serializable
data class DeployContract(override val address: String) : YieldSupply
@Serializable
data class ReactivateToken(override val address: String) : YieldSupply
@Serializable
data class InitializeToken(override val address: String) : YieldSupply
}
@Serializable

View file

@ -0,0 +1,10 @@
package com.tangem.domain.yield.supply.models
data class YieldSupplyRewardBalance(
val fiatBalance: String?,
val cryptoBalance: String?,
) {
companion object {
fun empty() = YieldSupplyRewardBalance(null, null)
}
}

View file

@ -108,4 +108,8 @@ interface YieldSupplyRepository {
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): YieldSupplyEnterStatus?
fun getShouldShowYieldPromoBanner(): Flow<Boolean>
suspend fun setShouldShowYieldPromoBanner(shouldShow: Boolean)
}

View file

@ -3,6 +3,7 @@ package com.tangem.domain.yield.supply.usecase
import com.tangem.domain.yield.supply.YieldSupplyRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
/**
* Emits a map of APY values per token.
@ -15,11 +16,11 @@ class YieldSupplyApyFlowUseCase(
private val yieldSupplyRepository: YieldSupplyRepository,
) {
operator fun invoke(): Flow<Map<String, String>> {
operator fun invoke(): Flow<Map<String, BigDecimal>> {
return yieldSupplyRepository.getMarketsFlow()
.map { yieldMarketTokenList ->
yieldMarketTokenList.filter { it.isActive }.associate { token ->
token.yieldSupplyKey to token.apy.toString()
token.yieldSupplyKey to token.apy
}
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.yield.supply.usecase
import com.tangem.domain.appcurrency.model.AppCurrency
import java.math.BigDecimal
class YieldSupplyGetDustMinAmountUseCase {
operator fun invoke(minAmount: BigDecimal, appCurrency: AppCurrency): BigDecimal {
return if (appCurrency.code in SUPPORTED_DUST_CURRENCIES) {
DUST_MIN_AMOUNT
} else {
minAmount.stripTrailingZeros()
}
}
companion object {
private val DUST_MIN_AMOUNT = BigDecimal("0.1")
private val SUPPORTED_DUST_CURRENCIES = setOf("EUR", "USD", "AUD", "CAD", "GBP")
}
}

View file

@ -1,11 +1,13 @@
package com.tangem.domain.yield.supply.usecase
import com.tangem.core.ui.format.bigdecimal.anyDecimals
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.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.delay
@ -22,7 +24,7 @@ class YieldSupplyGetRewardsBalanceUseCase(
private val dispatcherProvider: CoroutineDispatcherProvider,
) {
operator fun invoke(status: CryptoCurrencyStatus, appCurrency: AppCurrency): Flow<String> = flow {
operator fun invoke(status: CryptoCurrencyStatus, appCurrency: AppCurrency): Flow<YieldSupplyRewardBalance> = flow {
val cryptoAmount = status.value.amount
val fiatRate = status.value.fiatRate
@ -30,11 +32,7 @@ class YieldSupplyGetRewardsBalanceUseCase(
return@flow
}
val amount = if (cryptoAmount != null && fiatRate != null) {
cryptoAmount.multiply(fiatRate)
} else {
return@flow
}
if (cryptoAmount == null) return@flow
val tokenAddress = (status.currency as? CryptoCurrency.Token)?.contractAddress ?: return@flow
val apy = try {
@ -50,37 +48,57 @@ class YieldSupplyGetRewardsBalanceUseCase(
return@flow
}
val initialPerTickDelta = amount
.multiply(apyFraction)
.multiply(TICK_SECONDS_BD)
.divide(SECONDS_PER_YEAR_BD, SCALE, RoundingMode.HALF_UP)
.abs()
val initialPerTickDeltaCrypto = perTickDelta(cryptoAmount, apyFraction).abs()
val minVisibleDecimals = calculateMinVisibleDecimals(initialPerTickDelta)
val minVisibleDecimalsCrypto = calculateMinVisibleDecimals(
perTickDeltaAbs = initialPerTickDeltaCrypto,
maxDecimals = status.currency.decimals,
)
var currentBalance: BigDecimal = amount
val fiatAmountStart = fiatRate?.let { cryptoAmount.multiply(it) }
val minVisibleDecimalsFiat = fiatAmountStart?.let { amount ->
val initialPerTickDeltaFiat = perTickDelta(amount, apyFraction).abs()
calculateMinVisibleDecimals(
perTickDeltaAbs = initialPerTickDeltaFiat,
maxDecimals = FIAT_MAX_DECIMALS,
)
}
var currentCryptoBalance: BigDecimal = cryptoAmount
var currentFiatBalance: BigDecimal? = fiatAmountStart
while (true) {
emit(
currentBalance.format {
val fiatBalanceFormatted: String? = currentFiatBalance?.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).anyDecimals(decimals = minVisibleDecimals)
},
).anyDecimals(decimals = minVisibleDecimalsFiat ?: FIAT_MIN_DECIMALS)
}
val cryptoBalanceFormatted: String = currentCryptoBalance.format {
crypto(status.currency).anyDecimals(
maxDecimals = minVisibleDecimalsCrypto,
minDecimals = minVisibleDecimalsCrypto,
)
}
emit(
YieldSupplyRewardBalance(fiatBalance = fiatBalanceFormatted, cryptoBalance = cryptoBalanceFormatted),
)
val perTickDelta = currentBalance
.multiply(apyFraction)
.multiply(TICK_SECONDS_BD)
.divide(SECONDS_PER_YEAR_BD, SCALE, RoundingMode.HALF_UP)
val perTickDeltaCrypto = perTickDelta(currentCryptoBalance, apyFraction)
currentBalance = currentBalance.add(perTickDelta)
currentCryptoBalance = currentCryptoBalance.add(perTickDeltaCrypto)
currentFiatBalance = currentFiatBalance?.let { current ->
val perTickDeltaFiat = perTickDelta(current, apyFraction)
current.add(perTickDeltaFiat)
}
delay(TICK_MILLIS)
}
}.flowOn(dispatcherProvider.default)
private fun calculateMinVisibleDecimals(perTickDeltaAbs: BigDecimal): Int {
private fun calculateMinVisibleDecimals(perTickDeltaAbs: BigDecimal, maxDecimals: Int): Int {
if (perTickDeltaAbs <= BigDecimal.ZERO) return MIN_DECIMALS
val perTickAsDouble = perTickDeltaAbs.toDouble()
@ -88,20 +106,28 @@ class YieldSupplyGetRewardsBalanceUseCase(
val safe = if (perTickAsDouble <= 0.0) EPSILON else perTickAsDouble
val raw = ceil(-ln(safe) / LN_10)
return raw.toInt().coerceIn(MIN_DECIMALS, MAX_DECIMALS)
return raw.toInt().coerceIn(MIN_DECIMALS, maxDecimals)
}
private companion object {
const val TICK_MILLIS: Long = 300
private val TICK_SECONDS_BD = BigDecimal("0.3")
private val SECONDS_PER_YEAR_BD = BigDecimal("31536000") // 365 * 24 * 60 * 60
private val HUNDRED_BD = BigDecimal("100")
private const val SCALE = 18
private fun perTickDelta(amount: BigDecimal, apyFraction: BigDecimal): BigDecimal {
return amount
.multiply(apyFraction)
.multiply(TICK_SECONDS_BD)
.divide(SECONDS_PER_YEAR_BD, SCALE, RoundingMode.HALF_UP)
}
private const val MIN_DECIMALS = 3
private const val MAX_DECIMALS = 12
companion object {
internal const val TICK_MILLIS: Long = 800
internal val TICK_SECONDS_BD: BigDecimal = BigDecimal("0.8")
internal val SECONDS_PER_YEAR_BD: BigDecimal = BigDecimal("31536000") // 365 * 24 * 60 * 60
internal val HUNDRED_BD: BigDecimal = BigDecimal("100")
internal const val SCALE: Int = 18
private val LN_10 = ln(10.0)
private const val EPSILON = 1e-18
internal const val MIN_DECIMALS: Int = 3
internal const val FIAT_MIN_DECIMALS: Int = 2
internal const val FIAT_MAX_DECIMALS: Int = 12
internal val LN_10: Double = ln(10.0)
internal const val EPSILON: Double = 1e-18
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.domain.yield.supply.usecase
import com.tangem.domain.yield.supply.YieldSupplyRepository
import kotlinx.coroutines.flow.Flow
class YieldSupplyGetShouldShowMainPromoUseCase(
private val yieldSupplyRepository: YieldSupplyRepository,
) {
operator fun invoke(): Flow<Boolean> {
return yieldSupplyRepository.getShouldShowYieldPromoBanner()
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.yield.supply.usecase
import com.tangem.domain.yield.supply.YieldSupplyRepository
class YieldSupplySetShouldShowMainPromoUseCase(
private val yieldSupplyRepository: YieldSupplyRepository,
) {
suspend operator fun invoke(shouldShow: Boolean) {
yieldSupplyRepository.setShouldShowYieldPromoBanner(shouldShow)
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.domain.yield.supply.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.appcurrency.model.AppCurrency
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class YieldSupplyGetDustMinAmountUseCaseTest {
private val useCase = YieldSupplyGetDustMinAmountUseCase()
@Test
fun `GIVEN supported currency WHEN invoke THEN return dust min amount`() {
val minAmount = BigDecimal("123.456")
val appCurrency = AppCurrency(code = "EUR", name = "Euro", symbol = "")
val result = useCase(minAmount, appCurrency)
assertThat(result).isEqualTo(BigDecimal("0.1"))
}
@Test
fun `GIVEN unsupported currency WHEN invoke THEN return min amount stripped`() {
val minAmount = BigDecimal("1.2300")
val appCurrency = AppCurrency(code = "JPY", name = "Japanese Yen", symbol = "¥")
val result = useCase(minAmount, appCurrency)
assertThat(result).isEqualTo(BigDecimal("1.23"))
}
}

View file

@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.anyDecimals
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -11,6 +12,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetRewardsBalanceUseCase.Companion.TICK_MILLIS
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.mockk
@ -26,7 +28,6 @@ import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.ceil
import kotlin.math.ln
class YieldSupplyGetRewardsBalanceUseCaseTest {
@ -165,9 +166,9 @@ class YieldSupplyGetRewardsBalanceUseCaseTest {
val deferred = async { useCase(status, appCurrency).take(3).toList() }
testScheduler.advanceUntilIdle()
advanceTimeBy(300)
advanceTimeBy(TICK_MILLIS)
testScheduler.advanceUntilIdle()
advanceTimeBy(300)
advanceTimeBy(TICK_MILLIS)
testScheduler.advanceUntilIdle()
val collected = deferred.await()
@ -175,25 +176,147 @@ class YieldSupplyGetRewardsBalanceUseCaseTest {
val expectedDecimals = calculateMinVisibleDecimalsForTest(initialPerTickDelta(amount, apy))
val firstExpected = amount.format { fiat(
val firstExpected = amount.format {
fiat(
appCurrency.code,
appCurrency.symbol,
).anyDecimals(decimals = expectedDecimals) }
assertThat(collected[0]).isEqualTo(firstExpected)
).anyDecimals(decimals = expectedDecimals)
}
assertThat(collected[0].fiatBalance).isEqualTo(firstExpected)
val firstNext = nextBalance(amount, apy)
val secondExpected = firstNext.format { fiat(
val secondExpected = firstNext.format {
fiat(
appCurrency.code,
appCurrency.symbol,
).anyDecimals(decimals = expectedDecimals) }
assertThat(collected[1]).isEqualTo(secondExpected)
).anyDecimals(decimals = expectedDecimals)
}
assertThat(collected[1].fiatBalance).isEqualTo(secondExpected)
val secondNext = nextBalance(firstNext, apy)
val thirdExpected = secondNext.format { fiat(
val thirdExpected = secondNext.format {
fiat(
appCurrency.code,
appCurrency.symbol,
).anyDecimals(decimals = expectedDecimals) }
assertThat(collected[2]).isEqualTo(thirdExpected)
).anyDecimals(decimals = expectedDecimals)
}
assertThat(collected[2].fiatBalance).isEqualTo(thirdExpected)
}
@Test
fun `GIVEN polygon USDT0 Loaded status WHEN invoke THEN emit formatted balances`() = runTest {
val network = Network(
id = Network.ID(Network.RawID("POLYGON"), Network.DerivationPath.Card("m/44'/60'/0'/0/0")),
backendId = "polygon-pos",
name = "Polygon",
currencySymbol = "POL",
derivationPath = Network.DerivationPath.Card("m/44'/60'/0'/0/0"),
isTestnet = false,
standardType = Network.StandardType.Unspecified("Polygon"),
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
val tokenId = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(network.rawId),
suffix = CryptoCurrency.ID.Suffix.RawID("usdt0", "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"),
)
val currency = CryptoCurrency.Token(
id = tokenId,
network = network,
name = "USDT0",
symbol = "USDT0",
decimals = 6,
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/usdt0.png",
isCustom = false,
contractAddress = "0xc2132d05d31c914a87c6611c10748aeb04b58e8f",
)
val amount = BigDecimal("9.241136")
val fiatRate = BigDecimal("0.9999761277273864")
val status = CryptoCurrencyStatus(
currency = currency,
value = CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = amount.multiply(fiatRate),
fiatRate = fiatRate,
priceChange = BigDecimal("-0.000058200000000008245"),
yieldBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = NetworkAddress.Single(
NetworkAddress.Address(
value = "0xb71fa0E20ba8579B3ec51cC79aaa84Bf5982BB49",
type = NetworkAddress.Address.Type.Primary,
),
),
sources = CryptoCurrencyStatus.Sources(),
),
)
val apy = BigDecimal("5.0")
coEvery { repository.getCachedMarkets() } returns listOf(
YieldMarketToken(
tokenAddress = currency.contractAddress,
chainId = 137,
apy = apy,
isActive = true,
maxFeeNative = BigDecimal.ZERO,
maxFeeUSD = BigDecimal.ZERO,
backendId = "polygon-pos",
),
)
val dispatcherProvider = testDispatcherProvider(this)
val useCase = YieldSupplyGetRewardsBalanceUseCase(repository, dispatcherProvider)
val appCurrency = AppCurrency.Default
val deferred = async { useCase(status, appCurrency).take(2).toList() }
testScheduler.advanceUntilIdle()
advanceTimeBy(TICK_MILLIS)
testScheduler.advanceUntilIdle()
val emissions = deferred.await()
assertThat(emissions).hasSize(2)
val apyFraction = apy.divide(BigDecimal("100"), 18, RoundingMode.HALF_UP)
val perTickCrypto = amount.multiply(apyFraction)
.multiply(YieldSupplyGetRewardsBalanceUseCase.TICK_SECONDS_BD)
.divide(
YieldSupplyGetRewardsBalanceUseCase.SECONDS_PER_YEAR_BD,
YieldSupplyGetRewardsBalanceUseCase.SCALE,
RoundingMode.HALF_UP,
)
.abs()
val minCryptoDecimals = calculateMinVisibleDecimalsForTest(perTickCrypto).coerceAtMost(currency.decimals)
val fiatAmountStart = amount.multiply(fiatRate)
val perTickFiat = fiatAmountStart.multiply(apyFraction)
.multiply(YieldSupplyGetRewardsBalanceUseCase.TICK_SECONDS_BD)
.divide(
YieldSupplyGetRewardsBalanceUseCase.SECONDS_PER_YEAR_BD,
YieldSupplyGetRewardsBalanceUseCase.SCALE,
RoundingMode.HALF_UP,
)
.abs()
val minFiatDecimals = calculateMinVisibleDecimalsForTest(perTickFiat)
val expectedCrypto0 = amount.format {
crypto(currency).anyDecimals(
maxDecimals = minCryptoDecimals,
minDecimals = minCryptoDecimals,
)
}
val expectedFiat0 = fiatAmountStart.format {
fiat(appCurrency.code, appCurrency.symbol).anyDecimals(decimals = minFiatDecimals)
}
assertThat(emissions[0].cryptoBalance).isEqualTo(expectedCrypto0)
assertThat(emissions[0].fiatBalance).isEqualTo(expectedFiat0)
}
private fun testDispatcherProvider(scope: TestScope): CoroutineDispatcherProvider {
@ -260,42 +383,48 @@ class YieldSupplyGetRewardsBalanceUseCaseTest {
}
private fun initialPerTickDelta(amount: BigDecimal, apy: BigDecimal): BigDecimal {
val apyFraction = apy.divide(HUNDRED_BD, SCALE, RoundingMode.HALF_UP)
val apyFraction = apy.divide(
YieldSupplyGetRewardsBalanceUseCase.HUNDRED_BD,
YieldSupplyGetRewardsBalanceUseCase.SCALE,
RoundingMode.HALF_UP,
)
return amount
.multiply(apyFraction)
.multiply(TICK_SECONDS_BD)
.divide(SECONDS_PER_YEAR_BD, SCALE, RoundingMode.HALF_UP)
.multiply(YieldSupplyGetRewardsBalanceUseCase.TICK_SECONDS_BD)
.divide(
YieldSupplyGetRewardsBalanceUseCase.SECONDS_PER_YEAR_BD,
YieldSupplyGetRewardsBalanceUseCase.SCALE,
RoundingMode.HALF_UP,
)
.abs()
}
private fun nextBalance(current: BigDecimal, apy: BigDecimal): BigDecimal {
val apyFraction = apy.divide(HUNDRED_BD, SCALE, RoundingMode.HALF_UP)
val apyFraction = apy.divide(
YieldSupplyGetRewardsBalanceUseCase.HUNDRED_BD,
YieldSupplyGetRewardsBalanceUseCase.SCALE,
RoundingMode.HALF_UP,
)
val perTickDelta = current
.multiply(apyFraction)
.multiply(TICK_SECONDS_BD)
.divide(SECONDS_PER_YEAR_BD, SCALE, RoundingMode.HALF_UP)
.multiply(YieldSupplyGetRewardsBalanceUseCase.TICK_SECONDS_BD)
.divide(
YieldSupplyGetRewardsBalanceUseCase.SECONDS_PER_YEAR_BD,
YieldSupplyGetRewardsBalanceUseCase.SCALE,
RoundingMode.HALF_UP,
)
return current.add(perTickDelta)
}
private fun calculateMinVisibleDecimalsForTest(perTickDeltaAbs: BigDecimal): Int {
if (perTickDeltaAbs <= BigDecimal.ZERO) return MIN_DECIMALS
if (perTickDeltaAbs <= BigDecimal.ZERO) return YieldSupplyGetRewardsBalanceUseCase.MIN_DECIMALS
val perTickAsDouble = perTickDeltaAbs.toDouble()
if (perTickAsDouble.isNaN() || perTickAsDouble.isInfinite()) return MIN_DECIMALS
val safe = if (perTickAsDouble <= 0.0) EPSILON else perTickAsDouble
val raw = ceil(-ln(safe) / LN_10)
return raw.toInt().coerceIn(MIN_DECIMALS, MAX_DECIMALS)
}
private companion object {
private const val SCALE = 18
private val TICK_SECONDS_BD = BigDecimal("0.3")
private val SECONDS_PER_YEAR_BD = BigDecimal("31536000")
private val HUNDRED_BD = BigDecimal("100")
private const val MIN_DECIMALS = 3
private const val MAX_DECIMALS = 8
private val LN_10 = ln(10.0)
private const val EPSILON = 1e-18
if (perTickAsDouble.isNaN() || perTickAsDouble.isInfinite()) return YieldSupplyGetRewardsBalanceUseCase.MIN_DECIMALS
val safe = if (perTickAsDouble <= 0.0) YieldSupplyGetRewardsBalanceUseCase.EPSILON else perTickAsDouble
val raw = ceil(-kotlin.math.ln(safe) / YieldSupplyGetRewardsBalanceUseCase.LN_10)
return raw.toInt().coerceIn(
YieldSupplyGetRewardsBalanceUseCase.MIN_DECIMALS,
YieldSupplyGetRewardsBalanceUseCase.FIAT_MAX_DECIMALS,
)
}
}

View file

@ -88,6 +88,7 @@ dependencies {
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.yieldSupply.models)
/** Temp dependency to swap domain */
implementation(projects.features.swap.domain)

View file

@ -121,7 +121,7 @@ internal object TokenDetailsPreviewData {
selectedBalanceType = BalanceType.ALL,
onBalanceSelect = {},
displayCryptoBalance = "966,96 XLM",
displayYeildSupplyCryptoBalance = null,
displayYieldSupplyFiatBalance = null,
displayFiatBalance = "91,50$",
isBalanceSelectorEnabled = true,
isBalanceFlickering = false,

View file

@ -75,6 +75,7 @@ import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase
import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetRewardsBalanceUseCase
import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionListener
import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter
@ -416,7 +417,9 @@ internal class TokenDetailsModel @Inject constructor(
.saveIn(yieldSupplyBalanceJobHolder)
} else {
yieldSupplyBalanceJobHolder.cancel()
internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(null)
internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(
YieldSupplyRewardBalance.empty(),
)
}
}
@ -1251,14 +1254,8 @@ internal class TokenDetailsModel @Inject constructor(
}
private fun handleNavigationParam() {
when (val action = params.navigationAction) {
is NavigationAction.Staking -> openStaking()
is NavigationAction.YieldSupply -> if (action.isActive) {
modelScope.launch(dispatchers.default) {
fetchCurrencyStatusUseCase(userWalletId = userWalletId, id = cryptoCurrency.id)
}
}
else -> Unit
if (params.navigationAction is NavigationAction.Staking) {
openStaking()
}
}

View file

@ -28,7 +28,8 @@ internal sealed class TokenDetailsBalanceBlockState {
val isBalanceSelectorEnabled: Boolean,
val isBalanceFlickering: Boolean,
val yieldSupplyState: TokenDetailsYieldSupplyState = TokenDetailsYieldSupplyState.Empty,
val displayYeildSupplyCryptoBalance: String? = null,
val displayYieldSupplyFiatBalance: String? = null,
val displayYieldSupplyCryptoBalance: String? = null,
) : TokenDetailsBalanceBlockState()
data class Error(

View file

@ -98,8 +98,8 @@ internal class TokenDetailsLoadedBalanceConverter(
stakingCryptoAmount,
currentState.selectedBalanceType,
),
displayYeildSupplyCryptoBalance = (currentState as? TokenDetailsBalanceBlockState.Content)
?.displayYeildSupplyCryptoBalance,
displayYieldSupplyFiatBalance = (currentState as? TokenDetailsBalanceBlockState.Content)
?.displayYieldSupplyFiatBalance,
balanceSegmentedButtonConfig = currentState.balanceSegmentedButtonConfig,
onBalanceSelect = clickIntents::onBalanceSelect,
selectedBalanceType = currentState.selectedBalanceType,

View file

@ -26,6 +26,7 @@ import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase
import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsAppBarMenuConfig
@ -315,13 +316,18 @@ internal class TokenDetailsStateFactory(
return balanceSelectStateConverter.convert(buttonConfig)
}
fun getStateWithUpdatedYieldSupplyDisplayBalance(displayBalance: String?): TokenDetailsState {
fun getStateWithUpdatedYieldSupplyDisplayBalance(
yieldSupplyRewardBalance: YieldSupplyRewardBalance,
): TokenDetailsState {
val state = currentStateProvider()
val balanceState = state.tokenBalanceBlockState
return state.copy(
tokenBalanceBlockState = when (balanceState) {
is TokenDetailsBalanceBlockState.Content ->
balanceState.copy(displayYeildSupplyCryptoBalance = displayBalance)
balanceState.copy(
displayYieldSupplyFiatBalance = yieldSupplyRewardBalance.fiatBalance,
displayYieldSupplyCryptoBalance = yieldSupplyRewardBalance.cryptoBalance,
)
is TokenDetailsBalanceBlockState.Error -> balanceState
is TokenDetailsBalanceBlockState.Loading -> balanceState
},

View file

@ -122,12 +122,12 @@ private fun FiatBalance(
height = TangemTheme.dimens.size32,
),
)
is TokenDetailsBalanceBlockState.Content -> if (state.displayYeildSupplyCryptoBalance != null &&
is TokenDetailsBalanceBlockState.Content -> if (state.displayYieldSupplyFiatBalance != null &&
!isBalanceHidden
) {
TextAnimatedCounter(
modifier = modifier,
text = state.displayYeildSupplyCryptoBalance,
text = state.displayYieldSupplyFiatBalance,
style = TangemTheme.typography.h2.applyBladeBrush(
isEnabled = state.isBalanceFlickering,
textColor = TangemTheme.colors.text.primary1,
@ -136,7 +136,7 @@ private fun FiatBalance(
} else {
Text(
modifier = modifier,
text = (state.displayYeildSupplyCryptoBalance ?: state.displayFiatBalance).orMaskWithStars(
text = (state.displayYieldSupplyFiatBalance ?: state.displayFiatBalance).orMaskWithStars(
isBalanceHidden,
),
style = TangemTheme.typography.h2.applyBladeBrush(
@ -184,8 +184,9 @@ private fun CryptoBalance(
tint = TangemTheme.colors.icon.inactive,
contentDescription = null,
)
Text(
text = state.displayCryptoBalance.orMaskWithStars(isBalanceHidden),
TextAnimatedCounter(
text = (state.displayYieldSupplyCryptoBalance ?: state.displayCryptoBalance)
.orMaskWithStars(isBalanceHidden),
style = TangemTheme.typography.caption2.applyBladeBrush(
isEnabled = state.isBalanceFlickering,
textColor = TangemTheme.colors.text.tertiary,

View file

@ -41,7 +41,9 @@ internal class TxHistoryItemToTransactionStateConverter(
R.drawable.ic_close_24
} else {
when (type) {
is TransactionType.Approve -> R.drawable.ic_doc_24
is TransactionType.YieldSupply.DeployContract,
is TransactionType.Approve,
-> R.drawable.ic_doc_24
is TransactionType.Staking.Stake,
is TransactionType.Staking.Vote,
is TransactionType.Staking.Restake,
@ -51,11 +53,16 @@ internal class TxHistoryItemToTransactionStateConverter(
is TransactionType.Staking.Unstake,
is TransactionType.Staking.Withdraw,
-> R.drawable.ic_transaction_history_unstaking_24
is TransactionType.YieldSupply.Enter -> R.drawable.ic_connect_24
is TransactionType.YieldSupply.InitializeToken -> R.drawable.ic_gear_24
is TransactionType.YieldSupply.ReactivateToken -> R.drawable.ic_refresh_24
is TransactionType.YieldSupply.Exit -> R.drawable.ic_disconnect_24
is TransactionType.Operation,
is TransactionType.Swap,
is TransactionType.Transfer,
is TransactionType.YieldSupply,
is TransactionType.UnknownOperation,
TransactionType.YieldSupply.Send,
TransactionType.YieldSupply.Topup,
-> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24
}
}
@ -66,26 +73,55 @@ internal class TxHistoryItemToTransactionStateConverter(
is TransactionType.Operation -> stringReference(type.name)
is TransactionType.Swap -> resourceReference(R.string.common_swap)
is TransactionType.Transfer -> resourceReference(R.string.common_transfer)
is TransactionType.YieldSupply.Enter -> resourceReference(R.string.yield_module_transaction_enter)
is TransactionType.YieldSupply.Exit -> resourceReference(R.string.yield_module_transaction_exit)
is TransactionType.YieldSupply.Topup -> resourceReference(R.string.yield_module_transaction_topup)
is TransactionType.Staking.Stake -> resourceReference(R.string.common_stake)
is TransactionType.Staking.Unstake -> resourceReference(R.string.common_unstake)
is TransactionType.Staking.Vote -> resourceReference(R.string.staking_vote)
is TransactionType.Staking.ClaimRewards -> resourceReference(R.string.common_claim_rewards)
is TransactionType.Staking.Withdraw -> resourceReference(R.string.staking_withdraw)
is TransactionType.Staking.Restake -> resourceReference(R.string.staking_restake)
is TransactionType.YieldSupply -> when (type) {
is TransactionType.YieldSupply.Enter -> resourceReference(R.string.yield_module_transaction_enter)
is TransactionType.YieldSupply.Exit -> resourceReference(R.string.yield_module_transaction_exit)
TransactionType.YieldSupply.Topup -> resourceReference(R.string.yield_module_transaction_topup)
TransactionType.YieldSupply.Send -> if (isOutgoing) {
resourceReference(R.string.common_transfer)
} else {
resourceReference(R.string.yield_module_transaction_withdraw)
}
is TransactionType.YieldSupply.DeployContract -> resourceReference(
R.string
.yield_module_transaction_deploy_contract,
)
is TransactionType.YieldSupply.InitializeToken -> resourceReference(
R.string
.yield_module_transaction_initialize,
)
is TransactionType.YieldSupply.ReactivateToken -> resourceReference(
R.string
.yield_module_transaction_reactivate,
)
}
is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation)
}
private fun TxInfo.extractSubtitle(): TextReference {
return when (this.type) {
return when (val type = this.type) {
is TransactionType.YieldSupply -> if (currency is CryptoCurrency.Coin) {
if (type == TransactionType.YieldSupply.Send) {
extractSubtitleByAddressType()
} else {
resourceReference(
R.string.transaction_history_transaction_for_address,
wrappedList(type.address?.toBriefAddressFormat().orEmpty()),
)
}
} else {
when (type) {
is TransactionType.YieldSupply.Enter -> {
val amount = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
resourceReference(R.string.yield_module_transaction_enter_subtitle, wrappedList(amount))
}
is TransactionType.YieldSupply.Topup,
-> {
TransactionType.YieldSupply.Topup -> {
val amount = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
resourceReference(R.string.yield_module_transaction_topup_subtitle, wrappedList(amount))
}
@ -93,6 +129,21 @@ internal class TxHistoryItemToTransactionStateConverter(
val amount = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
resourceReference(R.string.yield_module_transaction_exit_subtitle, wrappedList(amount))
}
TransactionType.YieldSupply.Send -> {
if (isOutgoing) {
extractSubtitleByAddressType()
} else {
val amount =
amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
resourceReference(
R.string.yield_module_transaction_exit_subtitle,
wrappedList(amount),
)
}
}
else -> extractSubtitleByAddressType()
}
}
else -> extractSubtitleByAddressType()
}
}
@ -133,15 +184,22 @@ internal class TxHistoryItemToTransactionStateConverter(
@Suppress("ComplexCondition")
private fun TxInfo.getAmount(): String {
if (type is TransactionType.Staking.Vote ||
type == TransactionType.Staking.ClaimRewards ||
type == TransactionType.Staking.Withdraw ||
type == TransactionType.YieldSupply.Enter ||
type == TransactionType.YieldSupply.Exit ||
type == TransactionType.YieldSupply.Topup
) {
when (type) {
is TransactionType.Staking.Vote,
TransactionType.Staking.ClaimRewards,
TransactionType.Staking.Withdraw,
-> return ""
is TransactionType.YieldSupply -> if (currency is CryptoCurrency.Token) {
when (type) {
TransactionType.YieldSupply.Send -> if (!isOutgoing) {
return ""
}
else -> return ""
}
}
else -> Unit
}
val prefix = when {
status == TxInfo.TransactionStatus.Failed -> ""
this.amount.isZero() -> ""

View file

@ -21,6 +21,7 @@ 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.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplySetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.presentation.account.AccountDependencies
import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory
import com.tangem.feature.wallet.presentation.wallet.domain.unwrap
@ -57,6 +58,8 @@ internal interface WalletContentClickIntents {
apy: String,
)
fun onYieldPromoCloseClick()
fun onAccountExpandClick(account: Account)
fun onAccountCollapseClick(account: Account)
@ -94,6 +97,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val accountDependencies: AccountDependencies,
private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase,
private val yieldSupplySetShouldShowMainPromoUseCase: YieldSupplySetShouldShowMainPromoUseCase,
) : BaseWalletClickIntents(), WalletContentClickIntents {
override fun onDetailsClick() {
@ -193,6 +197,12 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
}
}
override fun onYieldPromoCloseClick() {
modelScope.launch {
yieldSupplySetShouldShowMainPromoUseCase(false)
}
}
override fun onAccountExpandClick(account: Account) {
val userWalletId = stateHolder.getSelectedWalletId()
accountDependencies.expandedAccountsHolder.expandAccount(userWalletId, account.accountId)

View file

@ -11,6 +11,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsAnalyticsSender
@ -45,6 +46,7 @@ internal class MultiWalletContentLoader(
private val currenciesRepository: CurrenciesRepository,
private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
private val stakingApyFlowUseCase: StakingApyFlowUseCase,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory,
@ -63,6 +65,7 @@ internal class MultiWalletContentLoader(
applyTokenListSortingUseCase = applyTokenListSortingUseCase,
yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase,
stakingApyFlowUseCase = stakingApyFlowUseCase,
yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase,
).let(::add)
WalletNFTListSubscriber(

View file

@ -11,6 +11,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsAnalyticsSender
@ -44,6 +45,7 @@ internal class MultiWalletContentLoaderFactory @Inject constructor(
private val currenciesRepository: CurrenciesRepository,
private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
private val stakingApyFlowUseCase: StakingApyFlowUseCase,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory,
@ -72,6 +74,7 @@ internal class MultiWalletContentLoaderFactory @Inject constructor(
hotWalletFeatureToggles = hotWalletFeatureToggles,
tangemPayFeatureToggles = tangemPayFeatureToggles,
tangemPayMainSubscriberFactory = tangemPayMainSubscriberFactory,
yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase,
)
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.staking.usecase.StakingApyFlowUseCase
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsAnalyticsSender
@ -35,6 +36,7 @@ internal class SingleWalletWithTokenContentLoader(
private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
private val stakingApyFlowUseCase: StakingApyFlowUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
) : WalletContentLoader(id = userWallet.walletId) {
override fun create(): List<WalletSubscriber> {
@ -49,6 +51,7 @@ internal class SingleWalletWithTokenContentLoader(
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase,
stakingApyFlowUseCase = stakingApyFlowUseCase,
yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase,
).let(::add)
MultiWalletWarningsSubscriber(
userWallet = userWallet,

View file

@ -7,6 +7,7 @@ import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.staking.usecase.StakingApyFlowUseCase
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsAnalyticsSender
@ -36,6 +37,7 @@ internal class SingleWalletWithTokenContentLoaderFactory @Inject constructor(
private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
private val stakingApyFlowUseCase: StakingApyFlowUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
) {
fun create(userWallet: UserWallet.Cold, clickIntents: WalletClickIntents): SingleWalletWithTokenContentLoader {
@ -55,6 +57,7 @@ internal class SingleWalletWithTokenContentLoaderFactory @Inject constructor(
yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase,
stakingApyFlowUseCase = stakingApyFlowUseCase,
hotWalletFeatureToggles = hotWalletFeatureToggles,
yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase,
)
}
}

View file

@ -11,14 +11,16 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.converte
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.TokenListStateConverter
import com.tangem.feature.wallet.presentation.wallet.state.utils.enableButtons
import timber.log.Timber
import java.math.BigDecimal
internal class SetTokenListTransformer(
private val params: TokenConverterParams,
private val userWallet: UserWallet,
private val appCurrency: AppCurrency,
private val clickIntents: WalletClickIntents,
private val yieldSupplyApyMap: Map<String, String> = emptyMap(),
private val yieldSupplyApyMap: Map<String, BigDecimal> = emptyMap(),
private val stakingApyMap: Map<String, List<Yield.Validator>> = emptyMap(),
private val shouldShowMainPromo: Boolean,
) : WalletStateTransformer(userWallet.walletId) {
override fun transform(prevState: WalletState): WalletState {
@ -63,6 +65,7 @@ internal class SetTokenListTransformer(
clickIntents = clickIntents,
yieldModuleApyMap = yieldSupplyApyMap,
stakingApyMap = stakingApyMap,
shouldShowMainPromo = shouldShowMainPromo,
).convert(value = this)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.domain.models.wallet.UserWallet
@ -17,6 +18,7 @@ internal class SetTxHistoryCountErrorTransformer(
private val error: TxHistoryStateError,
private val pendingTransactions: Set<TxInfo>,
private val clickIntents: WalletClickIntents,
private val currency: CryptoCurrency,
) : WalletStateTransformer(userWallet.walletId) {
private val txHistoryItemConverter by lazy {
@ -26,6 +28,7 @@ internal class SetTxHistoryCountErrorTransformer(
}
TxHistoryItemStateConverter(
currency = currency,
symbol = blockchain.currency,
decimals = blockchain.decimals(),
clickIntents = clickIntents,

View file

@ -28,17 +28,25 @@ import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState.OrganizeTokensButtonConfig as WalletOrganizeTokensButtonConfig
@Suppress("LongParameterList")
internal class TokenListStateConverter(
private val appCurrency: AppCurrency,
private val params: TokenConverterParams,
private val selectedWallet: UserWallet,
private val clickIntents: WalletClickIntents,
private val yieldModuleApyMap: Map<String, String>,
private val yieldModuleApyMap: Map<String, BigDecimal>,
private val stakingApyMap: Map<String, List<Yield.Validator>>,
private val shouldShowMainPromo: Boolean,
) : Converter<WalletTokensListState, WalletTokensListState> {
private val yieldSupplyPromoBannerKeyConverter = YieldSupplyPromoBannerKeyConverter(
yieldModuleApyMap,
shouldShowMainPromo,
)
private val onTokenClick: (accountId: AccountId?, currencyStatus: CryptoCurrencyStatus) -> Unit =
{ accountId, currencyStatus ->
clickIntents.onTokenItemClick(selectedWallet.walletId, currencyStatus)
@ -63,10 +71,12 @@ internal class TokenListStateConverter(
private fun tokenStatusConverter(accountId: AccountId? = null) = TokenItemStateConverter(
appCurrency = appCurrency,
yieldModuleApyMap = yieldModuleApyMap,
yieldSupplyPromoBannerKey = yieldSupplyPromoBannerKeyConverter.convert(params),
stakingApyMap = stakingApyMap,
onItemClick = { _, status -> onTokenClick(accountId, status) },
onItemLongClick = { _, status -> onTokenLongClick(accountId, status) },
onApyLabelClick = { status, apySource, apy -> onApyLabelClick(status, apySource, apy) },
onYieldPromoCloseClick = clickIntents::onYieldPromoCloseClick,
)
override fun convert(value: WalletTokensListState): WalletTokensListState {

View file

@ -9,6 +9,7 @@ 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.toTimeFormat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionStatus
import com.tangem.domain.models.network.TxInfo.TransactionType
@ -20,6 +21,7 @@ import com.tangem.utils.converter.Converter
import com.tangem.utils.toBriefAddressFormat
internal class TxHistoryItemStateConverter(
private val currency: CryptoCurrency,
private val symbol: String,
private val decimals: Int,
private val clickIntents: WalletClickIntents,
@ -49,7 +51,9 @@ internal class TxHistoryItemStateConverter(
R.drawable.ic_close_24
} else {
when (type) {
is TransactionType.Approve -> R.drawable.ic_doc_24
is TransactionType.YieldSupply.DeployContract,
is TransactionType.Approve,
-> R.drawable.ic_doc_24
is TransactionType.Staking.Stake,
is TransactionType.Staking.Vote,
is TransactionType.Staking.Restake,
@ -59,11 +63,16 @@ internal class TxHistoryItemStateConverter(
is TransactionType.Staking.Unstake,
is TransactionType.Staking.Withdraw,
-> R.drawable.ic_transaction_history_unstaking_24
is TransactionType.YieldSupply.Enter -> R.drawable.ic_connect_24
is TransactionType.YieldSupply.InitializeToken -> R.drawable.ic_gear_24
is TransactionType.YieldSupply.ReactivateToken -> R.drawable.ic_refresh_24
is TransactionType.YieldSupply.Exit -> R.drawable.ic_disconnect_24
is TransactionType.Operation,
is TransactionType.Swap,
is TransactionType.Transfer,
is TransactionType.YieldSupply,
is TransactionType.UnknownOperation,
TransactionType.YieldSupply.Send,
TransactionType.YieldSupply.Topup,
-> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24
}
}
@ -74,32 +83,81 @@ internal class TxHistoryItemStateConverter(
is TransactionType.Operation -> stringReference(type.name)
is TransactionType.Swap -> resourceReference(R.string.common_swap)
is TransactionType.Transfer -> resourceReference(R.string.common_transfer)
is TransactionType.YieldSupply.Enter -> resourceReference(R.string.yield_module_transaction_enter)
is TransactionType.YieldSupply.Exit -> resourceReference(R.string.yield_module_transaction_exit)
is TransactionType.YieldSupply.Topup -> resourceReference(R.string.yield_module_transaction_topup)
is TransactionType.Staking.Stake -> resourceReference(R.string.common_stake)
is TransactionType.Staking.Unstake -> resourceReference(R.string.common_unstake)
is TransactionType.Staking.Vote -> resourceReference(R.string.staking_vote)
is TransactionType.Staking.ClaimRewards -> resourceReference(R.string.common_claim_rewards)
is TransactionType.Staking.Withdraw -> resourceReference(R.string.staking_withdraw)
is TransactionType.Staking.Restake -> resourceReference(R.string.staking_restake)
is TransactionType.YieldSupply -> when (type) {
is TransactionType.YieldSupply.Enter -> resourceReference(R.string.yield_module_transaction_enter)
is TransactionType.YieldSupply.Exit -> resourceReference(R.string.yield_module_transaction_exit)
TransactionType.YieldSupply.Topup -> resourceReference(R.string.yield_module_transaction_topup)
TransactionType.YieldSupply.Send -> if (isOutgoing) {
resourceReference(R.string.common_transfer)
} else {
resourceReference(R.string.yield_module_transaction_withdraw)
}
is TransactionType.YieldSupply.DeployContract -> resourceReference(
R.string
.yield_module_transaction_deploy_contract,
)
is TransactionType.YieldSupply.InitializeToken -> resourceReference(
R.string
.yield_module_transaction_initialize,
)
is TransactionType.YieldSupply.ReactivateToken -> resourceReference(
R.string
.yield_module_transaction_reactivate,
)
}
is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation)
}
private fun TxInfo.extractSubtitle(): TextReference {
return when (this.type) {
return when (val type = this.type) {
is TransactionType.YieldSupply -> if (currency is CryptoCurrency.Coin) {
resourceReference(
R.string.transaction_history_transaction_for_address,
wrappedList(type.address?.toBriefAddressFormat().orEmpty()),
)
} else {
when (type) {
is TransactionType.YieldSupply.Enter -> {
val amount = amount.format { crypto(symbol = symbol, decimals = decimals) }
resourceReference(R.string.yield_module_transaction_enter_subtitle, wrappedList(amount))
val amount = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
resourceReference(
R.string.yield_module_transaction_enter_subtitle,
wrappedList(amount),
)
}
TransactionType.YieldSupply.Topup -> {
val amount = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
resourceReference(
R.string.yield_module_transaction_topup_subtitle,
wrappedList(amount),
)
}
TransactionType.YieldSupply.Send -> {
if (isOutgoing) {
extractSubtitleByAddressType()
} else {
val amount =
amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
resourceReference(
R.string.yield_module_transaction_exit_subtitle,
wrappedList(amount),
)
}
is TransactionType.YieldSupply.Topup,
-> {
val amount = amount.format { crypto(symbol = symbol, decimals = decimals) }
resourceReference(R.string.yield_module_transaction_topup_subtitle, wrappedList(amount))
}
is TransactionType.YieldSupply.Exit -> {
val amount = amount.format { crypto(symbol = symbol, decimals = decimals) }
resourceReference(R.string.yield_module_transaction_exit_subtitle, wrappedList(amount))
val amount = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
resourceReference(
R.string.yield_module_transaction_exit_subtitle,
wrappedList(amount),
)
}
else -> extractSubtitleByAddressType()
}
}
else -> extractSubtitleByAddressType()
}
@ -147,15 +205,19 @@ internal class TxHistoryItemStateConverter(
@Suppress("ComplexCondition")
private fun TxInfo.getAmount(): String {
if (type is TransactionType.Staking.Vote ||
type == TransactionType.Staking.ClaimRewards ||
type == TransactionType.Staking.Withdraw ||
type == TransactionType.YieldSupply.Enter ||
type == TransactionType.YieldSupply.Exit ||
type == TransactionType.YieldSupply.Topup
) {
when (type) {
is TransactionType.Staking.Vote,
TransactionType.Staking.ClaimRewards,
TransactionType.Staking.Withdraw,
-> return ""
is TransactionType.YieldSupply -> {
if (currency is CryptoCurrency.Token && type == TransactionType.YieldSupply.Send && !isOutgoing) {
return ""
}
}
else -> Unit
}
val prefix = when {
status == TransactionStatus.Failed -> ""
this.amount.isZero() -> ""

View file

@ -0,0 +1,48 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.feature.wallet.presentation.wallet.state.transformers.TokenConverterParams
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class YieldSupplyPromoBannerKeyConverter(
private val yieldModuleApyMap: Map<String, BigDecimal>,
private val shouldShowMainPromo: Boolean,
) : Converter<TokenConverterParams, String?> {
override fun convert(value: TokenConverterParams): String? {
if (!shouldShowMainPromo) return null
val currencies = when (value) {
is TokenConverterParams.Wallet -> value.tokenList.flattenCurrencies()
is TokenConverterParams.Account -> value.accountList.flattenCurrencies()
}.filter { status ->
status.value is CryptoCurrencyStatus.Loaded ||
status.value is CryptoCurrencyStatus.Custom
}
val tokens = currencies.filter { it.currency is CryptoCurrency.Token }
if (tokens.any { it.value.yieldSupplyStatus?.isActive == true }) return null
if (yieldModuleApyMap.isEmpty()) return null
val max = tokens.asSequence()
.mapNotNull { status ->
val token = status.currency as? CryptoCurrency.Token ?: return@mapNotNull null
val tokenKey = token.yieldSupplyKey()
val shouldIgnoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId)
val matchedKey = yieldModuleApyMap.keys.firstOrNull { mapKey ->
mapKey.equals(tokenKey, shouldIgnoreCase)
} ?: return@mapNotNull null
status to matchedKey
}
.maxByOrNull { (status, _) -> status.value.amount ?: BigDecimal.ZERO }
return max?.second
}
}

View file

@ -5,16 +5,18 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.usecase.StakingApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.account.AccountDependencies
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.utils.coroutines.combine6
import com.tangem.utils.coroutines.combine7
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import java.math.BigDecimal
/**
* Subscriber that monitors account list related data and updates the wallet state accordingly.
@ -30,19 +32,21 @@ internal class AccountListSubscriber @AssistedInject constructor(
override val clickIntents: WalletClickIntents,
private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
private val stakingApyFlowUseCase: StakingApyFlowUseCase,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
) : BasicAccountListSubscriber() {
override fun create(coroutineScope: CoroutineScope): Flow<*> = combine6(
override fun create(coroutineScope: CoroutineScope): Flow<*> = combine7(
flow1 = getAccountStatusListFlow(),
flow2 = getAppCurrencyFlow(),
flow3 = accountDependencies.expandedAccountsHolder.expandedAccounts(userWallet),
flow4 = accountDependencies.isAccountsModeEnabledUseCase(),
flow5 = yieldSupplyApyFlow(),
flow6 = stakingApyFlow(),
flow7 = yieldSupplyGetShouldShowMainPromoFlow(),
transform = ::updateState,
)
private fun yieldSupplyApyFlow(): Flow<Map<String, String>> {
private fun yieldSupplyApyFlow(): Flow<Map<String, BigDecimal>> {
return yieldSupplyApyFlowUseCase().distinctUntilChanged()
}
@ -50,6 +54,10 @@ internal class AccountListSubscriber @AssistedInject constructor(
return stakingApyFlowUseCase().distinctUntilChanged()
}
private fun yieldSupplyGetShouldShowMainPromoFlow(): Flow<Boolean> {
return yieldSupplyGetShouldShowMainPromoUseCase().distinctUntilChanged()
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): AccountListSubscriber

View file

@ -20,6 +20,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.TokenCon
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import timber.log.Timber
import java.math.BigDecimal
/**
* Basic implementation of [WalletSubscriber] for wallet with accounts.
@ -46,8 +47,9 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
appCurrency: AppCurrency,
expandedAccounts: Set<AccountId>,
isAccountMode: Boolean,
yieldSupplyApyMap: Map<String, String> = emptyMap(),
yieldSupplyApyMap: Map<String, BigDecimal> = emptyMap(),
stakingApyMap: Map<String, List<Yield.Validator>> = emptyMap(),
shouldShowMainPromo: Boolean = false,
) {
val accountFlattenCurrencies = accountList.flattenCurrencies()
val mainAccount = accountList.mainAccount
@ -67,6 +69,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
portfolioId = PortfolioId(mainAccount.accountId),
yieldSupplyApyMap = yieldSupplyApyMap,
stakingApyMap = stakingApyMap,
shouldShowMainPromo = shouldShowMainPromo,
)
}
isAccountMode -> {
@ -82,7 +85,13 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
} else {
val convertParams = TokenConverterParams.Account(accountList, expandedAccounts)
updateContent(convertParams, appCurrency, yieldSupplyApyMap, stakingApyMap)
updateContent(
params = convertParams,
appCurrency = appCurrency,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingApyMap = stakingApyMap,
shouldShowMainPromo = shouldShowMainPromo,
)
}
}
}
@ -92,8 +101,9 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
maybeTokenList: Lce<TokenListError, TokenList>,
appCurrency: AppCurrency,
portfolioId: PortfolioId,
yieldSupplyApyMap: Map<String, String> = emptyMap(),
yieldSupplyApyMap: Map<String, BigDecimal> = emptyMap(),
stakingApyMap: Map<String, List<Yield.Validator>> = emptyMap(),
shouldShowMainPromo: Boolean,
) {
val tokenList = maybeTokenList.getOrElse(
ifLoading = { maybeContent ->
@ -123,14 +133,16 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
appCurrency = appCurrency,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingApyMap = stakingApyMap,
shouldShowMainPromo = shouldShowMainPromo,
)
}
private fun updateContent(
params: TokenConverterParams,
appCurrency: AppCurrency,
yieldSupplyApyMap: Map<String, String> = emptyMap(),
yieldSupplyApyMap: Map<String, BigDecimal> = emptyMap(),
stakingApyMap: Map<String, List<Yield.Validator>> = emptyMap(),
shouldShowMainPromo: Boolean,
) {
stateController.update(
SetTokenListTransformer(
@ -140,6 +152,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
clickIntents = clickIntents,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingApyMap = stakingApyMap,
shouldShowMainPromo = shouldShowMainPromo,
),
)
}

View file

@ -15,6 +15,7 @@ import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.usecase.StakingApyFlowUseCase
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker
@ -28,6 +29,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.math.BigDecimal
@Deprecated("Use AccountListSubscriber instead")
@Suppress("LongParameterList")
@ -40,6 +42,7 @@ internal abstract class BasicTokenListSubscriber(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
private val stakingApyFlowUseCase: StakingApyFlowUseCase,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
) : WalletSubscriber() {
private val sendAnalyticsJobHolder = JobHolder()
@ -69,7 +72,8 @@ internal abstract class BasicTokenListSubscriber(
flow2 = appCurrencyFlow(),
flow3 = yieldSupplyApyFlow(),
flow4 = stakingApyFlow(),
transform = { maybeTokenList, appCurrency, yieldSupplyApyMap, stakingApyMap ->
flow5 = yieldSupplyGetShouldShowMainPromoFlow(),
transform = { maybeTokenList, appCurrency, yieldSupplyApyMap, stakingApyMap, shouldShowMainPromo ->
val tokenList = maybeTokenList.getOrElse(
ifLoading = { maybeContent ->
val isRefreshing = stateHolder.getWalletState(userWallet.walletId)
@ -98,6 +102,7 @@ internal abstract class BasicTokenListSubscriber(
appCurrency = appCurrency,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingApyMap = stakingApyMap,
shouldShowMainPromo = shouldShowMainPromo,
)
walletWithFundsChecker.check(tokenList)
@ -122,8 +127,9 @@ internal abstract class BasicTokenListSubscriber(
private fun updateContent(
params: TokenConverterParams,
appCurrency: AppCurrency,
yieldSupplyApyMap: Map<String, String>,
yieldSupplyApyMap: Map<String, BigDecimal>,
stakingApyMap: Map<String, List<Yield.Validator>>,
shouldShowMainPromo: Boolean,
) {
stateHolder.update(
SetTokenListTransformer(
@ -133,6 +139,7 @@ internal abstract class BasicTokenListSubscriber(
clickIntents = clickIntents,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingApyMap = stakingApyMap,
shouldShowMainPromo = shouldShowMainPromo,
),
)
}
@ -146,9 +153,12 @@ internal abstract class BasicTokenListSubscriber(
}
.distinctUntilChanged()
private fun yieldSupplyApyFlow(): Flow<Map<String, String>> = yieldSupplyApyFlowUseCase()
private fun yieldSupplyApyFlow(): Flow<Map<String, BigDecimal>> = yieldSupplyApyFlowUseCase()
.distinctUntilChanged()
private fun stakingApyFlow(): Flow<Map<String, List<Yield.Validator>>> = stakingApyFlowUseCase()
.distinctUntilChanged()
private fun yieldSupplyGetShouldShowMainPromoFlow(): Flow<Boolean> = yieldSupplyGetShouldShowMainPromoUseCase()
.distinctUntilChanged()
}

View file

@ -12,6 +12,7 @@ import com.tangem.domain.staking.usecase.StakingApyFlowUseCase
import com.tangem.domain.tokens.ApplyTokenListSortingUseCase
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.domain.MultiWalletTokenListStore
@ -32,6 +33,7 @@ internal class MultiWalletTokenListSubscriber(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
stakingApyFlowUseCase: StakingApyFlowUseCase,
yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
) : BasicTokenListSubscriber(
userWallet = userWallet,
stateHolder = stateHolder,
@ -41,6 +43,7 @@ internal class MultiWalletTokenListSubscriber(
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase,
stakingApyFlowUseCase = stakingApyFlowUseCase,
yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase,
) {
override fun tokenListFlow(coroutineScope: CoroutineScope): LceFlow<TokenListError, TokenList> {

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.staking.usecase.StakingApyFlowUseCase
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.domain.MultiWalletTokenListStore
@ -27,6 +28,7 @@ internal class SingleWalletWithTokenListSubscriber(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
stakingApyFlowUseCase: StakingApyFlowUseCase,
yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
) : BasicTokenListSubscriber(
userWallet = userWallet,
stateHolder = stateHolder,
@ -36,6 +38,7 @@ internal class SingleWalletWithTokenListSubscriber(
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase,
stakingApyFlowUseCase = stakingApyFlowUseCase,
yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase,
) {
override fun tokenListFlow(coroutineScope: CoroutineScope): LceFlow<TokenListError, TokenList> {

View file

@ -5,6 +5,7 @@ import androidx.paging.cachedIn
import androidx.paging.map
import arrow.core.Either
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWallet
@ -58,7 +59,7 @@ internal class TxHistorySubscriber(
refresh = isRefresh,
).map { it.cachedIn(coroutineScope) }
setLoadedTxHistoryState(maybeTxHistoryItems)
setLoadedTxHistoryState(maybeTxHistoryItems, status.currency)
}
}
}
@ -67,18 +68,19 @@ internal class TxHistorySubscriber(
private fun setLoadingTxHistoryState(maybeTxHistoryItemCount: MaybeTxHistoryCount, status: CryptoCurrencyStatus) {
stateHolder.update(
maybeTxHistoryItemCount.fold(
ifLeft = {
ifLeft = { error ->
SetTxHistoryCountErrorTransformer(
userWallet = userWallet,
error = it,
error = error,
pendingTransactions = status.value.pendingTransactions,
currency = status.currency,
clickIntents = clickIntents,
)
},
ifRight = {
ifRight = { txCount ->
SetTxHistoryCountTransformer(
userWalletId = userWallet.walletId,
transactionsCount = it,
transactionsCount = txCount,
clickIntents = clickIntents,
)
},
@ -86,7 +88,7 @@ internal class TxHistorySubscriber(
)
}
private fun setLoadedTxHistoryState(maybeTxHistoryItems: MaybeTxHistoryItems) {
private fun setLoadedTxHistoryState(maybeTxHistoryItems: MaybeTxHistoryItems, currency: CryptoCurrency) {
stateHolder.update(
maybeTxHistoryItems.fold(
ifLeft = {
@ -102,6 +104,7 @@ internal class TxHistorySubscriber(
symbol = blockchain.currency,
decimals = blockchain.decimals(),
clickIntents = clickIntents,
currency = currency,
)
SetTxHistoryItemsTransformer(

View file

@ -5,6 +5,7 @@ import androidx.paging.cachedIn
import androidx.paging.map
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWallet
@ -51,7 +52,7 @@ internal class TxHistorySubscriberV2(
refresh = isRefresh,
).map { it.cachedIn(coroutineScope) }
setLoadedTxHistoryState(maybeTxHistoryItems)
setLoadedTxHistoryState(maybeTxHistoryItems, currency = status.currency)
}
}
}
@ -60,18 +61,19 @@ internal class TxHistorySubscriberV2(
private fun setLoadingTxHistoryState(maybeTxHistoryItemCount: MaybeTxHistoryCount, status: CryptoCurrencyStatus) {
stateController.update(
maybeTxHistoryItemCount.fold(
ifLeft = {
ifLeft = { error ->
SetTxHistoryCountErrorTransformer(
userWallet = userWallet,
error = it,
error = error,
pendingTransactions = status.value.pendingTransactions,
clickIntents = clickIntents,
currency = status.currency,
)
},
ifRight = {
ifRight = { txCount ->
SetTxHistoryCountTransformer(
userWalletId = userWallet.walletId,
transactionsCount = it,
transactionsCount = txCount,
clickIntents = clickIntents,
)
},
@ -79,7 +81,7 @@ internal class TxHistorySubscriberV2(
)
}
private fun setLoadedTxHistoryState(maybeTxHistoryItems: MaybeTxHistoryItems) {
private fun setLoadedTxHistoryState(maybeTxHistoryItems: MaybeTxHistoryItems, currency: CryptoCurrency) {
stateController.update(
maybeTxHistoryItems.fold(
ifLeft = {
@ -92,6 +94,7 @@ internal class TxHistorySubscriberV2(
ifRight = { itemsFlow ->
val blockchain = userWallet.scanResponse.cardTypesResolver.getBlockchain()
val itemConverter = TxHistoryItemStateConverter(
currency = currency,
symbol = blockchain.currency,
decimals = blockchain.decimals(),
clickIntents = clickIntents,

View file

@ -0,0 +1,208 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.PortfolioId
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.feature.wallet.presentation.wallet.state.transformers.TokenConverterParams
import org.junit.Test
import java.math.BigDecimal
class YieldSupplyPromoBannerKeyConverterTest {
@Test
fun `GIVEN promo disabled WHEN convert THEN return null`() {
val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xABCDEF")
val status = createStatus(token = token, amount = BigDecimal.ONE, isYieldActive = false)
val tokenList = ungroupedTokenList(status)
val params = TokenConverterParams.Wallet(
portfolioId = PortfolioId.Wallet(UserWalletId("00")),
tokenList = tokenList,
)
val converter = YieldSupplyPromoBannerKeyConverter(
yieldModuleApyMap = mapOf(token.yieldSupplyKey() to BigDecimal("0.10")),
shouldShowMainPromo = false,
)
val result = converter.convert(params)
assertThat(result).isNull()
}
@Test
fun `GIVEN empty apy map WHEN convert THEN return null`() {
val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xA1")
val status = createStatus(token = token, amount = BigDecimal("2.0"), isYieldActive = false)
val params = TokenConverterParams.Wallet(
portfolioId = PortfolioId.Wallet(UserWalletId("00")),
tokenList = ungroupedTokenList(status),
)
val converter = YieldSupplyPromoBannerKeyConverter(
yieldModuleApyMap = emptyMap(),
shouldShowMainPromo = true,
)
val result = converter.convert(params)
assertThat(result).isNull()
}
@Test
fun `GIVEN active yield token present WHEN convert THEN return null`() {
val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xAA")
val statusActive = createStatus(token = token, amount = BigDecimal("5"), isYieldActive = true)
val params = TokenConverterParams.Wallet(
portfolioId = PortfolioId.Wallet(UserWalletId("00")),
tokenList = ungroupedTokenList(statusActive),
)
val converter = YieldSupplyPromoBannerKeyConverter(
yieldModuleApyMap = mapOf(token.yieldSupplyKey() to BigDecimal("0.12")),
shouldShowMainPromo = true,
)
val result = converter.convert(params)
assertThat(result).isNull()
}
@Test
fun `GIVEN multiple candidates EVM case insensitive WHEN convert THEN return key of max amount`() {
val evmNetworkId = "ETH"
val tokenSmall = createToken(networkId = evmNetworkId, backendId = evmNetworkId, contract = "0xAbCd")
val tokenBig = createToken(networkId = evmNetworkId, backendId = evmNetworkId, contract = "0xBEEF")
val statusSmall = createStatus(token = tokenSmall, amount = BigDecimal("1.00"), isYieldActive = false)
val statusBig = createStatus(token = tokenBig, amount = BigDecimal("10.00"), isYieldActive = false)
val apyMap = mapOf(
"${tokenSmall.network.backendId}_${tokenSmall.contractAddress.lowercase()}" to BigDecimal("0.05"),
"${tokenBig.network.backendId}_${tokenBig.contractAddress.uppercase()}" to BigDecimal("0.15"),
)
val params = TokenConverterParams.Wallet(
portfolioId = PortfolioId.Wallet(UserWalletId("00")),
tokenList = ungroupedTokenList(statusSmall, statusBig),
)
val converter = YieldSupplyPromoBannerKeyConverter(
yieldModuleApyMap = apyMap,
shouldShowMainPromo = true,
)
val result = converter.convert(params)
val expectedKey = "${tokenBig.network.backendId}_${tokenBig.contractAddress.uppercase()}"
assertThat(result).isEqualTo(expectedKey)
}
@Test
fun `GIVEN non evm case sensitive mismatch WHEN convert THEN return null`() {
val nonEvmId = "xrp"
val token = createToken(networkId = nonEvmId, backendId = nonEvmId, contract = "rAbC123")
val status = createStatus(token = token, amount = BigDecimal("3"), isYieldActive = false)
val mismatchedKey = "${token.network.backendId}_${token.contractAddress.lowercase()}"
val apyMap = mapOf(mismatchedKey to BigDecimal("0.07"))
val params = TokenConverterParams.Wallet(
portfolioId = PortfolioId.Wallet(UserWalletId("00")),
tokenList = ungroupedTokenList(status),
)
val converter = YieldSupplyPromoBannerKeyConverter(
yieldModuleApyMap = apyMap,
shouldShowMainPromo = true,
)
val result = converter.convert(params)
assertThat(result).isNull()
}
private fun ungroupedTokenList(vararg statuses: CryptoCurrencyStatus): TokenList.Ungrouped {
return TokenList.Ungrouped(
totalFiatBalance = com.tangem.domain.models.TotalFiatBalance.Loaded(
amount = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
sortedBy = com.tangem.domain.models.TokensSortType.NONE,
currencies = statuses.toList(),
)
}
private fun createStatus(
token: CryptoCurrency.Token,
amount: BigDecimal,
isYieldActive: Boolean,
): CryptoCurrencyStatus {
val networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
value = "addr",
type = NetworkAddress.Address.Type.Primary,
),
)
val value = CryptoCurrencyStatus.Custom(
amount = amount,
fiatAmount = null,
fiatRate = null,
priceChange = null,
yieldBalance = null,
yieldSupplyStatus = if (isYieldActive) {
YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = null,
)
} else {
null
},
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = networkAddress,
sources = CryptoCurrencyStatus.Sources(),
)
return CryptoCurrencyStatus(
currency = token,
value = value,
)
}
private fun createToken(networkId: String, backendId: String, contract: String): CryptoCurrency.Token {
val network = Network(
id = Network.ID(value = networkId, derivationPath = Network.DerivationPath.None),
backendId = backendId,
name = backendId,
currencySymbol = "SYM",
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = when (backendId) {
"ethereum" -> Network.StandardType.ERC20
else -> Network.StandardType.Unspecified("UNSPEC")
},
hasFiatFeeRate = false,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(networkId),
suffix = CryptoCurrency.ID.Suffix.ContractAddress(contract),
),
network = network,
name = "Token",
symbol = "TKN",
decimals = 18,
iconUrl = null,
isCustom = false,
contractAddress = contract,
)
}
}

View file

@ -58,6 +58,7 @@ internal class YieldSupplyActiveModel @Inject constructor(
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val urlOpener: UrlOpener,
private val appRouter: AppRouter,
private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase,
) : Model(), YieldSupplyStopEarningComponent.ModelCallback,
YieldSupplyApproveComponent.ModelCallback {
@ -241,11 +242,13 @@ internal class YieldSupplyActiveModel @Inject constructor(
userWalletId,
cryptoCurrencyStatusFlow.value,
).onRight { minAmount ->
val dustAmount = yieldSupplyGetDustMinAmountUseCase(minAmount = minAmount, appCurrency = appCurrency)
uiState.update(
YieldSupplyActiveMinAmountTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value,
appCurrency = appCurrency,
minAmount = minAmount,
dustMinAmount = dustAmount,
analyticsHandler = analyticsHandler,
onApprove = ::onApprove,
),

View file

@ -28,11 +28,12 @@ import java.math.BigDecimal
* - Builds the fee policy note text using the minimum amount.
* - Adds contextual notifications:
* - Approval required notification when spending is not yet allowed (emits analytics on CTA).
* - "Not all amount supplied" info when wallet balance exceeds the supplied balance by more than [minAmount].
* - "Not all amount supplied" info when the not-supplied balance exceeds the dust threshold [dustMinAmount].
*
* @property cryptoCurrencyStatus Current currency status used to calculate values and flags.
* @property appCurrency Preferred fiat currency for formatting.
* @property minAmount Protocol-required minimal amount to deposit/supply (in crypto units).
* @property dustMinAmount Threshold used to detect dust/not-supplied balance (in crypto units).
* @property analyticsHandler Analytics reporter for user actions.
* @property onApprove Action invoked when the "Approve" notification button is tapped.
*/
@ -40,6 +41,7 @@ internal class YieldSupplyActiveMinAmountTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val minAmount: BigDecimal,
private val dustMinAmount: BigDecimal,
private val analyticsHandler: AnalyticsEventHandler,
private val onApprove: () -> Unit,
) : Transformer<YieldSupplyActiveContentUM> {
@ -89,7 +91,7 @@ internal class YieldSupplyActiveMinAmountTransformer(
}
private fun getNotSuppliedNotification(cryptoCurrencyStatus: CryptoCurrencyStatus): NotificationUM? {
return if (cryptoCurrencyStatus.shouldShowNotSuppliedInfoIcon(minAmount)) {
return if (cryptoCurrencyStatus.shouldShowNotSuppliedInfoIcon(dustMinAmount)) {
val cryptoCurrency = cryptoCurrencyStatus.currency
val notDepositedAmount = cryptoCurrencyStatus.notSuppliedAmountOrNull()
val formattedAmount =

View file

@ -1,5 +1,6 @@
package com.tangem.features.yield.supply.impl.main.model
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import android.os.SystemClock
import com.tangem.common.routing.AppRoute.YieldSupplyPromo
@ -12,6 +13,8 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -51,6 +54,7 @@ internal class YieldSupplyModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
@ -61,6 +65,7 @@ internal class YieldSupplyModel @Inject constructor(
private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase,
private val yieldSupplyRepository: YieldSupplyRepository,
private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase,
private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase,
) : Model(), YieldSupplyClickIntents {
private val params = paramsContainer.require<YieldSupplyComponent.Params>()
@ -69,6 +74,7 @@ internal class YieldSupplyModel @Inject constructor(
field = MutableStateFlow<YieldSupplyUM>(YieldSupplyUM.Initial)
private val cryptoCurrency = params.cryptoCurrency
private var appCurrency: AppCurrency = AppCurrency.Default
var userWallet: UserWallet by Delegates.notNull()
private val fetchCurrencyJobHolder = JobHolder()
@ -81,10 +87,17 @@ internal class YieldSupplyModel @Inject constructor(
}
private fun checkIfYieldSupplyIsAvailable() {
modelScope.launch(dispatchers.io) {
modelScope.launch(dispatchers.default) {
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
val isAvailable = yieldSupplyIsAvailableUseCase(params.userWalletId, params.cryptoCurrency)
if (isAvailable) {
subscribeOnCurrencyStatusUpdates()
singleNetworkStatusFetcher(
params = SingleNetworkStatusFetcher.Params(
userWalletId = params.userWalletId,
network = cryptoCurrency.network,
),
)
}
}
}
@ -334,7 +347,11 @@ internal class YieldSupplyModel @Inject constructor(
val minAmount = yieldSupplyMinAmountUseCase(userWalletId = userWallet.walletId, cryptoCurrencyStatus)
.getOrNull()
if (minAmount != null) {
cryptoCurrencyStatus.shouldShowNotSuppliedInfoIcon(minAmount)
val dustAmount = yieldSupplyGetDustMinAmountUseCase(
minAmount = minAmount,
appCurrency = appCurrency,
)
cryptoCurrencyStatus.shouldShowNotSuppliedInfoIcon(dustAmount)
} else {
false
}

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "releases-5.31-1314"
tangemBlockchainSdk = "releases-5.31.1-1316"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "releases-5.31-569"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^