Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 16:11:04 +02:00
parent e354f12cf5
commit febb2bda4c
10 changed files with 186 additions and 67 deletions

View file

@ -87,7 +87,11 @@ internal class DefaultFeedComponent(
modifier = modifier,
state = state,
promoBannersBlockComponent = ComposableContentComponent { promoModifier ->
promoBannersBlockComponent.ContentWithPadding(modifier = promoModifier, horizontalItemPadding = 16.dp)
promoBannersBlockComponent.ContentWithPadding(
modifier = promoModifier,
horizontalItemPadding = 16.dp,
walletId = null,
)
},
contentPadding = contentPadding,
)

View file

@ -1,14 +1,21 @@
package com.tangem.features.promobanners.api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.factory.ComponentFactory
@Immutable
interface PromoBannersBlockComponent {
/**
* @param walletId when non-null, renders the banners of that specific wallet (used by the wallet
* pager so each page shows its own banners synchronously while swiping); when null, renders the
* currently selected wallet's banners.
*/
@Composable
fun ContentWithPadding(horizontalItemPadding: Dp, modifier: Modifier)
fun ContentWithPadding(horizontalItemPadding: Dp, walletId: String?, modifier: Modifier)
fun setVisibleOnScreen(isVisible: Boolean)

View file

@ -26,8 +26,15 @@ internal class DefaultPromoBannersBlockComponent @AssistedInject constructor(
}
@Composable
override fun ContentWithPadding(horizontalItemPadding: Dp, modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
override fun ContentWithPadding(horizontalItemPadding: Dp, walletId: String?, modifier: Modifier) {
val state = if (walletId != null) {
val statesByWallet by model.bannerStates.collectAsStateWithLifecycle()
statesByWallet[walletId]
} else {
val selectedState by model.uiState.collectAsStateWithLifecycle()
selectedState
} ?: return
PromoBannersBlock(
state = state,
modifier = modifier,

View file

@ -1,11 +1,11 @@
package com.tangem.features.promobanners.impl.model
import androidx.core.net.toUri
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
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.navigation.deeplink.DeeplinkLauncher
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.promobanners.impl.analytics.PromoBannerAnalyticsEvent
@ -13,11 +13,13 @@ import com.tangem.features.promobanners.impl.converters.PromoBannerDisplayToNoti
import com.tangem.features.promobanners.impl.repository.PromoBannersRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.collections.immutable.persistentListOf
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@ -39,20 +41,58 @@ internal class PromoBannersBlockModel @Inject constructor(
private val placeholderName: String = params.placeholder.value
private val shownBannerIds: MutableSet<ShownBannerKey> = ConcurrentHashMap.newKeySet()
private var isVisibleOnScreen: Boolean = params.isInitiallyVisibleOnScreen
private var wasCarouselScrolled = false
private val savedDisplayIdByWalletId: MutableMap<String, Int> = mutableMapOf()
private val prefetchedWalletIds: MutableSet<String> = ConcurrentHashMap.newKeySet()
private val prefetchSemaphore = Semaphore(permits = PREFETCH_PARALLELISM)
val uiState: StateFlow<PromoBannersBlockUM>
field = MutableStateFlow(getInitialState())
/** Raw banners per wallet, filled from cache/prefetch/network. Source of truth for the UI. */
private val rawBannersByWalletId = MutableStateFlow<Map<String, List<PromoBannerDisplay>>>(emptyMap())
private val selectedWalletId = MutableStateFlow<String?>(null)
private val screenVisible = MutableStateFlow(params.isInitiallyVisibleOnScreen)
private val baseBannerStates: StateFlow<Map<String, PromoBannersBlockUM>> =
rawBannersByWalletId
.map { rawByWallet ->
rawByWallet.mapValues { (walletId, banners) -> buildState(walletId, banners, isVisible = false) }
}
.stateIn(modelScope, SharingStarted.Eagerly, emptyMap())
/**
* Per-wallet UI states, so a pager page can render its own wallet's banners synchronously from
* cache as it slides in instead of the whole block waiting for the wallet selection to settle.
* Only cheaply toggles [PromoBannersBlockUM.isVisibleOnScreen] on selection/visibility change.
*/
val bannerStates: StateFlow<Map<String, PromoBannersBlockUM>> =
combine(baseBannerStates, selectedWalletId, screenVisible) { base, selected, visible ->
base.mapValues { (walletId, state) ->
// Only the active wallet is "visible on screen" for analytics purposes; pre-composed
// off-screen pager pages must not emit "banner shown" events.
val shouldBeVisible = visible && walletId == selected
if (state.isVisibleOnScreen == shouldBeVisible) {
state
} else {
state.copy(
isVisibleOnScreen = shouldBeVisible,
)
}
}
}.stateIn(modelScope, SharingStarted.Eagerly, emptyMap())
val uiState: StateFlow<PromoBannersBlockUM> =
combine(bannerStates, selectedWalletId) { states, selected ->
states[selected] ?: getInitialState()
}.stateIn(modelScope, SharingStarted.Eagerly, getInitialState())
init {
subscribeOnSelectedWallet()
prefetchAllWallets()
}
fun setVisibleOnScreen(visible: Boolean) {
isVisibleOnScreen = visible
uiState.update { it.copy(isVisibleOnScreen = visible) }
screenVisible.value = visible
}
private fun subscribeOnSelectedWallet() {
@ -61,19 +101,68 @@ internal class PromoBannersBlockModel @Inject constructor(
.filterNotNull()
.map { it.walletId.stringValue }
.distinctUntilChanged()
.onEach {
.collectLatest { walletId ->
wasCarouselScrolled = false
selectedWalletId.value = walletId
loadWallet(walletId)
}
.collectLatest { walletId -> loadBanners(walletId) }
}
}
private suspend fun loadBanners(walletId: String) {
/**
* Warms the banners for every wallet in the background so switching to another wallet renders its
* banners instantly from cache instead of after a network gap. Each wallet is fetched at most once
* ([prefetchedWalletIds]); [PromoBannersRepository.getBanners] is a no-op once the wallet is cached.
*/
private fun prefetchAllWallets() {
modelScope.launch {
val languageISOCode = Locale.getDefault().language
userWalletsListRepository.userWallets
.filterNotNull()
.collect { wallets ->
wallets.forEach { wallet ->
val walletId = wallet.walletId.stringValue
if (prefetchedWalletIds.add(walletId)) {
modelScope.launch {
prefetchSemaphore.withPermit {
runSuspendCatching {
repository.getBanners(walletId, params.placeholder, languageISOCode)
}.onSuccess { putBanners(walletId, it) }
.onFailure { prefetchedWalletIds.remove(walletId) }
}
}
}
}
}
}
}
private suspend fun loadWallet(walletId: String) {
val cached = runSuspendCatching { repository.getCachedBanners(walletId, params.placeholder) }.getOrNull()
if (cached != null) {
putBanners(walletId, cached)
return
}
putBanners(walletId, banners = emptyList())
runSuspendCatching {
repository.getBanners(walletId, params.placeholder, Locale.getDefault().language)
}.onSuccess { banners ->
putBanners(walletId, banners)
}.onFailure { error ->
TangemLogger.w("Failed to load promo banners", error)
}
}
private fun putBanners(walletId: String, banners: List<PromoBannerDisplay>) {
rawBannersByWalletId.update { it + (walletId to banners) }
}
private fun buildState(
walletId: String,
banners: List<PromoBannerDisplay>,
isVisible: Boolean,
): PromoBannersBlockUM {
val bannerUMs = banners.map { banner ->
converter.convert(
banner = banner,
@ -89,22 +178,20 @@ internal class PromoBannersBlockModel @Inject constructor(
0
}
uiState.value = PromoBannersBlockUM(
return PromoBannersBlockUM(
userWalletId = walletId,
initialPage = initialPage,
banners = bannerUMs,
isVisibleOnScreen = isVisibleOnScreen,
isVisibleOnScreen = isVisible,
placeholder = params.placeholder,
onBannerShown = { displayId -> onBannerShown(walletId, displayId) },
onCarouselScrolled = ::onCarouselScrolled,
onPageChanged = { displayId -> savedDisplayIdByWalletId[walletId] = displayId },
)
}.onFailure { error ->
TangemLogger.w("Failed to load promo banners", error)
}
}
private fun onBannerShown(walletId: String, displayId: Int) {
if (walletId != selectedWalletId.value) return
if (shownBannerIds.add(walletId to displayId)) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholderName))
}
@ -137,7 +224,7 @@ internal class PromoBannersBlockModel @Inject constructor(
userWalletId = "",
initialPage = 0,
banners = persistentListOf(),
isVisibleOnScreen = isVisibleOnScreen,
isVisibleOnScreen = screenVisible.value,
placeholder = params.placeholder,
onBannerShown = {},
onCarouselScrolled = {},
@ -146,12 +233,9 @@ internal class PromoBannersBlockModel @Inject constructor(
private fun onBannerDismiss(walletId: String, displayId: Int) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholderName))
uiState.update { state ->
state.copy(
banners = state.banners
.filterNot { it.displayId == displayId }
.toImmutableList(),
)
rawBannersByWalletId.update { byWallet ->
val banners = byWallet[walletId] ?: return@update byWallet
byWallet + (walletId to banners.filterNot { it.id == displayId })
}
modelScope.launch {
runSuspendCatching {
@ -169,5 +253,6 @@ internal class PromoBannersBlockModel @Inject constructor(
const val DEEPLINK_SCHEME_TANGEM = "tangem"
const val DEEPLINK_HOST_SURVEY = "survey"
const val QUERY_DISPLAY_ID = "display_id"
const val PREFETCH_PARALLELISM = 3
}
}

View file

@ -52,6 +52,10 @@ internal class DefaultPromoBannersRepository(
return banners
}
override suspend fun getCachedBanners(walletId: String, placeholder: Placeholder): List<PromoBannerDisplay>? {
return cache.getSyncOrNull()?.get(BannersCacheKey(walletId, placeholder))
}
override suspend fun dismissBanner(walletId: String, displayId: Int) {
cache.update(default = emptyMap()) { current ->
current.mapValues { (key, banners) ->

View file

@ -11,5 +11,7 @@ internal interface PromoBannersRepository {
languageISOCode: String,
): List<PromoBannerDisplay>
suspend fun getCachedBanners(walletId: String, placeholder: Placeholder): List<PromoBannerDisplay>?
suspend fun dismissBanner(walletId: String, displayId: Int)
}

View file

@ -25,7 +25,6 @@ import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent
import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.feature.wallet.child.tokenActions.DefaultTokenActionsComponent
import com.tangem.feature.wallet.child.tokenActions.TokenActionsComponent
import com.tangem.feature.wallet.child.wallet.model.WalletModel
@ -36,6 +35,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen2
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedComponent
import com.tangem.feature.walletsettings.component.RenameWalletComponent
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
@ -295,14 +295,10 @@ internal class WalletComponent @AssistedInject constructor(
var headerSize by remember { mutableStateOf(0.dp) }
val dialog by dialog.subscribeAsState()
val uiState by model.uiState.collectAsStateWithLifecycle()
val promoBannersBlockComponentContentComponent = ComposableContentComponent { promoModifier ->
promoBannersBlockComponent.ContentWithPadding(modifier = promoModifier, horizontalItemPadding = 12.dp)
}
if (designFeatureToggles.isRedesignEnabled) {
WalletScreen2(
state = uiState,
promoBannersBlockComponent = promoBannersBlockComponentContentComponent,
promoBannersBlockComponent = promoBannersBlockComponent,
tangemPayComponent = tangemPayMainBlockComponent,
virtualAccountComponent = virtualAccountMainBlockComponent,
bottomSheetContent = { onExpandSheet ->
@ -319,7 +315,7 @@ internal class WalletComponent @AssistedInject constructor(
} else {
WalletScreen(
state = uiState,
promoBannersBlockComponent = promoBannersBlockComponentContentComponent,
promoBannersBlockComponent = promoBannersBlockComponent,
tangemPayComponent = tangemPayMainBlockComponent,
virtualAccountComponent = virtualAccountMainBlockComponent,
bottomSheetContent = { onExpandSheet ->

View file

@ -59,7 +59,6 @@ import com.tangem.core.ui.components.sheetscaffold.*
import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar
import com.tangem.core.ui.components.snackbar.TangemSnackbar
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.softLayerShadow
import com.tangem.core.ui.extensions.stringResourceSafe
@ -83,6 +82,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeTokensButton
import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock
import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.tangempay.component.TangemPayMainBlockComponent
import com.tangem.features.tangempay.entity.TangemPayMainUM
import com.tangem.features.virtualaccount.main.component.VirtualAccountMainBlockComponent
@ -98,7 +98,7 @@ internal fun WalletScreen(
state: WalletScreenState,
tangemPayComponent: TangemPayMainBlockComponent,
virtualAccountComponent: VirtualAccountMainBlockComponent,
promoBannersBlockComponent: ComposableContentComponent? = null,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
@ -142,7 +142,7 @@ private fun WalletContent(
snackbarHostState: SnackbarHostState,
isAutoScroll: State<Boolean>,
onAutoScrollReset: () -> Unit,
promoBannersBlockComponent: ComposableContentComponent? = null,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
@ -226,7 +226,11 @@ private fun WalletContent(
promoBannersBlockComponent?.let { component ->
item(key = "PromoBannersBlock") {
component.Content(modifier = itemModifier)
component.ContentWithPadding(
horizontalItemPadding = 12.dp,
modifier = itemModifier,
walletId = null,
)
}
}

View file

@ -55,7 +55,6 @@ import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.rememberIsKeyboardVisible
import com.tangem.core.ui.components.sheetscaffold.*
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
@ -74,12 +73,15 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.common.Wallet
import com.tangem.feature.wallet.presentation.wallet.ui.components.common.WalletPagerIndicator
import com.tangem.feature.wallet.presentation.wallet.ui.components.common.WalletTopBar
import com.tangem.feature.wallet.presentation.wallet.ui.utils.lazyListStateMapSaver
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.tangempay.component.TangemPayMainBlockComponent
import com.tangem.features.tangempay.entity.TangemPayMainUM
import com.tangem.features.virtualaccount.main.component.VirtualAccountMainBlockComponent
import com.tangem.features.virtualaccount.main.entity.VirtualAccountMainUM
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeTint
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
@ -95,7 +97,7 @@ internal fun WalletScreen2(
tangemPayComponent: TangemPayMainBlockComponent,
virtualAccountComponent: VirtualAccountMainBlockComponent,
modifier: Modifier = Modifier,
promoBannersBlockComponent: ComposableContentComponent? = null,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
@ -147,7 +149,7 @@ internal fun WalletScreen2(
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
onBottomSheetStateChange = onBottomSheetStateChange,
modifier = modifier,
listStates = listStates,
listStates = remember(listStates) { listStates.toImmutableMap() },
)
WalletEventEffect(
@ -171,9 +173,9 @@ private fun WalletContent2(
tangemPayComponent: TangemPayMainBlockComponent,
virtualAccountComponent: VirtualAccountMainBlockComponent,
behavior: TangemCollapsingAppBarBehavior,
listStates: Map<Int, LazyListState>,
listStates: ImmutableMap<Int, LazyListState>,
modifier: Modifier = Modifier,
promoBannersBlockComponent: ComposableContentComponent? = null,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit,
@ -282,6 +284,7 @@ private fun WalletContent2(
val currentWallet = state.wallets2.getOrElse(currentWalletIndex) {
state.wallets2[state.selectedWalletIndex]
}
val currentWalletId = currentWallet.walletsBalanceUM.id.stringValue
LaunchedEffect(walletsPagerState.currentPage, currentWallet.walletsBalanceUM) {
if (walletsPagerState.currentPage == currentWalletIndex) {
@ -350,6 +353,7 @@ private fun WalletContent2(
contentPadding = contentPadding,
tangemPayComponent = tangemPayComponent,
promoBannersBlockComponent = promoBannersBlockComponent,
walletId = currentWalletId,
virtualAccountComponent = virtualAccountComponent,
modifier = Modifier
.fillMaxSize()

View file

@ -10,12 +10,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import com.tangem.common.ui.notifications.notifications
import com.tangem.common.ui.notifications.notificationsCarousel
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.txHistoryItems
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.MainScreenTestTags
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
@ -26,6 +26,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.nftCollection
import com.tangem.feature.wallet.presentation.wallet.ui.components.organizeTokens2
import com.tangem.feature.wallet.presentation.wallet.ui.components.tangemPay
import com.tangem.feature.wallet.presentation.wallet.ui.components.virtualAccount
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.tangempay.component.TangemPayMainBlockComponent
import com.tangem.features.virtualaccount.main.component.VirtualAccountMainBlockComponent
import kotlinx.collections.immutable.toPersistentList
@ -40,7 +41,8 @@ internal fun WalletListContent(
virtualAccountComponent: VirtualAccountMainBlockComponent,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
promoBannersBlockComponent: ComposableContentComponent? = null,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
walletId: String? = null,
) {
val containerColor = TangemTheme.colors2.surface.level1
@ -67,7 +69,11 @@ internal fun WalletListContent(
promoBannersBlockComponent?.let { component ->
item(key = "PromoBannersBlock") {
component.Content(modifier = Modifier.padding(top = TangemTheme.dimens2.x3))
component.ContentWithPadding(
horizontalItemPadding = 12.dp,
modifier = Modifier.padding(top = TangemTheme.dimens2.x3),
walletId = walletId,
)
}
}