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.markets.impl)
|
||||||
implementation(projects.features.feed.api)
|
implementation(projects.features.feed.api)
|
||||||
implementation(projects.features.feed.impl)
|
implementation(projects.features.feed.impl)
|
||||||
|
implementation(projects.features.promoBanners.api)
|
||||||
|
implementation(projects.features.promoBanners.impl)
|
||||||
implementation(projects.features.onramp.api)
|
implementation(projects.features.onramp.api)
|
||||||
implementation(projects.features.onramp.impl)
|
implementation(projects.features.onramp.impl)
|
||||||
implementation(projects.features.onboardingV2.api)
|
implementation(projects.features.onboardingV2.api)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.tangem.tap.di
|
package com.tangem.tap.di
|
||||||
|
|
||||||
import android.content.Context
|
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.finisher.AppFinisher
|
||||||
import com.tangem.core.navigation.settings.SettingsManager
|
import com.tangem.core.navigation.settings.SettingsManager
|
||||||
import com.tangem.core.navigation.share.ShareManager
|
import com.tangem.core.navigation.share.ShareManager
|
||||||
|
|
@ -44,5 +46,9 @@ internal interface UtilsModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSettingsManager(@ApplicationContext context: Context): SettingsManager =
|
fun provideSettingsManager(@ApplicationContext context: Context): SettingsManager =
|
||||||
IntentSettingsManager(context)
|
IntentSettingsManager(context)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideLinkHandler(appRouter: AppRouter): LinkHandler = LinkHandler(appRouter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
package com.tangem.tap.di.domain
|
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.domain.settings.repositories.SettingsRepository
|
||||||
|
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -17,8 +22,13 @@ internal object PromoDomainModule {
|
||||||
fun provideShouldShowSwapPromoWalletUseCase(
|
fun provideShouldShowSwapPromoWalletUseCase(
|
||||||
promoRepository: PromoRepository,
|
promoRepository: PromoRepository,
|
||||||
settingsRepository: SettingsRepository,
|
settingsRepository: SettingsRepository,
|
||||||
|
newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
|
||||||
): ShouldShowPromoWalletUseCase {
|
): ShouldShowPromoWalletUseCase {
|
||||||
return ShouldShowPromoWalletUseCase(promoRepository, settingsRepository)
|
return ShouldShowPromoWalletUseCase(
|
||||||
|
promoRepository,
|
||||||
|
settingsRepository,
|
||||||
|
newPromoBannersFeatureToggles.isNewPromoBannersEnabled,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ dependencies {
|
||||||
/* Core */
|
/* Core */
|
||||||
implementation(projects.core.decompose)
|
implementation(projects.core.decompose)
|
||||||
implementation(projects.core.configToggles)
|
implementation(projects.core.configToggles)
|
||||||
implementation(projects.core.navigation)
|
|
||||||
|
|
||||||
/* Domain */
|
/* Domain */
|
||||||
implementation(projects.domain.qrScanning.models)
|
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)
|
private val SMALL_DOT_SIZE = DpSize(4.dp, 4.dp)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier) {
|
fun PagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier, hasBackground: Boolean = true) {
|
||||||
val colors = PagerIndicatorColors(
|
val colors = if (hasBackground) {
|
||||||
active = TangemTheme.colors.control.key,
|
PagerIndicatorColors(
|
||||||
inactive = TangemTheme.colors.text.tertiary,
|
active = TangemTheme.colors.control.key,
|
||||||
overlay = TangemTheme.colors.overlay.secondary,
|
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(
|
PagerIndicatorContent(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
colors = colors,
|
colors = colors,
|
||||||
|
hasBackground = hasBackground,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +93,7 @@ private fun PagerIndicatorContent(
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
colors: PagerIndicatorColors,
|
colors: PagerIndicatorColors,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
hasBackground: Boolean = true,
|
||||||
boxModifier: Modifier = Modifier,
|
boxModifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val totalPages = pagerState.pageCount
|
val totalPages = pagerState.pageCount
|
||||||
|
|
@ -98,16 +108,19 @@ private fun PagerIndicatorContent(
|
||||||
|
|
||||||
val visibleIndices = (animState.displayLower until animState.displayUpper).toList()
|
val visibleIndices = (animState.displayLower until animState.displayUpper).toList()
|
||||||
|
|
||||||
Box(
|
val backgroundModifier = if (hasBackground) {
|
||||||
modifier = modifier
|
modifier
|
||||||
.width(BACKGROUND_SIZE.width)
|
.width(BACKGROUND_SIZE.width)
|
||||||
.height(BACKGROUND_SIZE.height)
|
.height(BACKGROUND_SIZE.height)
|
||||||
.background(
|
.background(color = colors.overlay, shape = CircleShape)
|
||||||
color = colors.overlay,
|
|
||||||
shape = CircleShape,
|
|
||||||
)
|
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.then(boxModifier),
|
.then(boxModifier)
|
||||||
|
} else {
|
||||||
|
modifier.padding(vertical = 4.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = backgroundModifier,
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,19 @@ import com.tangem.domain.settings.repositories.SettingsRepository
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emitAll
|
import kotlinx.coroutines.flow.emitAll
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
class ShouldShowPromoWalletUseCase(
|
class ShouldShowPromoWalletUseCase(
|
||||||
private val promoRepository: PromoRepository,
|
private val promoRepository: PromoRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val isNewPromoBannersEnabled: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
operator fun invoke(userWalletId: UserWalletId, promoId: PromoId): Flow<Boolean> {
|
operator fun invoke(userWalletId: UserWalletId, promoId: PromoId): Flow<Boolean> {
|
||||||
|
if (isNewPromoBannersEnabled) return flowOf(false)
|
||||||
|
|
||||||
return flow {
|
return flow {
|
||||||
emit(false)
|
emit(false)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ dependencies {
|
||||||
api(projects.features.tokenRecieve.api)
|
api(projects.features.tokenRecieve.api)
|
||||||
api(projects.features.wallet.api)
|
api(projects.features.wallet.api)
|
||||||
api(projects.features.account.api)
|
api(projects.features.account.api)
|
||||||
|
implementation(projects.features.promoBanners.api)
|
||||||
|
|
||||||
/* Data */
|
/* Data */
|
||||||
implementation(projects.data.common)
|
implementation(projects.data.common)
|
||||||
|
|
@ -57,7 +58,6 @@ dependencies {
|
||||||
implementation(projects.domain.yieldSupply)
|
implementation(projects.domain.yieldSupply)
|
||||||
implementation(projects.domain.earn)
|
implementation(projects.domain.earn)
|
||||||
|
|
||||||
|
|
||||||
/* Compose */
|
/* Compose */
|
||||||
implementation(deps.compose.coil)
|
implementation(deps.compose.coil)
|
||||||
implementation(deps.compose.foundation)
|
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.decompose.navigation.Route
|
||||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||||
import com.tangem.features.feed.components.earn.DefaultEarnComponent
|
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.feed.DefaultFeedComponent
|
||||||
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
|
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
|
||||||
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
|
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 analyticsEventHandler: AnalyticsEventHandler,
|
||||||
private val portfolioComponentFactory: MarketsPortfolioComponent.Factory,
|
private val portfolioComponentFactory: MarketsPortfolioComponent.Factory,
|
||||||
private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory,
|
private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory,
|
||||||
|
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
|
||||||
|
private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -100,6 +104,8 @@ internal class FeedEntryChildFactory @Inject constructor(
|
||||||
appComponentContext = appComponentContext,
|
appComponentContext = appComponentContext,
|
||||||
params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents),
|
params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents),
|
||||||
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
|
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
|
||||||
|
promoBannersBlockComponentFactory = promoBannersBlockComponentFactory,
|
||||||
|
newPromoBannersFeatureToggles = newPromoBannersFeatureToggles,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is Child.Earn -> {
|
is Child.Earn -> {
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,15 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||||
import com.arkivanov.decompose.router.slot.childSlot
|
import com.arkivanov.decompose.router.slot.childSlot
|
||||||
import com.tangem.core.analytics.models.AnalyticsParam
|
import com.tangem.core.analytics.models.AnalyticsParam
|
||||||
import com.tangem.core.decompose.context.AppComponentContext
|
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.context.childByContext
|
||||||
import com.tangem.core.decompose.model.getOrCreateModel
|
import com.tangem.core.decompose.model.getOrCreateModel
|
||||||
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||||
import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent
|
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
|
||||||
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent.Params
|
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent.Params
|
||||||
import com.tangem.features.feed.model.feed.FeedComponentModel
|
import com.tangem.features.feed.model.feed.FeedComponentModel
|
||||||
|
|
@ -28,10 +31,22 @@ internal class DefaultFeedComponent(
|
||||||
appComponentContext: AppComponentContext,
|
appComponentContext: AppComponentContext,
|
||||||
private val params: FeedParams,
|
private val params: FeedParams,
|
||||||
private val addToPortfolioComponentFactory: AddToPortfolioPreselectedDataComponent.Factory,
|
private val addToPortfolioComponentFactory: AddToPortfolioPreselectedDataComponent.Factory,
|
||||||
|
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
|
||||||
|
private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
|
||||||
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
|
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
|
||||||
|
|
||||||
private val feedComponentModel = getOrCreateModel<FeedComponentModel, FeedParams>(params = params)
|
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(
|
private val bottomSheetSlot = childSlot(
|
||||||
source = feedComponentModel.bottomSheetNavigation,
|
source = feedComponentModel.bottomSheetNavigation,
|
||||||
serializer = null,
|
serializer = null,
|
||||||
|
|
@ -62,6 +77,7 @@ internal class DefaultFeedComponent(
|
||||||
FeedList(
|
FeedList(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = state,
|
state = state,
|
||||||
|
promoBannersBlockComponent = promoBannersBlockComponent,
|
||||||
)
|
)
|
||||||
bottomSheet.child?.instance?.BottomSheet()
|
bottomSheet.child?.instance?.BottomSheet()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||||
import com.tangem.core.ui.R
|
import com.tangem.core.ui.R
|
||||||
import com.tangem.core.ui.components.SpacerH
|
import com.tangem.core.ui.components.SpacerH
|
||||||
import com.tangem.core.ui.components.SpacerW
|
import com.tangem.core.ui.components.SpacerW
|
||||||
|
|
@ -56,7 +57,11 @@ internal fun FeedListHeader(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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
|
val background = LocalMainBottomSheetColor.current.value
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -83,6 +88,7 @@ internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) {
|
||||||
FeedListContent(
|
FeedListContent(
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
state = state,
|
state = state,
|
||||||
|
promoBannersBlockComponent = promoBannersBlockComponent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +129,11 @@ private fun FeedSearchBar(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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
|
val background = LocalMainBottomSheetColor.current.value
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -140,6 +150,8 @@ private fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) {
|
||||||
feedListCallbacks = state.feedListCallbacks,
|
feedListCallbacks = state.feedListCallbacks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
promoBannersBlockComponent?.Content(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
NewsBlock(
|
NewsBlock(
|
||||||
news = state.news,
|
news = state.news,
|
||||||
feedListCallbacks = state.feedListCallbacks,
|
feedListCallbacks = state.feedListCallbacks,
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,4 @@ android {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.core.decompose)
|
implementation(projects.core.decompose)
|
||||||
implementation(projects.core.ui)
|
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.decompose.factory.ComponentFactory
|
||||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||||
import com.tangem.domain.models.wallet.UserWalletId
|
|
||||||
|
|
||||||
interface PromoBannersBlockComponent : ComposableContentComponent {
|
interface PromoBannersBlockComponent : ComposableContentComponent {
|
||||||
|
|
||||||
data class Params(
|
data class Params(
|
||||||
val userWalletId: UserWalletId,
|
|
||||||
val placeholder: Placeholder,
|
val placeholder: Placeholder,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class Placeholder(val value: String) {
|
enum class Placeholder {
|
||||||
MAIN("main"),
|
MAIN,
|
||||||
FEED("shtorka"),
|
FEED,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Factory : ComponentFactory<Params, PromoBannersBlockComponent>
|
interface Factory : ComponentFactory<Params, PromoBannersBlockComponent>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ dependencies {
|
||||||
implementation(projects.features.promoBanners.api)
|
implementation(projects.features.promoBanners.api)
|
||||||
|
|
||||||
/** Domain */
|
/** Domain */
|
||||||
|
implementation(projects.domain.common)
|
||||||
implementation(projects.domain.models)
|
implementation(projects.domain.models)
|
||||||
|
|
||||||
/** Core */
|
/** 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(
|
internal sealed class PromoBannerAnalyticsEvent(
|
||||||
event: String,
|
event: String,
|
||||||
params: Map<String, String> = emptyMap(),
|
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",
|
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(
|
data class CarouselScrolled(
|
||||||
event = "Banner Clicked",
|
private val displayId: String,
|
||||||
params = mapOf("banner_id" to bannerId),
|
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",
|
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(
|
internal data class PromoBannersBlockUM(
|
||||||
val banners: ImmutableList<PromoBannerNotificationUM> = persistentListOf(),
|
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.common.response.getOrThrow
|
||||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||||
import com.tangem.datasource.api.tangemTech.models.promobanners.DismissPromoBannerRequest
|
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.converters.PromoBannerDisplayDTOConverter
|
||||||
import com.tangem.features.promobanners.impl.model.PromoBannerDisplay
|
import com.tangem.features.promobanners.impl.model.PromoBannerDisplay
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
internal typealias BannersCache = Map<BannersCacheKey, List<PromoBannerDisplay>>
|
||||||
internal class DefaultPromoBannersRepository @Inject constructor(
|
|
||||||
|
internal data class BannersCacheKey(
|
||||||
|
val walletId: String,
|
||||||
|
val placeholder: Placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal class DefaultPromoBannersRepository(
|
||||||
private val tangemTechApi: TangemTechApi,
|
private val tangemTechApi: TangemTechApi,
|
||||||
private val dispatchers: CoroutineDispatcherProvider,
|
private val dispatchers: CoroutineDispatcherProvider,
|
||||||
|
private val cache: RuntimeSharedStore<BannersCache>,
|
||||||
) : PromoBannersRepository {
|
) : PromoBannersRepository {
|
||||||
|
|
||||||
private val converter = PromoBannerDisplayDTOConverter()
|
private val converter = PromoBannerDisplayDTOConverter()
|
||||||
|
|
||||||
override suspend fun getBanners(walletId: String, placeholder: String, locale: String): List<PromoBannerDisplay> =
|
override suspend fun getBanners(
|
||||||
withContext(dispatchers.io) {
|
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(
|
tangemTechApi.getPromoBannerDisplays(
|
||||||
walletId = walletId,
|
walletId = walletId,
|
||||||
placeholder = placeholder,
|
placeholder = placeholder.toApiValue(),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
.items
|
.items
|
||||||
|
|
@ -30,7 +45,24 @@ internal class DefaultPromoBannersRepository @Inject constructor(
|
||||||
.sortedBy { it.priority.order }
|
.sortedBy { it.priority.order }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache.update(default = emptyMap()) { current ->
|
||||||
|
current + (key to banners)
|
||||||
|
}
|
||||||
|
|
||||||
|
return banners
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun dismissBanner(walletId: String, displayId: String) {
|
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) {
|
withContext(dispatchers.io) {
|
||||||
val request = DismissPromoBannerRequest(
|
val request = DismissPromoBannerRequest(
|
||||||
walletId = walletId,
|
walletId = walletId,
|
||||||
|
|
@ -39,4 +71,9 @@ internal class DefaultPromoBannersRepository @Inject constructor(
|
||||||
tangemTechApi.dismissPromoBannerDisplay(displayId, request).getOrThrow()
|
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
|
package com.tangem.features.promobanners.impl.repository
|
||||||
|
|
||||||
|
import com.tangem.features.promobanners.api.PromoBannersBlockComponent.Placeholder
|
||||||
import com.tangem.features.promobanners.impl.model.PromoBannerDisplay
|
import com.tangem.features.promobanners.impl.model.PromoBannerDisplay
|
||||||
|
|
||||||
internal interface PromoBannersRepository {
|
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)
|
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.PromoBannerDisplay
|
||||||
import com.tangem.features.promobanners.impl.model.PromoBannerPriority
|
import com.tangem.features.promobanners.impl.model.PromoBannerPriority
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class PromoBannerDisplayToNotificationConverterTest {
|
class PromoBannerDisplayToNotificationConverterTest {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@ dependencies {
|
||||||
implementation(projects.features.yieldSupply.api)
|
implementation(projects.features.yieldSupply.api)
|
||||||
implementation(projects.features.tangempay.details.api)
|
implementation(projects.features.tangempay.details.api)
|
||||||
implementation(projects.features.feed.api)
|
implementation(projects.features.feed.api)
|
||||||
|
implementation(projects.features.promoBanners.api)
|
||||||
|
|
||||||
/** Common modules */
|
/** Common modules */
|
||||||
implementation(projects.common)
|
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.context.childByContext
|
||||||
import com.tangem.core.decompose.model.getOrCreateModel
|
import com.tangem.core.decompose.model.getOrCreateModel
|
||||||
import com.tangem.core.ui.DesignFeatureToggles
|
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.components.bottomsheets.state.BottomSheetState
|
||||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||||
|
|
@ -54,6 +56,8 @@ internal class WalletComponent @AssistedInject constructor(
|
||||||
private val pushNotificationsBottomSheetComponent: PushNotificationsBottomSheetComponent.Factory,
|
private val pushNotificationsBottomSheetComponent: PushNotificationsBottomSheetComponent.Factory,
|
||||||
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
|
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
|
||||||
private val yieldSupplyDepositedWarningComponent: YieldSupplyDepositedWarningComponent.Factory,
|
private val yieldSupplyDepositedWarningComponent: YieldSupplyDepositedWarningComponent.Factory,
|
||||||
|
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
|
||||||
|
private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
|
||||||
private val networkSelectionComponentFactory: NetworkSelectionComponent.Factory,
|
private val networkSelectionComponentFactory: NetworkSelectionComponent.Factory,
|
||||||
private val designFeatureToggles: DesignFeatureToggles,
|
private val designFeatureToggles: DesignFeatureToggles,
|
||||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
) : 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 {
|
init {
|
||||||
lifecycle.subscribe(model.screenLifecycleProvider)
|
lifecycle.subscribe(model.screenLifecycleProvider)
|
||||||
doOnResume { model.onResume() }
|
doOnResume { model.onResume() }
|
||||||
|
|
@ -220,6 +234,7 @@ internal class WalletComponent @AssistedInject constructor(
|
||||||
} else {
|
} else {
|
||||||
WalletScreen(
|
WalletScreen(
|
||||||
state = model.uiState.collectAsStateWithLifecycle().value,
|
state = model.uiState.collectAsStateWithLifecycle().value,
|
||||||
|
promoBannersBlockComponent = promoBannersBlockComponent,
|
||||||
bottomSheetContent = {
|
bottomSheetContent = {
|
||||||
BottomSheetContent(
|
BottomSheetContent(
|
||||||
bottomSheetState = bottomSheetState,
|
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.ExpressStatusBottomSheet
|
||||||
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
|
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
|
||||||
import com.tangem.common.ui.expressStatus.expressTransactionsItems
|
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.atoms.handComposableComponentHeight
|
||||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||||
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggableHeaderLegacy
|
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggableHeaderLegacy
|
||||||
|
|
@ -91,6 +92,7 @@ import kotlin.math.roundToInt
|
||||||
@Composable
|
@Composable
|
||||||
internal fun WalletScreen(
|
internal fun WalletScreen(
|
||||||
state: WalletScreenState,
|
state: WalletScreenState,
|
||||||
|
promoBannersBlockComponent: ComposableContentComponent? = null,
|
||||||
bottomSheetContent: @Composable (() -> Unit),
|
bottomSheetContent: @Composable (() -> Unit),
|
||||||
bottomSheetHeaderHeightProvider: () -> Dp,
|
bottomSheetHeaderHeightProvider: () -> Dp,
|
||||||
onBottomSheetStateChange: (BottomSheetState) -> Unit,
|
onBottomSheetStateChange: (BottomSheetState) -> Unit,
|
||||||
|
|
@ -108,6 +110,7 @@ internal fun WalletScreen(
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
isAutoScroll = isAutoScroll,
|
isAutoScroll = isAutoScroll,
|
||||||
onAutoScrollReset = { isAutoScroll.value = false },
|
onAutoScrollReset = { isAutoScroll.value = false },
|
||||||
|
promoBannersBlockComponent = promoBannersBlockComponent,
|
||||||
bottomSheetContent = bottomSheetContent,
|
bottomSheetContent = bottomSheetContent,
|
||||||
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
|
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
|
||||||
onBottomSheetStateChange = onBottomSheetStateChange,
|
onBottomSheetStateChange = onBottomSheetStateChange,
|
||||||
|
|
@ -129,6 +132,7 @@ private fun WalletContent(
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
isAutoScroll: State<Boolean>,
|
isAutoScroll: State<Boolean>,
|
||||||
onAutoScrollReset: () -> Unit,
|
onAutoScrollReset: () -> Unit,
|
||||||
|
promoBannersBlockComponent: ComposableContentComponent? = null,
|
||||||
bottomSheetHeaderHeightProvider: () -> Dp,
|
bottomSheetHeaderHeightProvider: () -> Dp,
|
||||||
onBottomSheetStateChange: (BottomSheetState) -> Unit,
|
onBottomSheetStateChange: (BottomSheetState) -> Unit,
|
||||||
bottomSheetContent: @Composable (() -> Unit),
|
bottomSheetContent: @Composable (() -> Unit),
|
||||||
|
|
@ -210,6 +214,12 @@ private fun WalletContent(
|
||||||
|
|
||||||
notifications(configs = selectedWallet.warnings, modifier = itemModifier)
|
notifications(configs = selectedWallet.warnings, modifier = itemModifier)
|
||||||
|
|
||||||
|
promoBannersBlockComponent?.let { component ->
|
||||||
|
item(key = "PromoBannersBlock") {
|
||||||
|
component.Content(modifier = itemModifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedWallet is WalletState.MultiCurrency) {
|
if (selectedWallet is WalletState.MultiCurrency) {
|
||||||
item(
|
item(
|
||||||
key = "TangemPayMainScreenBlock",
|
key = "TangemPayMainScreenBlock",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue