Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-26 16:03:11 +04:00
parent ff8f9c9b18
commit 6aab25427d
75 changed files with 2122 additions and 57 deletions

View file

@ -0,0 +1,5 @@
package com.tangem.features.yield.supply.api
interface YieldSupplyFeatureToggles {
val isYieldPromoEnabled: Boolean
}

View file

@ -11,6 +11,7 @@ interface YieldSupplyPromoComponent : ComposableContentComponent {
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val apy: String,
val isPromoEnabled: Boolean = false,
)
interface Factory : ComponentFactory<Params, YieldSupplyPromoComponent>

View file

@ -15,6 +15,7 @@ sealed class YieldSupplyEntryRoute : Route {
data class Promo(
val cryptoCurrency: CryptoCurrency,
val apy: String,
val isPromoEnabled: Boolean = false,
) : YieldSupplyEntryRoute()
/** Route to yield supply active screen */

View file

@ -58,6 +58,8 @@ dependencies {
implementation(projects.domain.transaction)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.stories.models)
implementation(projects.domain.stories)
implementation(projects.domain.feedback.models)
implementation(projects.domain.feedback)
implementation(projects.domain.balanceHiding.models)
@ -76,6 +78,7 @@ dependencies {
implementation(deps.decompose)
implementation(deps.decompose.ext.compose)
implementation(deps.kotlin.immutable.collections)
implementation(deps.kotlin.datetime)
/** DI */
implementation(deps.hilt.android)

View file

@ -0,0 +1,15 @@
package com.tangem.features.yield.supply.impl
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import javax.inject.Inject
internal class DefaultYieldSupplyFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : YieldSupplyFeatureToggles {
override val isYieldPromoEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15154_YIELD_PROMO_ENABLED,
)
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.yield.supply.impl
import com.tangem.core.ui.coil.ImagePreloader
import com.tangem.domain.stories.GetStoryContentUseCase
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject
/**
* Warms the in-memory `StoriesStore` cache (and Coil image cache) for the yield-boost story.
*
* Called proactively from yield-supply models so that when the user taps "Learn more" /
* the active-boost row, [com.tangem.feature.stories.impl.model.StoriesModel] hits cache
* instead of waiting for the 1-second network fetch.
*/
internal class YieldBoostStoryPreloader @Inject constructor(
private val getStoryContentUseCase: GetStoryContentUseCase,
private val imagePreloader: ImagePreloader,
) {
suspend fun preload() {
runSuspendCatching {
getStoryContentUseCase
.invokeSync(id = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id, refresh = true)
.onRight { story -> story?.getImageUrls()?.forEach(imagePreloader::preload) }
}
}
}

View file

@ -17,4 +17,6 @@ internal data class YieldSupplyActiveContentUM(
val minFeeDescription: TextReference?,
val apy: TextReference? = null,
val isHighFee: Boolean = false,
val boostText: TextReference? = null,
val onBoostClick: () -> Unit = {},
)

View file

@ -11,7 +11,10 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
@ -25,15 +28,23 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.common.routing.AppRoute
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.usecase.GetYieldBoostStatusUseCase
import com.tangem.domain.yield.supply.usecase.*
import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
import com.tangem.features.yield.supply.impl.R
import com.tangem.core.res.R as CoreResR
import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader
import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM
import com.tangem.features.yield.supply.impl.active.model.transformers.YieldSupplyActiveFeeContentTransformer
import com.tangem.features.yield.supply.impl.active.model.transformers.YieldSupplyActiveMinAmountTransformer
import com.tangem.features.yield.supply.impl.subcomponents.approve.YieldSupplyApproveComponent
import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
@ -41,7 +52,10 @@ import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import javax.inject.Inject
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
@Suppress("LongParameterList", "LargeClass")
@ModelScoped
@ -60,6 +74,10 @@ internal class YieldSupplyActiveModel @Inject constructor(
private val urlOpener: UrlOpener,
private val appRouter: AppRouter,
private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase,
private val getYieldBoostStatusUseCase: GetYieldBoostStatusUseCase,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val designFeatureToggles: DesignFeatureToggles,
private val boostStoryPreloader: YieldBoostStoryPreloader,
) : Model(), YieldSupplyStopEarningComponent.ModelCallback,
YieldSupplyApproveComponent.ModelCallback {
@ -112,6 +130,8 @@ internal class YieldSupplyActiveModel @Inject constructor(
),
)
subscribeOnCurrencyStatusUpdates()
loadBoostBlock()
modelScope.launch(dispatchers.io) { boostStoryPreloader.preload() }
modelScope.launch(dispatchers.default) {
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
@ -219,6 +239,72 @@ internal class YieldSupplyActiveModel @Inject constructor(
}
}
private fun loadBoostBlock() {
if (!yieldSupplyFeatureToggles.isYieldPromoEnabled) return
if (designFeatureToggles.isRedesignEnabled) return
modelScope.launch(dispatchers.io) {
val status = getYieldBoostStatusUseCase(userWalletId).getOrNull() ?: return@launch
val token = cryptoCurrency as? CryptoCurrency.Token ?: return@launch
when {
status is YieldBoostStatus.Active && status.matches(token) -> {
uiState.update {
it.copy(boostText = buildActiveBoostText(status), onBoostClick = ::onBoostClick)
}
}
status is YieldBoostStatus.Completed && status.matches(token) -> {
uiState.update {
it.copy(
boostText = resourceReference(CoreResR.string.yield_promo_completed),
onBoostClick = ::onBoostClick,
)
}
}
}
}
}
private fun onBoostClick() {
appRouter.push(
AppRoute.Stories(
storyId = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id,
nextScreen = null,
screenSource = "YieldActive",
shouldMarkAsSeenOnClose = false,
),
)
}
private fun buildActiveBoostText(status: YieldBoostStatus.Active): TextReference {
val daysLeft = computeDaysLeft(status.qualificationEndDate.toEpochMilliseconds())
return combinedReference(
pluralReference(
id = CoreResR.plurals.common_days,
count = daysLeft,
formatArgs = wrappedList(daysLeft),
),
stringReference(" "),
resourceReference(CoreResR.string.yield_promo_left_title),
)
}
private fun computeDaysLeft(qualificationEndEpochMillis: Long): Int {
val nowMillis = Clock.System.now().toEpochMilliseconds()
val deltaMillis = max(qualificationEndEpochMillis - nowMillis, 0L)
return deltaMillis.milliseconds.inWholeDays.toInt()
}
private fun YieldBoostStatus.Active.matches(token: CryptoCurrency.Token): Boolean =
matchesToken(contractAddress = contractAddress, networkId = networkId, token = token)
private fun YieldBoostStatus.Completed.matches(token: CryptoCurrency.Token): Boolean =
matchesToken(contractAddress = contractAddress, networkId = networkId, token = token)
private fun matchesToken(contractAddress: String, networkId: String, token: CryptoCurrency.Token): Boolean {
val shouldIgnoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId)
return contractAddress.equals(token.contractAddress, ignoreCase = shouldIgnoreCase) &&
networkId == token.network.rawId
}
private fun loadApy() {
val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return
modelScope.launch(dispatchers.default) {

View file

@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -43,6 +44,7 @@ import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@Composable
internal fun YieldSupplyActiveContent(
state: YieldSupplyActiveContentUM,
@ -61,15 +63,29 @@ internal fun YieldSupplyActiveContent(
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.action)
.fillMaxWidth()
.padding(12.dp),
.fillMaxWidth(),
) {
CurrentApy(state.apy)
chartComponent.Content(Modifier)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(12.dp),
) {
CurrentApy(state.apy)
chartComponent.Content(Modifier)
}
AnimatedVisibility(state.boostText != null) {
Column {
HorizontalDivider(
thickness = TangemTheme.dimens.size0_5,
color = TangemTheme.colors.stroke.primary,
)
state.boostText?.let { boostText ->
BoostRow(text = boostText, onClick = state.onBoostClick)
}
}
}
}
AnimatedVisibility(state.notifications.isNotEmpty()) {
@ -359,6 +375,37 @@ private fun HighComissionInfoRow(title: TextReference, info: TextReference?, isH
}
}
@Composable
private fun BoostRow(text: TextReference, onClick: () -> Unit, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 12.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_gift_promo_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
Text(
text = text.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.size(20.dp),
)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)

View file

@ -0,0 +1,21 @@
package com.tangem.features.yield.supply.impl.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.features.yield.supply.impl.DefaultYieldSupplyFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object YieldSupplyFeatureModule {
@Provides
@Singleton
fun provideYieldSupplyFeatureToggles(featureTogglesManager: FeatureTogglesManager): YieldSupplyFeatureToggles {
return DefaultYieldSupplyFeatureToggles(featureTogglesManager)
}
}

View file

@ -86,6 +86,7 @@ internal class DefaultYieldSupplyEntryComponent @AssistedInject constructor(
userWalletId = params.userWalletId,
currency = configuration.cryptoCurrency,
apy = configuration.apy,
isPromoEnabled = configuration.isPromoEnabled,
),
)
is YieldSupplyEntryRoute.Active -> yieldSupplyActiveComponentFactory.create(

View file

@ -1,17 +1,21 @@
package com.tangem.features.yield.supply.impl.entry.model
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase
import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.launch
@ -20,12 +24,16 @@ import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class YieldSupplyEntryModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val isYieldBoostPromoEnabledForTokenUseCase: IsYieldBoostPromoEnabledForTokenUseCase,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val designFeatureToggles: DesignFeatureToggles,
) : Model() {
private val params = paramsContainer.require<YieldSupplyEntryComponent.Params>()
@ -90,7 +98,14 @@ internal class YieldSupplyEntryModel @Inject constructor(
return if (isActiveYield) {
YieldSupplyEntryRoute.Active(cryptoCurrency = token)
} else {
YieldSupplyEntryRoute.Promo(cryptoCurrency = token, apy = params.apy)
val isPromoEnabled = yieldSupplyFeatureToggles.isYieldPromoEnabled &&
!designFeatureToggles.isRedesignEnabled &&
isYieldBoostPromoEnabledForTokenUseCase(userWalletId, token).getOrElse { false }
YieldSupplyEntryRoute.Promo(
cryptoCurrency = token,
apy = params.apy,
isPromoEnabled = isPromoEnabled,
)
}
}
}

View file

@ -13,6 +13,8 @@ internal sealed class YieldSupplyUM {
val apyText: TextReference,
val title: TextReference,
val onClick: () -> Unit,
val onLearnMoreClick: () -> Unit,
val isBoostAvailable: Boolean = false,
) : YieldSupplyUM()
data object Loading : YieldSupplyUM()

View file

@ -3,4 +3,5 @@ package com.tangem.features.yield.supply.impl.main.model
interface YieldSupplyClickIntents {
fun onStartEarningClick()
fun onActiveClick()
fun onLearnMoreClick()
}

View file

@ -8,6 +8,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
@ -24,12 +25,17 @@ import com.tangem.domain.models.currency.shouldShowNotSuppliedNotification
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
import com.tangem.domain.yield.supply.promo.usecase.GetBoostedApyUseCase
import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase
import com.tangem.domain.yield.supply.usecase.*
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
import com.tangem.features.yield.supply.impl.main.model.converter.YieldSupplyToEarnBlockConverter
@ -61,6 +67,11 @@ internal class YieldSupplyModel @Inject constructor(
private val yieldSupplyEnterStatusFlowUseCase: YieldSupplyEnterStatusFlowUseCase,
private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase,
private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase,
private val isYieldBoostPromoEnabledForTokenUseCase: IsYieldBoostPromoEnabledForTokenUseCase,
private val getBoostedApyUseCase: GetBoostedApyUseCase,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val designFeatureToggles: DesignFeatureToggles,
private val boostStoryPreloader: YieldBoostStoryPreloader,
) : Model(), YieldSupplyClickIntents {
private val earnBlockConverter = YieldSupplyToEarnBlockConverter()
@ -82,6 +93,7 @@ internal class YieldSupplyModel @Inject constructor(
init {
checkIfYieldSupplyIsAvailable()
modelScope.launch(dispatchers.io) { boostStoryPreloader.preload() }
}
private fun checkIfYieldSupplyIsAvailable() {
@ -150,10 +162,17 @@ internal class YieldSupplyModel @Inject constructor(
val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return
yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken)
.onRight { tokenStatus ->
val isPromoEnabled = yieldSupplyFeatureToggles.isYieldPromoEnabled &&
!designFeatureToggles.isRedesignEnabled &&
isYieldBoostPromoEnabledForTokenUseCase(params.userWalletId, cryptoCurrencyToken)
.getOrElse { false }
val boostedApy = if (isPromoEnabled) getBoostedApyUseCase(tokenStatus.apy) else null
uiStateLegacy.update(
YieldSupplyTokenStatusSuccessTransformer(
tokenStatus = tokenStatus,
onStartEarningClick = ::onStartEarningClick,
onLearnMoreClick = ::onLearnMoreClick,
boostedApy = boostedApy,
),
)
}.onLeft { error ->
@ -170,19 +189,33 @@ internal class YieldSupplyModel @Inject constructor(
navigateToYieldSupplyEntry()
}
override fun onLearnMoreClick() {
appRouter.push(
AppRoute.Stories(
storyId = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id,
nextScreen = buildYieldEntryRoute(),
screenSource = "TokenDetails",
shouldMarkAsSeenOnClose = false,
),
)
}
private fun navigateToYieldSupplyEntry() {
val cryptoCurrencyStatus = latestCryptoCurrencyStatus ?: return
val route = buildYieldEntryRoute() ?: return
appRouter.push(route)
}
private fun buildYieldEntryRoute(): AppRoute.YieldSupplyEntry? {
val cryptoCurrencyStatus = latestCryptoCurrencyStatus ?: return null
val apy = when (val yieldSupplyUM = uiStateLegacy.value) {
is YieldSupplyUM.Available -> yieldSupplyUM.apy
is YieldSupplyUM.Content -> yieldSupplyUM.apy
else -> ""
}
appRouter.push(
AppRoute.YieldSupplyEntry(
userWalletId = params.userWalletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
apy = apy,
),
return AppRoute.YieldSupplyEntry(
userWalletId = params.userWalletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
apy = apy,
)
}

View file

@ -1,5 +1,10 @@
package com.tangem.features.yield.supply.impl.main.model.transformers
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import com.tangem.core.ui.extensions.annotatedReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -7,27 +12,45 @@ import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
import com.tangem.utils.transformer.Transformer
import java.math.BigDecimal
internal class YieldSupplyTokenStatusSuccessTransformer(
private val tokenStatus: YieldMarketToken,
private val onStartEarningClick: () -> Unit,
private val onLearnMoreClick: () -> Unit,
private val boostedApy: BigDecimal? = null,
) : Transformer<YieldSupplyUM> {
override fun transform(prevState: YieldSupplyUM): YieldSupplyUM {
if (!tokenStatus.isActive) return YieldSupplyUM.Unavailable
val boost = boostedApy
return YieldSupplyUM.Available(
title = resourceReference(
R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title,
),
title = if (boost != null) {
resourceReference(R.string.yield_apy_boost_banner_title)
} else {
resourceReference(R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title)
},
onClick = onStartEarningClick,
onLearnMoreClick = onLearnMoreClick,
isBoostAvailable = boost != null,
apy = tokenStatus.apy.toString(),
apyText = combinedReference(
resourceReference(
R.string.yield_module_token_details_earn_notification_apy,
),
stringReference(" ${tokenStatus.apy}%"),
),
apyText = if (boost != null) {
annotatedReference(buildBoostedApyText(baseApy = tokenStatus.apy, boostedApy = boost))
} else {
combinedReference(
resourceReference(R.string.yield_module_token_details_earn_notification_apy),
stringReference(" ${tokenStatus.apy}%"),
)
},
)
}
private fun buildBoostedApyText(baseApy: BigDecimal, boostedApy: BigDecimal) = buildAnnotatedString {
append("APY ")
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
append("$baseApy%")
}
append(" x3 → $boostedApy%")
}
}

View file

@ -25,6 +25,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerW12
import com.tangem.core.ui.components.SpacerW8
@ -64,21 +65,91 @@ internal fun YieldSupplyBlockContentLegacy(yieldSupplyUM: YieldSupplyUM, modifie
@Composable
private fun SupplyAvailable(supplyUM: YieldSupplyUM.Available, modifier: Modifier = Modifier) {
SupplyInfo(
title = resourceReference(R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title),
subtitle = resourceReference(R.string.yield_module_token_details_earn_notification_description),
rewardsApy = supplyUM.apyText,
iconTint = TangemTheme.colors.icon.accent,
modifier = modifier,
button = {
if (supplyUM.isBoostAvailable) {
SupplyAvailableBoosted(supplyUM = supplyUM, modifier = modifier)
} else {
SupplyInfo(
title = resourceReference(
R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title,
),
subtitle = resourceReference(R.string.yield_module_token_details_earn_notification_description),
rewardsApy = supplyUM.apyText,
iconTint = TangemTheme.colors.icon.accent,
modifier = modifier,
button = {
SecondaryButton(
text = stringResourceSafe(R.string.common_learn_more),
onClick = supplyUM.onClick,
size = TangemButtonSize.WideAction,
modifier = Modifier.fillMaxWidth(),
)
},
)
}
}
@Composable
private fun SupplyAvailableBoosted(supplyUM: YieldSupplyUM.Available, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.primary)
.padding(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_analytics_up_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
modifier = Modifier
.background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f), CircleShape)
.padding(6.dp)
.size(24.dp),
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.weight(1f),
) {
Text(
text = supplyUM.title.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
text = supplyUM.apyText.resolveAnnotatedReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.accent,
)
Text(
text = stringResourceSafe(R.string.yield_apy_boost_banner_subtitle),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
SecondaryButton(
text = stringResourceSafe(R.string.common_learn_more),
onClick = supplyUM.onLearnMoreClick,
size = TangemButtonSize.WideAction,
modifier = Modifier.weight(1f),
)
PrimaryButton(
text = stringResourceSafe(R.string.common_activate),
onClick = supplyUM.onClick,
size = TangemButtonSize.WideAction,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.weight(1f),
)
},
)
}
}
}
@Suppress("LongMethod")
@ -345,6 +416,15 @@ private class PreviewProvider : PreviewParameterProvider<YieldSupplyUM> {
apy = "5.1",
apyText = stringReference("5.1 % APY"),
onClick = {},
onLearnMoreClick = {},
),
YieldSupplyUM.Available(
title = TextReference.Res(R.string.yield_apy_boost_banner_title),
apy = "5.1",
apyText = stringReference("APY 5.1% x3 → 15.3%"),
onClick = {},
onLearnMoreClick = {},
isBoostAvailable = true,
),
YieldSupplyUM.Content(
title = stringReference("Aave l"),

View file

@ -5,7 +5,11 @@ import com.tangem.core.ui.extensions.TextReference
data class YieldSupplyPromoUM(
val tosLink: String,
val policyLink: String,
val boostTermsLink: String,
val title: TextReference,
val subtitle: TextReference,
val tokenSymbol: String,
val isBoostAvailable: Boolean = false,
val baseApy: String? = null,
val boostedApy: String? = null,
)

View file

@ -3,6 +3,7 @@ package com.tangem.features.yield.supply.impl.promo.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.common.TangemSiteUrlBuilder
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
@ -11,15 +12,19 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.yield.supply.promo.usecase.GetBoostedApyUseCase
import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader
import com.tangem.features.yield.supply.impl.promo.YieldSupplyPromoConfig
import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class YieldSupplyPromoModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
@ -27,22 +32,15 @@ internal class YieldSupplyPromoModel @Inject constructor(
private val analytics: AnalyticsEventHandler,
private val urlOpener: UrlOpener,
private val appRouter: AppRouter,
private val getBoostedApyUseCase: GetBoostedApyUseCase,
private val boostStoryPreloader: YieldBoostStoryPreloader,
) : Model(), YieldSupplyPromoClickIntents {
val params: YieldSupplyPromoComponent.Params = paramsContainer.require()
val uiState: YieldSupplyPromoUM = YieldSupplyPromoUM(
tosLink = AAVE_TOS_URL,
policyLink = AAVE_PRIVACY_URL,
tokenSymbol = params.currency.symbol,
title = resourceReference(
R.string.yield_module_promo_screen_title_v2,
wrappedList(params.apy),
),
subtitle = resourceReference(
R.string.yield_module_promo_screen_variable_rate_info_v2,
),
)
val bottomSheetNavigation: SlotNavigation<YieldSupplyPromoConfig> = SlotNavigation()
val uiState: YieldSupplyPromoUM = buildUiState()
init {
analytics.send(
@ -51,10 +49,9 @@ internal class YieldSupplyPromoModel @Inject constructor(
blockchain = params.currency.network.name,
),
)
modelScope.launch(dispatchers.io) { boostStoryPreloader.preload() }
}
val bottomSheetNavigation: SlotNavigation<YieldSupplyPromoConfig> = SlotNavigation()
override fun onBackClick() {
appRouter.pop()
}
@ -78,6 +75,31 @@ internal class YieldSupplyPromoModel @Inject constructor(
bottomSheetNavigation.activate(YieldSupplyPromoConfig.Action)
}
private fun buildUiState(): YieldSupplyPromoUM {
val isBoost = params.isPromoEnabled
val baseApyText = if (isBoost) "${params.apy}%" else null
val boostedApyText = if (isBoost) {
val baseApy = params.apy.toBigDecimalOrNull() ?: BigDecimal.ZERO
"${getBoostedApyUseCase(baseApy)}%"
} else {
null
}
return YieldSupplyPromoUM(
tosLink = AAVE_TOS_URL,
policyLink = AAVE_PRIVACY_URL,
boostTermsLink = TangemSiteUrlBuilder.YIELD_MODE_TERMS_URL,
tokenSymbol = params.currency.symbol,
isBoostAvailable = isBoost,
baseApy = baseApyText,
boostedApy = boostedApyText,
title = resourceReference(
R.string.yield_module_promo_screen_title_v2,
wrappedList(params.apy),
),
subtitle = resourceReference(R.string.yield_module_promo_screen_variable_rate_info_v2),
)
}
private companion object {
const val AAVE_TOS_URL = "https://aave.com/terms-of-service"
const val AAVE_PRIVACY_URL = "https://aave.com/privacy-policy"

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@ -16,12 +17,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.*
@ -73,7 +79,7 @@ internal fun YieldSupplyPromoContent(
}
}
@Suppress("MagicNumber")
@Suppress("MagicNumber", "LongMethod")
@Composable
private fun ColumnScope.Content(yieldSupplyPromoUM: YieldSupplyPromoUM, clickIntents: YieldSupplyPromoClickIntents) {
Box(modifier = Modifier.weight(1f)) {
@ -98,12 +104,22 @@ private fun ColumnScope.Content(yieldSupplyPromoUM: YieldSupplyPromoUM, clickInt
.size(32.dp),
)
SpacerH(20.dp)
Text(
text = yieldSupplyPromoUM.title.resolveReference(),
style = TangemTheme.typography.h2,
textAlign = TextAlign.Center,
color = TangemTheme.colors.text.primary1,
)
if (yieldSupplyPromoUM.isBoostAvailable &&
yieldSupplyPromoUM.baseApy != null &&
yieldSupplyPromoUM.boostedApy != null
) {
BoostPromoTitle(
baseApy = yieldSupplyPromoUM.baseApy,
boostedApy = yieldSupplyPromoUM.boostedApy,
)
} else {
Text(
text = yieldSupplyPromoUM.title.resolveReference(),
style = TangemTheme.typography.h2,
textAlign = TextAlign.Center,
color = TangemTheme.colors.text.primary1,
)
}
SpacerH8()
Label(
state = LabelUM(
@ -117,6 +133,17 @@ private fun ColumnScope.Content(yieldSupplyPromoUM: YieldSupplyPromoUM, clickInt
SpacerH32()
PromoItems(yieldSupplyPromoUM.tokenSymbol)
}
if (yieldSupplyPromoUM.isBoostAvailable &&
yieldSupplyPromoUM.baseApy != null &&
yieldSupplyPromoUM.boostedApy != null
) {
SpacerH(20.dp)
PromoBoostCard(
baseApy = yieldSupplyPromoUM.baseApy,
boostedApy = yieldSupplyPromoUM.boostedApy,
onLearnMoreClick = { clickIntents.onUrlClick(yieldSupplyPromoUM.boostTermsLink) },
)
}
SpacerH32()
}
Fade(
@ -176,6 +203,102 @@ private fun PromoItems(tokenSymbol: String) {
)
}
@Suppress("MagicNumber")
@Composable
private fun BoostPromoTitle(baseApy: String, boostedApy: String) {
val accent = TangemTheme.colors.text.accent
val primary = TangemTheme.colors.text.primary1
// Pass `%1$s` back as the argument so the placeholder survives formatting (`%%` → `%`).
val raw = stringResourceSafe(R.string.yield_module_promo_screen_title_v2, "%1\$s")
val (head, rest) = raw.split("%1\$s", limit = 2)
// The template leaves a stray `%` right after the value (after a space in RU/UK), but the APY
// strings already carry their own `%` — drop that duplicate.
val tail = rest.trimStart().removePrefix("%")
val annotated = buildAnnotatedString {
append(head)
withStyle(SpanStyle(color = accent, textDecoration = TextDecoration.LineThrough)) {
append(baseApy)
}
// Arrow glyph sits lower than digits in most fonts; lift it onto the cap-height baseline.
withStyle(SpanStyle(color = accent, baselineShift = BaselineShift(0.1f))) {
append("")
}
withStyle(SpanStyle(color = accent)) {
append(boostedApy)
}
append(tail)
}
Text(
text = annotated,
style = TangemTheme.typography.h2,
textAlign = TextAlign.Center,
color = primary,
)
}
@Composable
private fun PromoBoostCard(baseApy: String, boostedApy: String, onLearnMoreClick: () -> Unit) {
val accent = TangemTheme.colors.text.accent
val primary = TangemTheme.colors.text.primary1
val tertiary = TangemTheme.colors.text.tertiary
val titleAnnotated = buildAnnotatedString {
withStyle(SpanStyle(color = primary)) {
append(stringResourceSafe(R.string.common_yield_mode))
append(" · ")
}
withStyle(SpanStyle(color = accent)) {
append("APY ")
}
withStyle(SpanStyle(color = accent, textDecoration = TextDecoration.LineThrough)) {
append(baseApy)
}
withStyle(SpanStyle(color = accent)) {
append(" x3 → ")
append(boostedApy)
}
}
val learnMoreLabel = stringResourceSafe(R.string.common_learn_more).lowercase()
val eligibilityText = stringResourceSafe(R.string.yield_apy_boost_promo_eligibility_text)
val subtitleAnnotated = buildAnnotatedString {
append(eligibilityText)
append(" ")
withLink(
link = LinkAnnotation.Clickable(
tag = "YIELD_BOOST_LEARN_MORE",
linkInteractionListener = { onLearnMoreClick() },
),
block = {
appendColored(text = learnMoreLabel, color = accent)
},
)
}
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.primary)
.padding(12.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_gift_promo_24),
contentDescription = null,
tint = TangemTheme.colors.icon.primary1,
)
Column(
modifier = Modifier.padding(start = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(text = titleAnnotated, style = TangemTheme.typography.subtitle2)
Text(
text = subtitleAnnotated,
style = TangemTheme.typography.caption2,
color = tertiary,
)
}
}
}
@Composable
private fun PromoItem(@DrawableRes icon: Int, title: TextReference, subtitle: TextReference) {
Row(
@ -262,9 +385,11 @@ private fun YieldSupplyPromoContent_Preview() {
yieldSupplyPromoUM = YieldSupplyPromoUM(
tosLink = "https://tangem.com/terms-of-service/",
policyLink = "https://tangem.com/privacy-policy/",
boostTermsLink = "https://tangem.com/docs/en/yield-mode-terms.pdf",
title = resourceReference(R.string.yield_module_promo_screen_title),
tokenSymbol = "USDT",
subtitle = resourceReference(R.string.yield_module_promo_screen_variable_rate_info, wrappedList("5.3")),
isBoostAvailable = false,
),
clickIntents = object : YieldSupplyPromoClickIntents {
override fun onBackClick() {}

View file

@ -155,6 +155,7 @@ internal class YieldSupplyToEarnBlockConverterTest {
apyText = stringReference("5.1 % APY"),
title = stringReference("Yield Mode"),
onClick = { clicked = true },
onLearnMoreClick = {},
)
val result = converter.convert(available)