Updated on 2026-08-14
This commit is contained in:
parent
e354f12cf5
commit
febb2bda4c
10 changed files with 186 additions and 67 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,50 +101,97 @@ 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) {
|
||||
val languageISOCode = Locale.getDefault().language
|
||||
/**
|
||||
* 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, languageISOCode)
|
||||
repository.getBanners(walletId, params.placeholder, Locale.getDefault().language)
|
||||
}.onSuccess { banners ->
|
||||
val bannerUMs = banners.map { banner ->
|
||||
converter.convert(
|
||||
banner = banner,
|
||||
onDeeplinkClick = { deeplink -> onButtonClick(banner.id, deeplink) },
|
||||
onDismiss = { displayId -> onBannerDismiss(walletId, displayId) },
|
||||
)
|
||||
}.toImmutableList()
|
||||
|
||||
val savedDisplayId = savedDisplayIdByWalletId[walletId]
|
||||
val initialPage = if (savedDisplayId != null) {
|
||||
bannerUMs.indexOfFirst { it.displayId == savedDisplayId }.coerceAtLeast(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
uiState.value = PromoBannersBlockUM(
|
||||
userWalletId = walletId,
|
||||
initialPage = initialPage,
|
||||
banners = bannerUMs,
|
||||
isVisibleOnScreen = isVisibleOnScreen,
|
||||
placeholder = params.placeholder,
|
||||
onBannerShown = { displayId -> onBannerShown(walletId, displayId) },
|
||||
onCarouselScrolled = ::onCarouselScrolled,
|
||||
onPageChanged = { displayId -> savedDisplayIdByWalletId[walletId] = displayId },
|
||||
)
|
||||
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,
|
||||
onDeeplinkClick = { deeplink -> onButtonClick(banner.id, deeplink) },
|
||||
onDismiss = { displayId -> onBannerDismiss(walletId, displayId) },
|
||||
)
|
||||
}.toImmutableList()
|
||||
|
||||
val savedDisplayId = savedDisplayIdByWalletId[walletId]
|
||||
val initialPage = if (savedDisplayId != null) {
|
||||
bannerUMs.indexOfFirst { it.displayId == savedDisplayId }.coerceAtLeast(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
return PromoBannersBlockUM(
|
||||
userWalletId = walletId,
|
||||
initialPage = initialPage,
|
||||
banners = bannerUMs,
|
||||
isVisibleOnScreen = isVisible,
|
||||
placeholder = params.placeholder,
|
||||
onBannerShown = { displayId -> onBannerShown(walletId, displayId) },
|
||||
onCarouselScrolled = ::onCarouselScrolled,
|
||||
onPageChanged = { displayId -> savedDisplayIdByWalletId[walletId] = displayId },
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue