Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-25 20:08:49 +05:00
parent 0939f4b85e
commit 0e382ab739
3 changed files with 91 additions and 5 deletions

View file

@ -3,7 +3,10 @@ package com.tangem.feature.wallet.presentation.wallet.domain
import com.tangem.common.TangemSiteUrlBuilder import com.tangem.common.TangemSiteUrlBuilder
import com.tangem.common.ui.notifications.NotificationId import com.tangem.common.ui.notifications.NotificationId
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
@ -20,12 +23,14 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
/** /**
* Factory for creating a list of notifications that can be shown on the wallet screen. * Factory for creating a list of notifications that can be shown on the wallet screen.
* These notifications are not critical and can be stacked with each other. * These notifications are not critical and can be stacked with each other.
*/ */
@Suppress("LongParameterList")
@ModelScoped @ModelScoped
internal class GetWalletNotificationsCarouselFactory @Inject constructor( internal class GetWalletNotificationsCarouselFactory @Inject constructor(
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase, private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
@ -34,8 +39,15 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor(
private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase, private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase, private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
) { ) {
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotificationUM>> { fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotificationUM>> {
val isBalanceResolvedFlow = singleAccountStatusListSupplier(
SingleAccountStatusListProducer.Params(userWallet.walletId),
)
.map { it.totalFiatBalance !is TotalFiatBalance.Loading }
.distinctUntilChanged()
return combine( return combine(
flow = notificationsRepository.getShouldShowNotification( flow = notificationsRepository.getShouldShowNotification(
NotificationId.EnablePushesReminderNotification.key, NotificationId.EnablePushesReminderNotification.key,
@ -43,11 +55,15 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor(
flow2 = isReadyToShowRateAppUseCase().distinctUntilChanged(), flow2 = isReadyToShowRateAppUseCase().distinctUntilChanged(),
flow3 = getWalletsUseCase().conflate(), flow3 = getWalletsUseCase().conflate(),
flow4 = yieldSupplyGetShouldShowMainPromoUseCase().distinctUntilChanged(), flow4 = yieldSupplyGetShouldShowMainPromoUseCase().distinctUntilChanged(),
) { showPushesNotification, showRateAppPromo, wallets, shouldShowYieldPromoLocal -> flow5 = isBalanceResolvedFlow,
) { showPushesNotification, showRateAppPromo, wallets, shouldShowYieldPromoLocal, isBalanceResolved ->
buildList { buildList {
addNoteMigrationNotification(userWallet, wallets, clickIntents) addNoteMigrationNotification(userWallet, wallets, clickIntents)
addRateAppNotification(showRateAppPromo, clickIntents)
// isBalanceResolved gates Rate App on the balance leaving the loading state, so it does not
// flash during loading and then get replaced once balance-dependent banners are resolved.
addRateAppNotification(showRateAppPromo && isBalanceResolved, clickIntents)
addPushNotification( addPushNotification(
shouldShow = showPushesNotification, shouldShow = showPushesNotification,

View file

@ -338,20 +338,22 @@ internal class GetWalletNotificationsFactory @Inject constructor(
) { ) {
if (userWallet !is UserWallet.Hot) return if (userWallet !is UserWallet.Hot) return
if (totalFiatBalance is TotalFiatBalance.Loading) return
val isBackupExists = userWallet.backedUp val isBackupExists = userWallet.backedUp
val isAccessCodeRequired = userWallet.hotWalletId.authType == HotWalletId.AuthType.NoPassword && val isAccessCodeRequired = userWallet.hotWalletId.authType == HotWalletId.AuthType.NoPassword &&
!shouldAccessCodeSkipped !shouldAccessCodeSkipped
val shouldShowFinishActivation = !isBackupExists || isAccessCodeRequired val shouldShowFinishActivation = !isBackupExists || isAccessCodeRequired
val messageEffect = when (totalFiatBalance) { val messageEffect = when (totalFiatBalance) {
TotalFiatBalance.Failed,
TotalFiatBalance.Loading,
-> TangemMessageEffect.None
is TotalFiatBalance.Loaded -> if (totalFiatBalance.amount.orZero().isPositive()) { is TotalFiatBalance.Loaded -> if (totalFiatBalance.amount.orZero().isPositive()) {
TangemMessageEffect.Warning TangemMessageEffect.Warning
} else { } else {
TangemMessageEffect.None TangemMessageEffect.None
} }
TotalFiatBalance.Loading,
TotalFiatBalance.Failed,
-> TangemMessageEffect.None
} }
addIf( addIf(

View file

@ -2,6 +2,13 @@ package com.tangem.feature.wallet.presentation.wallet.domain
import arrow.core.Either import arrow.core.Either
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.notifications.repository.NotificationsRepository
@ -25,6 +32,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class GetWalletNotificationsCarouselFactoryTest { internal class GetWalletNotificationsCarouselFactoryTest {
@ -35,6 +43,7 @@ internal class GetWalletNotificationsCarouselFactoryTest {
private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase = mockk() private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase = mockk()
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase = mockk() private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase = mockk()
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk() private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk()
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true)
private val clickIntents: WalletClickIntents = mockk(relaxed = true) private val clickIntents: WalletClickIntents = mockk(relaxed = true)
private val userWallet: UserWallet.Hot = mockk(relaxed = true) private val userWallet: UserWallet.Hot = mockk(relaxed = true)
@ -45,6 +54,7 @@ internal class GetWalletNotificationsCarouselFactoryTest {
shouldShowYieldBoostMainBannerUseCase = shouldShowYieldBoostMainBannerUseCase, shouldShowYieldBoostMainBannerUseCase = shouldShowYieldBoostMainBannerUseCase,
yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase, yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase,
yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, yieldSupplyFeatureToggles = yieldSupplyFeatureToggles,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
) )
@BeforeEach @BeforeEach
@ -56,6 +66,7 @@ internal class GetWalletNotificationsCarouselFactoryTest {
shouldShowYieldBoostMainBannerUseCase, shouldShowYieldBoostMainBannerUseCase,
yieldSupplyGetShouldShowMainPromoUseCase, yieldSupplyGetShouldShowMainPromoUseCase,
yieldSupplyFeatureToggles, yieldSupplyFeatureToggles,
singleAccountStatusListSupplier,
clickIntents, clickIntents,
userWallet, userWallet,
) )
@ -68,6 +79,10 @@ internal class GetWalletNotificationsCarouselFactoryTest {
every { yieldSupplyGetShouldShowMainPromoUseCase() } returns flowOf(true) every { yieldSupplyGetShouldShowMainPromoUseCase() } returns flowOf(true)
every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true
coEvery { shouldShowYieldBoostMainBannerUseCase(any()) } returns Either.Right(true) coEvery { shouldShowYieldBoostMainBannerUseCase(any()) } returns Either.Right(true)
// Balance is loaded by default, so banners gated on balance are not suppressed.
every {
singleAccountStatusListSupplier(any<SingleAccountStatusListProducer.Params>())
} returns flowOf(accountStatusList(TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL)))
} }
@ParameterizedTest @ParameterizedTest
@ -85,6 +100,24 @@ internal class GetWalletNotificationsCarouselFactoryTest {
assertThat(result.any { it is WalletNotificationUM.YieldBoostPromo }).isEqualTo(model.expectedShown) assertThat(result.any { it is WalletNotificationUM.YieldBoostPromo }).isEqualTo(model.expectedShown)
} }
@ParameterizedTest
@MethodSource("provideRateAppTestModels")
fun `GIVEN ready to show rate app and balance state WHEN create THEN rate app banner visibility matches`(
model: RateAppModel,
) = runTest {
// Arrange
every { isReadyToShowRateAppUseCase() } returns flowOf(model.isReadyToShow)
every {
singleAccountStatusListSupplier(any<SingleAccountStatusListProducer.Params>())
} returns flowOf(accountStatusList(model.balance))
// Act
val result = factory.create(userWallet, clickIntents).first()
// Assert
assertThat(result.any { it is WalletNotificationUM.RateApp }).isEqualTo(model.expectedShown)
}
@Test @Test
fun `GIVEN banner shown WHEN buttons clicked THEN routes to click intents`() = runTest { fun `GIVEN banner shown WHEN buttons clicked THEN routes to click intents`() = runTest {
// Arrange // Arrange
@ -101,6 +134,16 @@ internal class GetWalletNotificationsCarouselFactoryTest {
verify { clickIntents.onDismissYieldBoostBanner(WALLET_ID) } verify { clickIntents.onDismissYieldBoostBanner(WALLET_ID) }
} }
private fun accountStatusList(balance: TotalFiatBalance) = AccountStatusList(
userWalletId = WALLET_ID,
accountStatuses = emptyList(),
totalAccounts = 0,
totalArchivedAccounts = 0,
totalFiatBalance = balance,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
internal data class Model( internal data class Model(
val toggleEnabled: Boolean, val toggleEnabled: Boolean,
val shouldShowLocal: Boolean, val shouldShowLocal: Boolean,
@ -121,6 +164,31 @@ internal class GetWalletNotificationsCarouselFactoryTest {
), ),
) )
internal data class RateAppModel(
val isReadyToShow: Boolean,
val balance: TotalFiatBalance,
val expectedShown: Boolean,
)
private fun provideRateAppTestModels() = listOf(
// Ready to show, but the balance is still loading — don't flash before Add Funds may appear.
RateAppModel(isReadyToShow = true, balance = TotalFiatBalance.Loading, expectedShown = false),
// Ready to show and the balance is loaded — the banner can appear.
RateAppModel(
isReadyToShow = true,
balance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL),
expectedShown = true,
),
// Ready to show and the balance failed — terminal state, only loading suppresses the banner.
RateAppModel(isReadyToShow = true, balance = TotalFiatBalance.Failed, expectedShown = true),
// Not ready to show — the banner stays hidden regardless of the balance state.
RateAppModel(
isReadyToShow = false,
balance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL),
expectedShown = false,
),
)
private companion object { private companion object {
val WALLET_ID = UserWalletId("01") val WALLET_ID = UserWalletId("01")
} }