Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-17 13:10:09 +02:00
parent 8beb80f5a7
commit 43d6e92a89
26 changed files with 639 additions and 8 deletions

View file

@ -0,0 +1,15 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.promobanners.api"
}
dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.domain.models)
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.promobanners.api
interface NewPromoBannersFeatureToggles {
val isNewPromoBannersEnabled: Boolean
}

View file

@ -0,0 +1,20 @@
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"),
}
interface Factory : ComponentFactory<Params, PromoBannersBlockComponent>
}

View file

@ -0,0 +1,50 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.promobanners.impl"
}
dependencies {
/** Project - API */
implementation(projects.features.promoBanners.api)
/** Domain */
implementation(projects.domain.models)
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
implementation(projects.core.datasource)
implementation(projects.core.configToggles)
/** Common */
implementation(projects.common.routing)
/** Compose */
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.lifecycle.compose)
/** Other */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.timber)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.truth)
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.promobanners.impl.analytics
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) {
data class Shown(val bannerId: String) : PromoBannerAnalyticsEvent(
event = "Banner Shown",
params = mapOf("banner_id" to bannerId),
)
data class Clicked(val bannerId: String) : PromoBannerAnalyticsEvent(
event = "Banner Clicked",
params = mapOf("banner_id" to bannerId),
)
data class Dismissed(val bannerId: String) : PromoBannerAnalyticsEvent(
event = "Banner Dismissed",
params = mapOf("banner_id" to bannerId),
)
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.promobanners.impl.converters
import com.tangem.datasource.api.tangemTech.models.promobanners.PromoBannerDisplayDTO
import com.tangem.features.promobanners.impl.model.PromoBannerDisplay
import com.tangem.features.promobanners.impl.model.PromoBannerPriority
import com.tangem.utils.converter.Converter
import java.util.Locale
internal class PromoBannerDisplayDTOConverter : Converter<PromoBannerDisplayDTO, PromoBannerDisplay> {
override fun convert(value: PromoBannerDisplayDTO): PromoBannerDisplay {
return PromoBannerDisplay(
id = value.id,
placeholder = value.placeholder,
priority = convertPriority(value.priority),
title = value.title,
subtitle = value.subtitle,
iconUrl = value.iconUrl,
deeplink = value.deeplink,
isButtonEnabled = value.buttonEnabled,
buttonText = value.buttonText,
isDismissable = value.dismissable,
)
}
private fun convertPriority(value: String): PromoBannerPriority {
return when (value.uppercase(Locale.ROOT)) {
"IMPORTANT" -> PromoBannerPriority.IMPORTANT
"HIGH" -> PromoBannerPriority.HIGH
"MEDIUM" -> PromoBannerPriority.MEDIUM
"LOW" -> PromoBannerPriority.LOW
else -> PromoBannerPriority.LOW
}
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.promobanners.impl.converters
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.promobanners.impl.model.PromoBannerDisplay
import com.tangem.features.promobanners.impl.model.PromoBannerNotificationUM
internal class PromoBannerDisplayToNotificationConverter {
fun convert(
banner: PromoBannerDisplay,
onDeeplinkClick: (String?) -> Unit,
onDismiss: (String) -> Unit,
): PromoBannerNotificationUM {
return PromoBannerNotificationUM(
displayId = banner.id,
config = NotificationConfig(
title = TextReference.Str(banner.title),
subtitle = TextReference.Str(banner.subtitle),
iconResId = com.tangem.core.ui.R.drawable.ic_alert_circle_24,
iconUrl = banner.iconUrl,
onCloseClick = if (banner.isDismissable) {
{ onDismiss(banner.id) }
} else {
null
},
buttonsState = banner.buttonText?.takeIf { banner.isButtonEnabled }?.let { text ->
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = TextReference.Str(text),
onClick = { onDeeplinkClick(banner.deeplink) },
)
},
),
)
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.promobanners.impl.model
internal data class PromoBannerDisplay(
val id: String,
val placeholder: String,
val priority: PromoBannerPriority,
val title: String,
val subtitle: String,
val iconUrl: String?,
val deeplink: String?,
val isButtonEnabled: Boolean,
val buttonText: String?,
val isDismissable: Boolean,
)

View file

@ -0,0 +1,8 @@
package com.tangem.features.promobanners.impl.model
import com.tangem.core.ui.components.notifications.NotificationConfig
internal data class PromoBannerNotificationUM(
val displayId: String,
val config: NotificationConfig,
)

View file

@ -0,0 +1,8 @@
package com.tangem.features.promobanners.impl.model
internal enum class PromoBannerPriority(val order: Int) {
IMPORTANT(order = 0),
HIGH(order = 1),
MEDIUM(order = 2),
LOW(order = 3),
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.promobanners.impl.model
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal data class PromoBannersBlockUM(
val banners: ImmutableList<PromoBannerNotificationUM> = persistentListOf(),
)

View file

@ -0,0 +1,42 @@
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.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(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) : PromoBannersRepository {
private val converter = PromoBannerDisplayDTOConverter()
override suspend fun getBanners(walletId: String, placeholder: String, locale: String): List<PromoBannerDisplay> =
withContext(dispatchers.io) {
tangemTechApi.getPromoBannerDisplays(
walletId = walletId,
placeholder = placeholder,
locale = locale,
).getOrThrow()
.items
.map(converter::convert)
.sortedBy { it.priority.order }
}
override suspend fun dismissBanner(walletId: String, displayId: String) {
withContext(dispatchers.io) {
val request = DismissPromoBannerRequest(
walletId = walletId,
isDismissed = true,
)
tangemTechApi.dismissPromoBannerDisplay(displayId, request).getOrThrow()
}
}
}

View file

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

View file

@ -0,0 +1,13 @@
package com.tangem.features.promobanners.impl.toggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import javax.inject.Inject
internal class DefaultNewPromoBannersFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : NewPromoBannersFeatureToggles {
override val isNewPromoBannersEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("NEW_PROMO_BANNERS_ENABLED")
}

View file

@ -0,0 +1,77 @@
package com.tangem.features.promobanners.impl.converters
import com.tangem.datasource.api.tangemTech.models.promobanners.PromoBannerDisplayDTO
import com.tangem.features.promobanners.impl.model.PromoBannerPriority
import org.junit.jupiter.api.Test
import com.google.common.truth.Truth.assertThat
class PromoBannerDisplayDTOConverterTest {
private val converter = PromoBannerDisplayDTOConverter()
@Test
fun `should convert DTO to domain model`() {
val dto = PromoBannerDisplayDTO(
id = "123",
placeholder = "MAIN",
priority = "HIGH",
title = "Test Banner",
subtitle = "Test subtitle",
iconUrl = "https://example.com/icon.png",
deeplink = "tangem://wallet",
buttonEnabled = true,
buttonText = "Click me",
dismissable = true,
)
val result = converter.convert(dto)
assertThat(result.id).isEqualTo("123")
assertThat(result.placeholder).isEqualTo("MAIN")
assertThat(result.priority).isEqualTo(PromoBannerPriority.HIGH)
assertThat(result.title).isEqualTo("Test Banner")
assertThat(result.subtitle).isEqualTo("Test subtitle")
assertThat(result.iconUrl).isEqualTo("https://example.com/icon.png")
assertThat(result.deeplink).isEqualTo("tangem://wallet")
assertThat(result.isButtonEnabled).isTrue()
assertThat(result.buttonText).isEqualTo("Click me")
assertThat(result.isDismissable).isTrue()
}
@Test
fun `should pass placeholder as is`() {
val dto = createDTO(placeholder = "UNKNOWN")
val result = converter.convert(dto)
assertThat(result.placeholder).isEqualTo("UNKNOWN")
}
@Test
fun `should map unknown priority to LOW`() {
val dto = createDTO(priority = "UNKNOWN")
val result = converter.convert(dto)
assertThat(result.priority).isEqualTo(PromoBannerPriority.LOW)
}
@Test
fun `should handle SHTORKA placeholder`() {
val dto = createDTO(placeholder = "SHTORKA")
val result = converter.convert(dto)
assertThat(result.placeholder).isEqualTo("SHTORKA")
}
private fun createDTO(
placeholder: String = "MAIN",
priority: String = "MEDIUM",
) = PromoBannerDisplayDTO(
id = "1",
placeholder = placeholder,
priority = priority,
title = "Title",
subtitle = "Subtitle",
iconUrl = null,
deeplink = null,
buttonEnabled = false,
buttonText = null,
dismissable = false,
)
}

View file

@ -0,0 +1,118 @@
package com.tangem.features.promobanners.impl.converters
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
class PromoBannerDisplayToNotificationConverterTest {
private val converter = PromoBannerDisplayToNotificationConverter()
@Test
fun `should convert banner with all fields`() {
val banner = createBanner(
id = "b1",
deeplink = "tangem://wallet",
isButtonEnabled = true,
buttonText = "Open",
isDismissable = true,
)
var clickedDeeplink: String? = "not_called"
var dismissedId: String? = null
val result = converter.convert(
banner = banner,
onDeeplinkClick = { clickedDeeplink = it },
onDismiss = { dismissedId = it },
)
assertThat(result.displayId).isEqualTo("b1")
assertThat(result.config.title).isEqualTo(TextReference.Str("Title"))
assertThat(result.config.subtitle).isEqualTo(TextReference.Str("Subtitle"))
assertThat(result.config.iconUrl).isEqualTo("https://icon.png")
assertThat(result.config.onClick).isNull()
assertThat(result.config.onCloseClick).isNotNull()
assertThat(result.config.buttonsState).isNotNull()
// Button click navigates via deeplink
val buttonState = result.config.buttonsState as com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState.SecondaryButtonConfig
buttonState.onClick.invoke()
assertThat(clickedDeeplink).isEqualTo("tangem://wallet")
result.config.onCloseClick!!.invoke()
assertThat(dismissedId).isEqualTo("b1")
}
@Test
fun `should always have null onClick - banner itself is not clickable`() {
val banner = createBanner(deeplink = "tangem://wallet")
val result = converter.convert(
banner = banner,
onDeeplinkClick = {},
onDismiss = {},
)
assertThat(result.config.onClick).isNull()
}
@Test
fun `should not set onCloseClick when not dismissable`() {
val banner = createBanner(isDismissable = false)
val result = converter.convert(
banner = banner,
onDeeplinkClick = {},
onDismiss = {},
)
assertThat(result.config.onCloseClick).isNull()
}
@Test
fun `should not set button when button is disabled`() {
val banner = createBanner(isButtonEnabled = false, buttonText = "Open")
val result = converter.convert(
banner = banner,
onDeeplinkClick = {},
onDismiss = {},
)
assertThat(result.config.buttonsState).isNull()
}
@Test
fun `should not set button when buttonText is null`() {
val banner = createBanner(isButtonEnabled = true, buttonText = null)
val result = converter.convert(
banner = banner,
onDeeplinkClick = {},
onDismiss = {},
)
assertThat(result.config.buttonsState).isNull()
}
private fun createBanner(
id: String = "id",
deeplink: String? = "https://example.com",
isButtonEnabled: Boolean = false,
buttonText: String? = null,
isDismissable: Boolean = true,
) = PromoBannerDisplay(
id = id,
placeholder = "main",
priority = PromoBannerPriority.MEDIUM,
title = "Title",
subtitle = "Subtitle",
iconUrl = "https://icon.png",
deeplink = deeplink,
isButtonEnabled = isButtonEnabled,
buttonText = buttonText,
isDismissable = isDismissable,
)
}