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

@ -10,6 +10,7 @@ interface StoriesComponent : ComposableContentComponent {
val storyId: String,
val nextScreen: AppRoute? = null,
val screenSource: String,
val shouldMarkAsSeenOnClose: Boolean = true,
)
interface Factory : ComponentFactory<Params, StoriesComponent>

View file

@ -1,6 +1,7 @@
package com.tangem.feature.stories.impl
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.core.res.R as CoreResR
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -13,6 +14,7 @@ internal object StoriesSlideConfigs {
fun getSlides(storyId: String): ImmutableList<SlideConfig> = when (storyId) {
StoryContentIds.STORY_FIRST_TIME_SWAP.id -> swapSlides()
StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id -> yieldPromoSlides()
else -> persistentListOf()
}
@ -34,4 +36,23 @@ internal object StoriesSlideConfigs {
com.tangem.core.res.R.string.swap_story_forth_subtitle_v2,
),
)
private fun yieldPromoSlides(): ImmutableList<SlideConfig> = persistentListOf(
SlideConfig(
CoreResR.string.yield_apy_boost_story_first_title,
CoreResR.string.yield_apy_boost_story_first_subtitle,
),
SlideConfig(
CoreResR.string.yield_apy_boost_story_second_title,
CoreResR.string.yield_apy_boost_story_second_subtitle,
),
SlideConfig(
CoreResR.string.yield_apy_boost_story_third_title,
CoreResR.string.yield_apy_boost_story_third_subtitle,
),
SlideConfig(
CoreResR.string.yield_apy_boost_story_fourth_title,
CoreResR.string.yield_apy_boost_story_fourth_subtitle,
),
)
}

View file

@ -40,7 +40,7 @@ internal class StoriesModel @Inject constructor(
private fun openScreen(hideStories: Boolean = true) {
modelScope.launch {
if (hideStories) {
if (hideStories && params.shouldMarkAsSeenOnClose) {
shouldShowStoriesUseCase.neverToShow(params.storyId)
}
router.pop()

View file

@ -33,8 +33,10 @@ import com.tangem.domain.settings.NeverToSuggestRateAppUseCase
import com.tangem.domain.settings.RemindToRateAppLaterUseCase
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.wallets.usecase.*
import com.tangem.domain.yield.supply.usecase.YieldSupplySetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
@ -87,6 +89,10 @@ internal interface WalletWarningsClickIntents {
fun onDismissAssetsDiscoveryNotification(userWalletId: UserWalletId)
fun onAssetsDiscoveryManageClick(userWalletId: UserWalletId)
fun onYieldBoostBannerClick(userWalletId: UserWalletId)
fun onDismissYieldBoostBanner(userWalletId: UserWalletId)
}
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
@ -118,6 +124,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
private val reviewManager: ReviewManager,
private val closeHotWalletUpgradeBannerUseCase: CloseHotWalletUpgradeBannerUseCase,
private val acknowledgeAssetsDiscoveryCompletionUseCase: AcknowledgeAssetsDiscoveryCompletionUseCase,
private val yieldSupplySetShouldShowMainPromoUseCase: YieldSupplySetShouldShowMainPromoUseCase,
) : BaseWalletClickIntents(), WalletWarningsClickIntents {
override fun onAddBackupCardClick() {
@ -408,4 +415,21 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
AccountId.forMainCryptoPortfolio(userWalletId),
)
}
override fun onYieldBoostBannerClick(userWalletId: UserWalletId) {
appRouter.push(
Stories(
storyId = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id,
nextScreen = null,
screenSource = "YieldMainBanner",
shouldMarkAsSeenOnClose = false,
),
)
}
override fun onDismissYieldBoostBanner(userWalletId: UserWalletId) {
modelScope.launch {
yieldSupplySetShouldShowMainPromoUseCase(shouldShow = false)
}
}
}

View file

@ -98,6 +98,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
is WalletNotification.Warning.TangemPayRefreshNeeded -> null
is WalletNotification.Warning.TangemPayUnreachable -> null
is WalletNotification.UpgradeHotWalletPromo -> null
is WalletNotification.YieldBoostPromo -> null
is WalletNotification.AssetsDiscoveryCompleted -> null
is WalletNotification.CreateTangemPayAccount -> TangemPayAnalyticsEvents.PermanentBannerShowed()
}

View file

@ -5,6 +5,7 @@ package com.tangem.feature.wallet.presentation.wallet.domain
import com.tangem.common.ui.notifications.NotificationId
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.models.AccountStatusList
@ -28,7 +29,10 @@ import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase
import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase
import com.tangem.domain.yield.supply.promo.usecase.ShouldShowYieldBoostMainBannerUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase
import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.account.AccountDependencies
@ -60,6 +64,10 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
private val checkHotWalletUpgradeBannerUseCase: CheckHotWalletUpgradeBannerUseCase,
private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val designFeatureToggles: DesignFeatureToggles,
) {
@Suppress("UNCHECKED_CAST", "MagicNumber", "LongMethod", "CastNullableToNonNullableType")
@ -87,6 +95,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
getUpgradeBannerClosureTimestampUseCase(userWallet.walletId)
.distinctUntilChanged(),
assetsDiscoveryProgressFlow,
yieldSupplyGetShouldShowMainPromoUseCase().distinctUntilChanged(),
) { array -> array }
.map { array ->
val accountStatusList = array[0] as AccountStatusList
@ -97,6 +106,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
val shouldShowUpgradeBanner = array[5] as Boolean
val closureTimestamp = array[6] as? Long
val assetsDiscoveryProgress = array[7] as AssetsDiscoveryProgress
val shouldShowYieldBoostPromoLocal = array[8] as Boolean
val flattenCurrencies = accountStatusList.flattenCurrencies()
val paymentAccountStatus = accountStatusList.accountStatuses
@ -165,10 +175,34 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
walletClickIntents = clickIntents,
)
}
addYieldBoostBannerNotification(
userWallet = userWallet,
shouldShowLocal = shouldShowYieldBoostPromoLocal,
clickIntents = clickIntents,
)
}.toImmutableList()
}
}
private suspend fun MutableList<WalletNotification>.addYieldBoostBannerNotification(
userWallet: UserWallet,
shouldShowLocal: Boolean,
clickIntents: WalletClickIntents,
) {
if (!shouldShowLocal) return
if (!yieldSupplyFeatureToggles.isYieldPromoEnabled) return
if (designFeatureToggles.isRedesignEnabled) return
val shouldShow = shouldShowYieldBoostMainBannerUseCase(userWallet.walletId).getOrNull() == true
if (!shouldShow) return
add(
WalletNotification.YieldBoostPromo(
onClick = { clickIntents.onYieldBoostBannerClick(userWallet.walletId) },
onCloseClick = { clickIntents.onDismissYieldBoostBanner(userWallet.walletId) },
),
)
}
private fun MutableList<WalletNotification>.addTangemPayWarnings(
status: AccountStatus.Payment,
userWallet: UserWallet,

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.components.notifications.NotificationConfig.IconTint
import com.tangem.core.ui.extensions.TextReference
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
import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType
import com.tangem.feature.wallet.impl.R
@ -319,6 +320,27 @@ sealed class WalletNotification(val config: NotificationConfig) {
),
)
data class YieldBoostPromo(
val onClick: () -> Unit,
val onCloseClick: () -> Unit,
) : WalletNotification(
config = NotificationConfig(
title = com.tangem.core.ui.extensions.combinedReference(
resourceReference(com.tangem.core.res.R.string.yield_apy_boost_banner_title),
stringReference(" · "),
resourceReference(com.tangem.core.res.R.string.yield_apy_boost_banner_title_apy_multiplied),
),
subtitle = resourceReference(com.tangem.core.res.R.string.yield_apy_boost_banner_subtitle),
iconResId = com.tangem.core.ui.R.drawable.ic_analytics_up_24,
iconTint = IconTint.Accent,
onCloseClick = onCloseClick,
buttonsState = ButtonsState.PrimaryButtonConfig(
text = resourceReference(com.tangem.core.res.R.string.yield_apy_boost_banner_button_title),
onClick = onClick,
),
),
)
data class AssetsDiscoveryCompleted(
val onCloseClick: () -> Unit,
val onManageTokensClick: () -> Unit,

View file

@ -1,6 +1,8 @@
package com.tangem.feature.wallet.presentation.wallet.subscribers
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.stories.GetStoryContentUseCase
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsAnalyticsSender
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsSingleEventSender
@ -15,7 +17,9 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
@Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]")
internal class MultiWalletWarningsSubscriber @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
@ -24,6 +28,7 @@ internal class MultiWalletWarningsSubscriber @AssistedInject constructor(
private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory,
private val walletWarningsAnalyticsSender: WalletWarningsAnalyticsSender,
private val walletWarningsSingleEventSender: WalletWarningsSingleEventSender,
private val getStoryContentUseCase: GetStoryContentUseCase,
) : WalletSubscriber() {
override fun create(coroutineScope: CoroutineScope): Flow<ImmutableList<WalletNotification>> {
@ -31,6 +36,15 @@ internal class MultiWalletWarningsSubscriber @AssistedInject constructor(
.conflate()
.distinctUntilChanged()
.onEach { warnings ->
if (warnings.any { it is WalletNotification.YieldBoostPromo }) {
coroutineScope.launch {
getStoryContentUseCase.invokeSync(
id = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id,
refresh = true,
)
}
}
val displayedState = stateController.getWalletState(userWallet.walletId)
// Wait until the wallet appears in the list

View file

@ -2,11 +2,18 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.tangem.common.ui.notifications.CreatePaymentAccountNotification
import com.tangem.core.ui.components.notifications.NoteMigrationNotification
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.annotatedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.ForceDarkTheme
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.impl.R
@ -48,6 +55,14 @@ internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotificati
)
}
}
is WalletNotification.YieldBoostPromo -> {
Notification(
config = item.config.copy(title = annotatedReference(yieldBoostPromoTitle())),
modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
iconTint = TangemTheme.colors.icon.accent,
subtitleColor = TangemTheme.colors.text.secondary,
)
}
is WalletNotification.CreateTangemPayAccount -> {
CreatePaymentAccountNotification(
modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
@ -76,4 +91,18 @@ internal fun LazyListScope.notifications(configs: ImmutableList<WalletNotificati
}
},
)
}
@Composable
private fun yieldBoostPromoTitle(): AnnotatedString {
val accent = TangemTheme.colors.text.accent
val baseTitle = stringResourceSafe(com.tangem.core.res.R.string.yield_apy_boost_banner_title)
val tailTitle = stringResourceSafe(com.tangem.core.res.R.string.yield_apy_boost_banner_title_apy_multiplied)
return buildAnnotatedString {
append(baseTitle)
append(" · ")
withStyle(SpanStyle(color = accent)) {
append(tailTitle)
}
}
}

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)