Updated on 2026-08-14
This commit is contained in:
parent
790fa03399
commit
50f30e9bbf
25 changed files with 583 additions and 42 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ dependencies {
|
|||
/* Core */
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.navigation)
|
||||
|
||||
/* Domain */
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -11,5 +11,4 @@ android {
|
|||
dependencies {
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.domain.models)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ dependencies {
|
|||
implementation(projects.features.promoBanners.api)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Core */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue