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

@ -253,6 +253,8 @@ dependencies {
implementation(projects.features.markets.impl)
implementation(projects.features.feed.api)
implementation(projects.features.feed.impl)
implementation(projects.features.promoBanners.api)
implementation(projects.features.promoBanners.impl)
implementation(projects.features.onramp.api)
implementation(projects.features.onramp.impl)
implementation(projects.features.onboardingV2.api)

View file

@ -1,6 +1,8 @@
package com.tangem.tap.di
import android.content.Context
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.LinkHandler
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.navigation.share.ShareManager
@ -44,5 +46,9 @@ internal interface UtilsModule {
@Singleton
fun provideSettingsManager(@ApplicationContext context: Context): SettingsManager =
IntentSettingsManager(context)
@Provides
@Singleton
fun provideLinkHandler(appRouter: AppRouter): LinkHandler = LinkHandler(appRouter)
}
}

View file

@ -1,7 +1,12 @@
package com.tangem.tap.di.domain
import com.tangem.domain.promo.*
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.ShouldShowPromoTokenUseCase
import com.tangem.domain.promo.ShouldShowPromoWalletUseCase
import com.tangem.domain.promo.ShouldShowStoriesUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -17,8 +22,13 @@ internal object PromoDomainModule {
fun provideShouldShowSwapPromoWalletUseCase(
promoRepository: PromoRepository,
settingsRepository: SettingsRepository,
newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
): ShouldShowPromoWalletUseCase {
return ShouldShowPromoWalletUseCase(promoRepository, settingsRepository)
return ShouldShowPromoWalletUseCase(
promoRepository,
settingsRepository,
newPromoBannersFeatureToggles.isNewPromoBannersEnabled,
)
}
@Provides

View file

@ -13,7 +13,6 @@ dependencies {
/* Core */
implementation(projects.core.decompose)
implementation(projects.core.configToggles)
implementation(projects.core.navigation)
/* Domain */
implementation(projects.domain.qrScanning.models)

View file

@ -39,15 +39,24 @@ private val NORMAL_DOT_SIZE = DpSize(8.dp, 8.dp)
private val SMALL_DOT_SIZE = DpSize(4.dp, 4.dp)
@Composable
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier) {
val colors = PagerIndicatorColors(
active = TangemTheme.colors.control.key,
inactive = TangemTheme.colors.text.tertiary,
overlay = TangemTheme.colors.overlay.secondary,
)
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier, hasBackground: Boolean = true) {
val colors = if (hasBackground) {
PagerIndicatorColors(
active = TangemTheme.colors.control.key,
inactive = TangemTheme.colors.text.tertiary,
overlay = TangemTheme.colors.overlay.secondary,
)
} else {
PagerIndicatorColors(
active = TangemTheme.colors.icon.primary1,
inactive = TangemTheme.colors.icon.inactive,
overlay = Color.Transparent,
)
}
PagerIndicatorContent(
pagerState = pagerState,
colors = colors,
hasBackground = hasBackground,
modifier = modifier,
)
}
@ -84,6 +93,7 @@ private fun PagerIndicatorContent(
pagerState: PagerState,
colors: PagerIndicatorColors,
modifier: Modifier = Modifier,
hasBackground: Boolean = true,
boxModifier: Modifier = Modifier,
) {
val totalPages = pagerState.pageCount
@ -98,16 +108,19 @@ private fun PagerIndicatorContent(
val visibleIndices = (animState.displayLower until animState.displayUpper).toList()
Box(
modifier = modifier
val backgroundModifier = if (hasBackground) {
modifier
.width(BACKGROUND_SIZE.width)
.height(BACKGROUND_SIZE.height)
.background(
color = colors.overlay,
shape = CircleShape,
)
.background(color = colors.overlay, shape = CircleShape)
.clip(CircleShape)
.then(boxModifier),
.then(boxModifier)
} else {
modifier.padding(vertical = 4.dp)
}
Box(
modifier = backgroundModifier,
contentAlignment = Alignment.Center,
) {
Row(

View file

@ -6,15 +6,19 @@ import com.tangem.domain.settings.repositories.SettingsRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.util.Calendar
class ShouldShowPromoWalletUseCase(
private val promoRepository: PromoRepository,
private val settingsRepository: SettingsRepository,
private val isNewPromoBannersEnabled: Boolean,
) {
operator fun invoke(userWalletId: UserWalletId, promoId: PromoId): Flow<Boolean> {
if (isNewPromoBannersEnabled) return flowOf(false)
return flow {
emit(false)

View file

@ -25,6 +25,7 @@ dependencies {
api(projects.features.tokenRecieve.api)
api(projects.features.wallet.api)
api(projects.features.account.api)
implementation(projects.features.promoBanners.api)
/* Data */
implementation(projects.data.common)
@ -57,7 +58,6 @@ dependencies {
implementation(projects.domain.yieldSupply)
implementation(projects.domain.earn)
/* Compose */
implementation(deps.compose.coil)
implementation(deps.compose.foundation)

View file

@ -7,6 +7,8 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
@ -21,6 +23,8 @@ internal class FeedEntryChildFactory @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val portfolioComponentFactory: MarketsPortfolioComponent.Factory,
private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
) {
@Serializable
@ -100,6 +104,8 @@ internal class FeedEntryChildFactory @Inject constructor(
appComponentContext = appComponentContext,
params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents),
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
promoBannersBlockComponentFactory = promoBannersBlockComponentFactory,
newPromoBannersFeatureToggles = newPromoBannersFeatureToggles,
)
}
is Child.Earn -> {

View file

@ -11,12 +11,15 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent.Params
import com.tangem.features.feed.model.feed.FeedComponentModel
@ -28,10 +31,22 @@ internal class DefaultFeedComponent(
appComponentContext: AppComponentContext,
private val params: FeedParams,
private val addToPortfolioComponentFactory: AddToPortfolioPreselectedDataComponent.Factory,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
private val feedComponentModel = getOrCreateModel<FeedComponentModel, FeedParams>(params = params)
private val promoBannersBlockComponent: PromoBannersBlockComponent? by lazy {
if (!newPromoBannersFeatureToggles.isNewPromoBannersEnabled) return@lazy null
promoBannersBlockComponentFactory.create(
context = child("promoBannersBlockComponent"),
params = PromoBannersBlockComponent.Params(
placeholder = PromoBannersBlockComponent.Placeholder.FEED,
),
)
}
private val bottomSheetSlot = childSlot(
source = feedComponentModel.bottomSheetNavigation,
serializer = null,
@ -62,6 +77,7 @@ internal class DefaultFeedComponent(
FeedList(
modifier = modifier,
state = state,
promoBannersBlockComponent = promoBannersBlockComponent,
)
bottomSheet.child?.instance?.BottomSheet()
}

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
@ -56,7 +57,11 @@ internal fun FeedListHeader(
}
@Composable
internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) {
internal fun FeedList(
state: FeedListUM,
modifier: Modifier = Modifier,
promoBannersBlockComponent: ComposableContentComponent? = null,
) {
val background = LocalMainBottomSheetColor.current.value
AnimatedContent(
modifier = modifier,
@ -83,6 +88,7 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) {
FeedListContent(
modifier = Modifier,
state = state,
promoBannersBlockComponent = promoBannersBlockComponent,
)
}
}
@ -123,7 +129,11 @@ private fun FeedSearchBar(
}
@Composable
private fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) {
private fun FeedListContent(
state: FeedListUM,
modifier: Modifier = Modifier,
promoBannersBlockComponent: ComposableContentComponent? = null,
) {
val background = LocalMainBottomSheetColor.current.value
Column(
modifier = modifier
@ -140,6 +150,8 @@ private fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) {
feedListCallbacks = state.feedListCallbacks,
)
promoBannersBlockComponent?.Content(modifier = Modifier.padding(horizontal = 16.dp))
NewsBlock(
news = state.news,
feedListCallbacks = state.feedListCallbacks,

View file

@ -11,5 +11,4 @@ android {
dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.domain.models)
}

View file

@ -2,18 +2,16 @@ package com.tangem.features.promobanners.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
interface PromoBannersBlockComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val placeholder: Placeholder,
)
enum class Placeholder(val value: String) {
MAIN("main"),
FEED("shtorka"),
enum class Placeholder {
MAIN,
FEED,
}
interface Factory : ComponentFactory<Params, PromoBannersBlockComponent>

View file

@ -15,6 +15,7 @@ dependencies {
implementation(projects.features.promoBanners.api)
/** Domain */
implementation(projects.domain.common)
implementation(projects.domain.models)
/** Core */

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 {

View file

@ -148,6 +148,7 @@ dependencies {
implementation(projects.features.yieldSupply.api)
implementation(projects.features.tangempay.details.api)
implementation(projects.features.feed.api)
implementation(projects.features.promoBanners.api)
/** Common modules */
implementation(projects.common)

View file

@ -16,6 +16,8 @@ import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
@ -54,6 +56,8 @@ internal class WalletComponent @AssistedInject constructor(
private val pushNotificationsBottomSheetComponent: PushNotificationsBottomSheetComponent.Factory,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val yieldSupplyDepositedWarningComponent: YieldSupplyDepositedWarningComponent.Factory,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
private val networkSelectionComponentFactory: NetworkSelectionComponent.Factory,
private val designFeatureToggles: DesignFeatureToggles,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
@ -67,6 +71,16 @@ internal class WalletComponent @AssistedInject constructor(
)
}
private val promoBannersBlockComponent: PromoBannersBlockComponent? by lazy {
if (!newPromoBannersFeatureToggles.isNewPromoBannersEnabled) return@lazy null
promoBannersBlockComponentFactory.create(
context = child("promoBannersBlockComponent"),
params = PromoBannersBlockComponent.Params(
placeholder = PromoBannersBlockComponent.Placeholder.MAIN,
),
)
}
init {
lifecycle.subscribe(model.screenLifecycleProvider)
doOnResume { model.onResume() }
@ -220,6 +234,7 @@ internal class WalletComponent @AssistedInject constructor(
} else {
WalletScreen(
state = model.uiState.collectAsStateWithLifecycle().value,
promoBannersBlockComponent = promoBannersBlockComponent,
bottomSheetContent = {
BottomSheetContent(
bottomSheetState = bottomSheetState,

View file

@ -49,6 +49,7 @@ import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetCo
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheet
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.common.ui.expressStatus.expressTransactionsItems
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.components.atoms.handComposableComponentHeight
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggableHeaderLegacy
@ -91,6 +92,7 @@ import kotlin.math.roundToInt
@Composable
internal fun WalletScreen(
state: WalletScreenState,
promoBannersBlockComponent: ComposableContentComponent? = null,
bottomSheetContent: @Composable (() -> Unit),
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
@ -108,6 +110,7 @@ internal fun WalletScreen(
snackbarHostState = snackbarHostState,
isAutoScroll = isAutoScroll,
onAutoScrollReset = { isAutoScroll.value = false },
promoBannersBlockComponent = promoBannersBlockComponent,
bottomSheetContent = bottomSheetContent,
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
onBottomSheetStateChange = onBottomSheetStateChange,
@ -129,6 +132,7 @@ private fun WalletContent(
snackbarHostState: SnackbarHostState,
isAutoScroll: State<Boolean>,
onAutoScrollReset: () -> Unit,
promoBannersBlockComponent: ComposableContentComponent? = null,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
bottomSheetContent: @Composable (() -> Unit),
@ -210,6 +214,12 @@ private fun WalletContent(
notifications(configs = selectedWallet.warnings, modifier = itemModifier)
promoBannersBlockComponent?.let { component ->
item(key = "PromoBannersBlock") {
component.Content(modifier = itemModifier)
}
}
if (selectedWallet is WalletState.MultiCurrency) {
item(
key = "TangemPayMainScreenBlock",