Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-20 12:11:28 +02:00
parent 790fa03399
commit 50f30e9bbf
25 changed files with 583 additions and 42 deletions

View file

@ -0,0 +1,39 @@
package com.tangem.features.promobanners.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.promobanners.impl.model.PromoBannersBlockModel
import com.tangem.features.promobanners.impl.ui.PromoBannersBlock
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultPromoBannersBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: PromoBannersBlockComponent.Params,
) : PromoBannersBlockComponent, AppComponentContext by appComponentContext {
private val model: PromoBannersBlockModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
PromoBannersBlock(
state = state,
modifier = modifier,
)
}
@AssistedFactory
interface Factory : PromoBannersBlockComponent.Factory {
override fun create(
context: AppComponentContext,
params: PromoBannersBlockComponent.Params,
): DefaultPromoBannersBlockComponent
}
}

View file

@ -5,20 +5,37 @@ import com.tangem.core.analytics.models.AnalyticsEvent
internal sealed class PromoBannerAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Banner", event = event, params = params) {
) : AnalyticsEvent(category = "Promo Banner", event = event, params = params) {
data class Shown(val bannerId: String) : PromoBannerAnalyticsEvent(
data class Shown(
private val displayId: String,
private val placeholder: String,
) : PromoBannerAnalyticsEvent(
event = "Banner Shown",
params = mapOf("banner_id" to bannerId),
params = mapOf("Display Id" to displayId, "Placeholder" to placeholder),
)
data class Clicked(val bannerId: String) : PromoBannerAnalyticsEvent(
event = "Banner Clicked",
params = mapOf("banner_id" to bannerId),
data class CarouselScrolled(
private val displayId: String,
private val placeholder: String,
) : PromoBannerAnalyticsEvent(
event = "Banner Carousel Scrolled",
params = mapOf("Display Id" to displayId, "Placeholder" to placeholder),
)
data class Dismissed(val bannerId: String) : PromoBannerAnalyticsEvent(
data class Clicked(
private val displayId: String,
private val placeholder: String,
) : PromoBannerAnalyticsEvent(
event = "Banner Button Clicked",
params = mapOf("Display Id" to displayId, "Placeholder" to placeholder),
)
data class Dismissed(
private val displayId: String,
private val placeholder: String,
) : PromoBannerAnalyticsEvent(
event = "Banner Dismissed",
params = mapOf("banner_id" to bannerId),
params = mapOf("Display Id" to displayId, "Placeholder" to placeholder),
)
}

View file

@ -0,0 +1,63 @@
package com.tangem.features.promobanners.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.promobanners.impl.DefaultPromoBannersBlockComponent
import com.tangem.features.promobanners.impl.model.PromoBannersBlockModel
import com.tangem.features.promobanners.impl.repository.DefaultPromoBannersRepository
import com.tangem.features.promobanners.impl.repository.PromoBannersRepository
import com.tangem.features.promobanners.impl.toggles.DefaultNewPromoBannersFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface PromoBannersFeatureModule {
@Binds
@Singleton
fun bindPromoBannersBlockComponentFactory(
factory: DefaultPromoBannersBlockComponent.Factory,
): PromoBannersBlockComponent.Factory
@Binds
@Singleton
fun bindFeatureToggles(impl: DefaultNewPromoBannersFeatureToggles): NewPromoBannersFeatureToggles
companion object {
@Provides
@Singleton
fun provideRepository(
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): PromoBannersRepository {
return DefaultPromoBannersRepository(
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
cache = RuntimeSharedStore(),
)
}
}
}
@Module
@InstallIn(ModelComponent::class)
internal interface PromoBannersModelModule {
@Binds
@IntoMap
@ClassKey(PromoBannersBlockModel::class)
fun bindPromoBannersBlockModel(model: PromoBannersBlockModel): Model
}

View file

@ -0,0 +1,123 @@
package com.tangem.features.promobanners.impl.model
import com.tangem.common.routing.LinkHandler
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.domain.common.wallets.UserWalletsListRepository
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.promobanners.impl.analytics.PromoBannerAnalyticsEvent
import com.tangem.features.promobanners.impl.converters.PromoBannerDisplayToNotificationConverter
import com.tangem.features.promobanners.impl.repository.PromoBannersRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@ModelScoped
internal class PromoBannersBlockModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val repository: PromoBannersRepository,
private val linkHandler: LinkHandler,
private val analyticsEventHandler: AnalyticsEventHandler,
private val userWalletsListRepository: UserWalletsListRepository,
) : Model() {
private val params = paramsContainer.require<PromoBannersBlockComponent.Params>()
private val converter = PromoBannerDisplayToNotificationConverter()
private val placeholder: String = params.placeholder.name.lowercase()
private val shownBannerIds: MutableSet<String> = ConcurrentHashMap.newKeySet()
private var wasCarouselScrolled = false
val uiState: StateFlow<PromoBannersBlockUM>
field = MutableStateFlow(PromoBannersBlockUM())
init {
subscribeOnSelectedWallet()
}
private fun subscribeOnSelectedWallet() {
modelScope.launch {
userWalletsListRepository.selectedUserWallet
.filterNotNull()
.map { it.walletId.stringValue }
.distinctUntilChanged()
.onEach {
shownBannerIds.clear()
wasCarouselScrolled = false
}
.collectLatest { walletId -> loadBanners(walletId) }
}
}
private suspend fun loadBanners(walletId: String) {
val locale = Locale.getDefault().language
runSuspendCatching {
repository.getBanners(walletId, params.placeholder, locale)
}.onSuccess { banners ->
uiState.value = PromoBannersBlockUM(
banners = banners.map { banner ->
converter.convert(
banner = banner,
onDeeplinkClick = { deeplink -> onButtonClick(banner.id, deeplink) },
onDismiss = { displayId -> onBannerDismiss(walletId, displayId) },
)
}.toImmutableList(),
onBannerShown = ::onBannerShown,
onCarouselScrolled = ::onCarouselScrolled,
)
}.onFailure { error ->
Timber.w(error, "Failed to load promo banners")
}
}
private fun onBannerShown(displayId: String) {
if (shownBannerIds.add(displayId)) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholder))
}
}
private fun onCarouselScrolled(displayId: String) {
if (!wasCarouselScrolled) {
wasCarouselScrolled = true
analyticsEventHandler.send(PromoBannerAnalyticsEvent.CarouselScrolled(displayId, placeholder))
}
}
private fun onButtonClick(displayId: String, deeplink: String?) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholder))
deeplink?.let { linkHandler.navigate(it) }
}
private fun onBannerDismiss(walletId: String, displayId: String) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholder))
uiState.update { state ->
state.copy(
banners = state.banners
.filterNot { it.displayId == displayId }
.toImmutableList(),
)
}
modelScope.launch {
runSuspendCatching {
repository.dismissBanner(walletId, displayId)
}.onFailure { error ->
Timber.w(
error,
"Failed to dismiss promo banner %s for wallet %s",
displayId,
walletId,
)
}
}
}
}

View file

@ -5,4 +5,6 @@ import kotlinx.collections.immutable.persistentListOf
internal data class PromoBannersBlockUM(
val banners: ImmutableList<PromoBannerNotificationUM> = persistentListOf(),
val onBannerShown: (displayId: String) -> Unit = {},
val onCarouselScrolled: (displayId: String) -> Unit = {},
)

View file

@ -3,26 +3,41 @@ package com.tangem.features.promobanners.impl.repository
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.promobanners.DismissPromoBannerRequest
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.features.promobanners.api.PromoBannersBlockComponent.Placeholder
import com.tangem.features.promobanners.impl.converters.PromoBannerDisplayDTOConverter
import com.tangem.features.promobanners.impl.model.PromoBannerDisplay
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class DefaultPromoBannersRepository @Inject constructor(
internal typealias BannersCache = Map<BannersCacheKey, List<PromoBannerDisplay>>
internal data class BannersCacheKey(
val walletId: String,
val placeholder: Placeholder,
)
internal class DefaultPromoBannersRepository(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val cache: RuntimeSharedStore<BannersCache>,
) : PromoBannersRepository {
private val converter = PromoBannerDisplayDTOConverter()
override suspend fun getBanners(walletId: String, placeholder: String, locale: String): List<PromoBannerDisplay> =
withContext(dispatchers.io) {
override suspend fun getBanners(
walletId: String,
placeholder: Placeholder,
locale: String,
): List<PromoBannerDisplay> {
val key = BannersCacheKey(walletId, placeholder)
cache.getSyncOrNull()?.get(key)?.let { return it }
val banners = withContext(dispatchers.io) {
tangemTechApi.getPromoBannerDisplays(
walletId = walletId,
placeholder = placeholder,
placeholder = placeholder.toApiValue(),
locale = locale,
).getOrThrow()
.items
@ -30,7 +45,24 @@ internal class DefaultPromoBannersRepository @Inject constructor(
.sortedBy { it.priority.order }
}
cache.update(default = emptyMap()) { current ->
current + (key to banners)
}
return banners
}
override suspend fun dismissBanner(walletId: String, displayId: String) {
cache.update(default = emptyMap()) { current ->
current.mapValues { (key, banners) ->
if (key.walletId == walletId) {
banners.filterNot { it.id == displayId }
} else {
banners
}
}
}
withContext(dispatchers.io) {
val request = DismissPromoBannerRequest(
walletId = walletId,
@ -39,4 +71,9 @@ internal class DefaultPromoBannersRepository @Inject constructor(
tangemTechApi.dismissPromoBannerDisplay(displayId, request).getOrThrow()
}
}
private fun Placeholder.toApiValue(): String = when (this) {
Placeholder.MAIN -> "main"
Placeholder.FEED -> "shtorka"
}
}

View file

@ -1,10 +1,11 @@
package com.tangem.features.promobanners.impl.repository
import com.tangem.features.promobanners.api.PromoBannersBlockComponent.Placeholder
import com.tangem.features.promobanners.impl.model.PromoBannerDisplay
internal interface PromoBannersRepository {
suspend fun getBanners(walletId: String, placeholder: String, locale: String): List<PromoBannerDisplay>
suspend fun getBanners(walletId: String, placeholder: Placeholder, locale: String): List<PromoBannerDisplay>
suspend fun dismissBanner(walletId: String, displayId: String)
}

View file

@ -0,0 +1,167 @@
package com.tangem.features.promobanners.impl.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.util.lerp
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.pager.PagerIndicator
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.promobanners.impl.model.PromoBannerNotificationUM
import com.tangem.features.promobanners.impl.model.PromoBannersBlockUM
import kotlin.math.ceil
import kotlin.math.floor
@Composable
internal fun PromoBannersBlock(state: PromoBannersBlockUM, modifier: Modifier = Modifier) {
if (state.banners.isEmpty()) return
if (state.banners.size == 1) {
val banner = state.banners.first()
LaunchedEffect(banner.displayId) {
state.onBannerShown(banner.displayId)
}
SingleBanner(
banner = banner,
modifier = modifier,
)
} else {
BannersCarousel(
banners = state.banners,
onBannerShown = state.onBannerShown,
onCarouselScroll = state.onCarouselScrolled,
modifier = modifier,
)
}
}
@Composable
private fun SingleBanner(banner: PromoBannerNotificationUM, modifier: Modifier = Modifier) {
Notification(
config = banner.config,
modifier = modifier.fillMaxWidth(),
containerColor = TangemTheme.colors.background.primary,
)
}
@Composable
private fun BannersCarousel(
banners: List<PromoBannerNotificationUM>,
onBannerShown: (String) -> Unit,
onCarouselScroll: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val pagerState = rememberPagerState(pageCount = { banners.size })
LaunchedEffect(pagerState, banners) {
snapshotFlow { pagerState.currentPage }
.collect { page ->
banners.getOrNull(page)?.let { banner ->
onBannerShown(banner.displayId)
if (page == 1) {
// one-time event when user scrolls from first to second page,
// indicating that he has interacted with the carousel
onCarouselScroll(banner.displayId)
}
}
}
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SmoothHeightPager(
banners = banners,
pagerState = pagerState,
modifier = Modifier.fillMaxWidth(),
)
SpacerH8()
PagerIndicator(
pagerState = pagerState,
hasBackground = false,
)
}
}
/**
* HorizontalPager that smoothly interpolates its height between pages
* based on the current scroll position, preventing layout jumps.
*
* Only the two adjacent pages (lower and upper of the current scroll position)
* are measured per frame O(1) instead of O(N).
*/
@Composable
private fun SmoothHeightPager(
banners: List<PromoBannerNotificationUM>,
pagerState: PagerState,
modifier: Modifier = Modifier,
) {
SubcomposeLayout(modifier = modifier) { constraints ->
if (banners.isEmpty()) {
return@SubcomposeLayout layout(constraints.maxWidth, 0) {}
}
val pageConstraints = constraints.copy(minHeight = 0)
val scrollPosition = (pagerState.currentPage + pagerState.currentPageOffsetFraction)
.coerceIn(0f, banners.lastIndex.toFloat())
val lowerPage = floor(scrollPosition).toInt().coerceIn(banners.indices)
val upperPage = ceil(scrollPosition).toInt().coerceIn(banners.indices)
val fraction = scrollPosition - floor(scrollPosition)
val lowerHeight = subcompose(slotId = "measure_lower") {
Notification(
config = banners[lowerPage].config,
modifier = Modifier.fillMaxWidth(),
containerColor = TangemTheme.colors.background.primary,
)
}.first().measure(pageConstraints).height
val upperHeight = if (upperPage != lowerPage) {
subcompose(slotId = "measure_upper") {
Notification(
config = banners[upperPage].config,
modifier = Modifier.fillMaxWidth(),
containerColor = TangemTheme.colors.background.primary,
)
}.first().measure(pageConstraints).height
} else {
lowerHeight
}
val interpolatedHeight = lerp(lowerHeight, upperHeight, fraction)
val pagerPlaceable = subcompose(slotId = "pager") {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
pageSpacing = TangemTheme.dimens.spacing16,
) { page ->
val banner = banners.getOrNull(page) ?: return@HorizontalPager
Notification(
config = banner.config,
modifier = Modifier.fillMaxWidth(),
containerColor = TangemTheme.colors.background.primary,
)
}
}.first().measure(
constraints.copy(minHeight = interpolatedHeight, maxHeight = interpolatedHeight),
)
layout(pagerPlaceable.width, interpolatedHeight) {
pagerPlaceable.place(0, 0)
}
}
}

View file

@ -4,7 +4,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.promobanners.impl.model.PromoBannerDisplay
import com.tangem.features.promobanners.impl.model.PromoBannerPriority
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.jupiter.api.Test
class PromoBannerDisplayToNotificationConverterTest {