diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ab2fe2e0b3..10ff946ef5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt index 8ee75b9d1a..65e36a1536 100644 --- a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt @@ -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) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt index 76a87ca009..ccb73d68a6 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/PromoDomainModule.kt @@ -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 diff --git a/common/routing/build.gradle.kts b/common/routing/build.gradle.kts index df9824a0d4..b34c827c74 100644 --- a/common/routing/build.gradle.kts +++ b/common/routing/build.gradle.kts @@ -13,7 +13,6 @@ dependencies { /* Core */ implementation(projects.core.decompose) implementation(projects.core.configToggles) - implementation(projects.core.navigation) /* Domain */ implementation(projects.domain.qrScanning.models) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt b/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt index e2f223a394..12219152b1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/pager/PagerIndicator.kt @@ -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( diff --git a/domain/promo/src/main/java/com/tangem/domain/promo/ShouldShowPromoWalletUseCase.kt b/domain/promo/src/main/java/com/tangem/domain/promo/ShouldShowPromoWalletUseCase.kt index 7f6f41114d..b7cf191c4d 100644 --- a/domain/promo/src/main/java/com/tangem/domain/promo/ShouldShowPromoWalletUseCase.kt +++ b/domain/promo/src/main/java/com/tangem/domain/promo/ShouldShowPromoWalletUseCase.kt @@ -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 { + if (isNewPromoBannersEnabled) return flowOf(false) + return flow { emit(false) diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index df759d55a1..95ac903999 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -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) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 31b71bb3ea..812d8b8cef 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -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 -> { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index 7fbaa5c4fe..bd940eae65 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -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(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() } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 943dd2fce3..e8267f9b85 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -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, diff --git a/features/promo-banners/api/build.gradle.kts b/features/promo-banners/api/build.gradle.kts index a81f1d0c23..cfa0100f41 100644 --- a/features/promo-banners/api/build.gradle.kts +++ b/features/promo-banners/api/build.gradle.kts @@ -11,5 +11,4 @@ android { dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) - implementation(projects.domain.models) } \ No newline at end of file diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt index f9525a6ac5..bb50b2bcd2 100644 --- a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt @@ -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 diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index cee5719108..0bb6f452b2 100644 --- a/features/promo-banners/impl/build.gradle.kts +++ b/features/promo-banners/impl/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(projects.features.promoBanners.api) /** Domain */ + implementation(projects.domain.common) implementation(projects.domain.models) /** Core */ diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/DefaultPromoBannersBlockComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/DefaultPromoBannersBlockComponent.kt new file mode 100644 index 0000000000..23ae1dce22 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/DefaultPromoBannersBlockComponent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt index fd6944d386..229e06fcdb 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt @@ -5,20 +5,37 @@ import com.tangem.core.analytics.models.AnalyticsEvent internal sealed class PromoBannerAnalyticsEvent( event: String, params: Map = 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), ) } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt new file mode 100644 index 0000000000..a6d23d0e7e --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt new file mode 100644 index 0000000000..7a9a54c6db --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt @@ -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() + private val converter = PromoBannerDisplayToNotificationConverter() + + private val placeholder: String = params.placeholder.name.lowercase() + private val shownBannerIds: MutableSet = ConcurrentHashMap.newKeySet() + private var wasCarouselScrolled = false + + val uiState: StateFlow + 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, + ) + } + } + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt index 8a311ac7d7..e58b9d432c 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt @@ -5,4 +5,6 @@ import kotlinx.collections.immutable.persistentListOf internal data class PromoBannersBlockUM( val banners: ImmutableList = persistentListOf(), + val onBannerShown: (displayId: String) -> Unit = {}, + val onCarouselScrolled: (displayId: String) -> Unit = {}, ) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt index 166ef40064..ba193f57e4 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt @@ -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> + +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, ) : PromoBannersRepository { private val converter = PromoBannerDisplayDTOConverter() - override suspend fun getBanners(walletId: String, placeholder: String, locale: String): List = - withContext(dispatchers.io) { + override suspend fun getBanners( + walletId: String, + placeholder: Placeholder, + locale: String, + ): List { + 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" + } } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt index 564b71625f..e4e7430d98 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt @@ -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 + suspend fun getBanners(walletId: String, placeholder: Placeholder, locale: String): List suspend fun dismissBanner(walletId: String, displayId: String) } \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt new file mode 100644 index 0000000000..37b834d756 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt @@ -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, + 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, + 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) + } + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt index 3ae0b738dc..fed421aa20 100644 --- a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt @@ -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 { diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 4c28061ca1..cb23c78346 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -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) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index 8134a10127..3c717f349c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -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, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 4f12a59f17..7e0a0747aa 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -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, 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",