Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-24 12:17:28 +02:00
parent c7e4ff0598
commit b74acabcc4
15 changed files with 136 additions and 68 deletions

View file

@ -191,12 +191,12 @@ interface TangemTechApi {
suspend fun getPromoBannerDisplays( suspend fun getPromoBannerDisplays(
@Query("walletId") walletId: String, @Query("walletId") walletId: String,
@Query("placeholder") placeholder: String, @Query("placeholder") placeholder: String,
@Query("locale") locale: String, @Query("lang") languageISOCode: String,
): ApiResponse<PromoBannerDisplaysResponse> ): ApiResponse<PromoBannerDisplaysResponse>
@PATCH("v1/displays/{displayId}") @PATCH("v1/banner/displays/{displayId}")
suspend fun dismissPromoBannerDisplay( suspend fun dismissPromoBannerDisplay(
@Path("displayId") displayId: String, @Path("displayId") displayId: Int,
@Body body: DismissPromoBannerRequest, @Body body: DismissPromoBannerRequest,
): ApiResponse<DismissPromoBannerResponse> ): ApiResponse<DismissPromoBannerResponse>
// endregion // endregion

View file

@ -5,20 +5,31 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class PromoBannerDisplaysResponse( data class PromoBannerDisplaysResponse(
@Json(name = "items") val items: List<PromoBannerDisplayDTO>, @Json(name = "items")
val items: List<PromoBannerDisplayDTO>,
) )
@Suppress("BooleanPropertyNaming") @Suppress("BooleanPropertyNaming")
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class PromoBannerDisplayDTO( data class PromoBannerDisplayDTO(
@Json(name = "id") val id: String, @Json(name = "id")
@Json(name = "placeholder") val placeholder: String, val id: Int,
@Json(name = "priority") val priority: String, @Json(name = "placeholder")
@Json(name = "title") val title: String, val placeholder: String,
@Json(name = "subtitle") val subtitle: String, @Json(name = "priority")
@Json(name = "iconUrl") val iconUrl: String?, val priority: String,
@Json(name = "deeplink") val deeplink: String?, @Json(name = "title")
@Json(name = "buttonEnabled") val buttonEnabled: Boolean, val title: String,
@Json(name = "buttonText") val buttonText: String?, @Json(name = "subtitle")
@Json(name = "dismissable") val dismissable: Boolean, val subtitle: String,
@Json(name = "iconUrl")
val iconUrl: String?,
@Json(name = "deeplink")
val deeplink: String?,
@Json(name = "buttonEnabled")
val buttonEnabled: Boolean,
@Json(name = "buttonText")
val buttonText: String?,
@Json(name = "dismissable")
val dismissable: Boolean,
) )

View file

@ -8,34 +8,51 @@ internal sealed class PromoBannerAnalyticsEvent(
) : AnalyticsEvent(category = "Promo Banner", event = event, params = params) { ) : AnalyticsEvent(category = "Promo Banner", event = event, params = params) {
data class Shown( data class Shown(
private val displayId: String, private val displayId: Int,
private val placeholder: String, private val placeholder: String,
) : PromoBannerAnalyticsEvent( ) : PromoBannerAnalyticsEvent(
event = "Banner Shown", event = "Banner Shown",
params = mapOf("Display Id" to displayId, "Placeholder" to placeholder), params = mapOf(
PARAM_DISPLAY_ID to displayId.toString(),
PARAM_PLACEHOLDER to placeholder,
),
) )
data class CarouselScrolled( data class CarouselScrolled(
private val displayId: String, private val displayId: Int,
private val placeholder: String, private val placeholder: String,
) : PromoBannerAnalyticsEvent( ) : PromoBannerAnalyticsEvent(
event = "Banner Carousel Scrolled", event = "Banner Carousel Scrolled",
params = mapOf("Display Id" to displayId, "Placeholder" to placeholder), params = mapOf(
PARAM_DISPLAY_ID to displayId.toString(),
PARAM_PLACEHOLDER to placeholder,
),
) )
data class Clicked( data class Clicked(
private val displayId: String, private val displayId: Int,
private val placeholder: String, private val placeholder: String,
) : PromoBannerAnalyticsEvent( ) : PromoBannerAnalyticsEvent(
event = "Banner Button Clicked", event = "Banner Button Clicked",
params = mapOf("Display Id" to displayId, "Placeholder" to placeholder), params = mapOf(
PARAM_DISPLAY_ID to displayId.toString(),
PARAM_PLACEHOLDER to placeholder,
),
) )
data class Dismissed( data class Dismissed(
private val displayId: String, private val displayId: Int,
private val placeholder: String, private val placeholder: String,
) : PromoBannerAnalyticsEvent( ) : PromoBannerAnalyticsEvent(
event = "Banner Dismissed", event = "Banner Dismissed",
params = mapOf("Display Id" to displayId, "Placeholder" to placeholder), params = mapOf(
PARAM_DISPLAY_ID to displayId.toString(),
PARAM_PLACEHOLDER to placeholder,
),
) )
private companion object {
const val PARAM_DISPLAY_ID = "Display Id"
const val PARAM_PLACEHOLDER = "Placeholder"
}
} }

View file

@ -25,7 +25,7 @@ internal class PromoBannerDisplayDTOConverter : Converter<PromoBannerDisplayDTO,
private fun convertPriority(value: String): PromoBannerPriority { private fun convertPriority(value: String): PromoBannerPriority {
return when (value.uppercase(Locale.ROOT)) { return when (value.uppercase(Locale.ROOT)) {
"IMPORTANT" -> PromoBannerPriority.IMPORTANT "TOP" -> PromoBannerPriority.TOP
"HIGH" -> PromoBannerPriority.HIGH "HIGH" -> PromoBannerPriority.HIGH
"MEDIUM" -> PromoBannerPriority.MEDIUM "MEDIUM" -> PromoBannerPriority.MEDIUM
"LOW" -> PromoBannerPriority.LOW "LOW" -> PromoBannerPriority.LOW

View file

@ -10,7 +10,7 @@ internal class PromoBannerDisplayToNotificationConverter {
fun convert( fun convert(
banner: PromoBannerDisplay, banner: PromoBannerDisplay,
onDeeplinkClick: (String?) -> Unit, onDeeplinkClick: (String?) -> Unit,
onDismiss: (String) -> Unit, onDismiss: (Int) -> Unit,
): PromoBannerNotificationUM { ): PromoBannerNotificationUM {
return PromoBannerNotificationUM( return PromoBannerNotificationUM(
displayId = banner.id, displayId = banner.id,

View file

@ -1,7 +1,7 @@
package com.tangem.features.promobanners.impl.model package com.tangem.features.promobanners.impl.model
internal data class PromoBannerDisplay( internal data class PromoBannerDisplay(
val id: String, val id: Int,
val placeholder: String, val placeholder: String,
val priority: PromoBannerPriority, val priority: PromoBannerPriority,
val title: String, val title: String,

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import com.tangem.features.promobanners.impl.converters.PromoBannerDisplayToNoti
import com.tangem.features.promobanners.impl.repository.PromoBannersRepository import com.tangem.features.promobanners.impl.repository.PromoBannersRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.collections.immutable.persistentListOf
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -34,11 +35,12 @@ internal class PromoBannersBlockModel @Inject constructor(
private val converter = PromoBannerDisplayToNotificationConverter() private val converter = PromoBannerDisplayToNotificationConverter()
private val placeholder: String = params.placeholder.name.lowercase() private val placeholder: String = params.placeholder.name.lowercase()
private val shownBannerIds: MutableSet<String> = ConcurrentHashMap.newKeySet() private val shownBannerIds: MutableSet<Int> = ConcurrentHashMap.newKeySet()
private var wasCarouselScrolled = false private var wasCarouselScrolled = false
private val savedDisplayIdByWalletId: MutableMap<String, Int> = mutableMapOf()
val uiState: StateFlow<PromoBannersBlockUM> val uiState: StateFlow<PromoBannersBlockUM>
field = MutableStateFlow(PromoBannersBlockUM()) field = MutableStateFlow(getInitialState())
init { init {
subscribeOnSelectedWallet() subscribeOnSelectedWallet()
@ -59,46 +61,67 @@ internal class PromoBannersBlockModel @Inject constructor(
} }
private suspend fun loadBanners(walletId: String) { private suspend fun loadBanners(walletId: String) {
val locale = Locale.getDefault().language val languageISOCode = Locale.getDefault().language
runSuspendCatching { runSuspendCatching {
repository.getBanners(walletId, params.placeholder, locale) repository.getBanners(walletId, params.placeholder, languageISOCode)
}.onSuccess { banners -> }.onSuccess { banners ->
val bannerUMs = banners.map { banner ->
converter.convert(
banner = banner,
onDeeplinkClick = { deeplink -> onButtonClick(banner.id, deeplink) },
onDismiss = { displayId -> onBannerDismiss(walletId, displayId) },
)
}.toImmutableList()
val savedDisplayId = savedDisplayIdByWalletId[walletId]
val initialPage = if (savedDisplayId != null) {
bannerUMs.indexOfFirst { it.displayId == savedDisplayId }.coerceAtLeast(0)
} else {
0
}
uiState.value = PromoBannersBlockUM( uiState.value = PromoBannersBlockUM(
banners = banners.map { banner -> userWalletId = walletId,
converter.convert( initialPage = initialPage,
banner = banner, banners = bannerUMs,
onDeeplinkClick = { deeplink -> onButtonClick(banner.id, deeplink) },
onDismiss = { displayId -> onBannerDismiss(walletId, displayId) },
)
}.toImmutableList(),
onBannerShown = ::onBannerShown, onBannerShown = ::onBannerShown,
onCarouselScrolled = ::onCarouselScrolled, onCarouselScrolled = ::onCarouselScrolled,
onPageChanged = { displayId -> savedDisplayIdByWalletId[walletId] = displayId },
) )
}.onFailure { error -> }.onFailure { error ->
TangemLogger.w("Failed to load promo banners", error) TangemLogger.w("Failed to load promo banners", error)
} }
} }
private fun onBannerShown(displayId: String) { private fun onBannerShown(displayId: Int) {
if (shownBannerIds.add(displayId)) { if (shownBannerIds.add(displayId)) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholder)) analyticsEventHandler.send(PromoBannerAnalyticsEvent.Shown(displayId, placeholder))
} }
} }
private fun onCarouselScrolled(displayId: String) { private fun onCarouselScrolled(displayId: Int) {
if (!wasCarouselScrolled) { if (!wasCarouselScrolled) {
wasCarouselScrolled = true wasCarouselScrolled = true
analyticsEventHandler.send(PromoBannerAnalyticsEvent.CarouselScrolled(displayId, placeholder)) analyticsEventHandler.send(PromoBannerAnalyticsEvent.CarouselScrolled(displayId, placeholder))
} }
} }
private fun onButtonClick(displayId: String, deeplink: String?) { private fun onButtonClick(displayId: Int, deeplink: String?) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholder)) analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholder))
deeplink?.let { deeplinkLauncher.launch(it) } deeplink?.let { deeplinkLauncher.launch(it) }
} }
private fun onBannerDismiss(walletId: String, displayId: String) { private fun getInitialState() = PromoBannersBlockUM(
userWalletId = "",
initialPage = 0,
banners = persistentListOf(),
onBannerShown = {},
onCarouselScrolled = {},
onPageChanged = {},
)
private fun onBannerDismiss(walletId: String, displayId: Int) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholder)) analyticsEventHandler.send(PromoBannerAnalyticsEvent.Dismissed(displayId, placeholder))
uiState.update { state -> uiState.update { state ->
state.copy( state.copy(

View file

@ -1,10 +1,12 @@
package com.tangem.features.promobanners.impl.model package com.tangem.features.promobanners.impl.model
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal data class PromoBannersBlockUM( internal data class PromoBannersBlockUM(
val banners: ImmutableList<PromoBannerNotificationUM> = persistentListOf(), val userWalletId: String,
val onBannerShown: (displayId: String) -> Unit = {}, val initialPage: Int,
val onCarouselScrolled: (displayId: String) -> Unit = {}, val banners: ImmutableList<PromoBannerNotificationUM>,
val onBannerShown: (displayId: Int) -> Unit,
val onCarouselScrolled: (displayId: Int) -> Unit,
val onPageChanged: (displayId: Int) -> Unit,
) )

View file

@ -28,7 +28,7 @@ internal class DefaultPromoBannersRepository(
override suspend fun getBanners( override suspend fun getBanners(
walletId: String, walletId: String,
placeholder: Placeholder, placeholder: Placeholder,
locale: String, languageISOCode: String,
): List<PromoBannerDisplay> { ): List<PromoBannerDisplay> {
val key = BannersCacheKey(walletId, placeholder) val key = BannersCacheKey(walletId, placeholder)
@ -38,7 +38,7 @@ internal class DefaultPromoBannersRepository(
tangemTechApi.getPromoBannerDisplays( tangemTechApi.getPromoBannerDisplays(
walletId = walletId, walletId = walletId,
placeholder = placeholder.toApiValue(), placeholder = placeholder.toApiValue(),
locale = locale, languageISOCode = languageISOCode,
).getOrThrow() ).getOrThrow()
.items .items
.map(converter::convert) .map(converter::convert)
@ -52,7 +52,7 @@ internal class DefaultPromoBannersRepository(
return banners return banners
} }
override suspend fun dismissBanner(walletId: String, displayId: String) { override suspend fun dismissBanner(walletId: String, displayId: Int) {
cache.update(default = emptyMap()) { current -> cache.update(default = emptyMap()) { current ->
current.mapValues { (key, banners) -> current.mapValues { (key, banners) ->
if (key.walletId == walletId) { if (key.walletId == walletId) {

View file

@ -5,7 +5,11 @@ import com.tangem.features.promobanners.impl.model.PromoBannerDisplay
internal interface PromoBannersRepository { internal interface PromoBannersRepository {
suspend fun getBanners(walletId: String, placeholder: Placeholder, locale: String): List<PromoBannerDisplay> suspend fun getBanners(
walletId: String,
placeholder: Placeholder,
languageISOCode: String,
): List<PromoBannerDisplay>
suspend fun dismissBanner(walletId: String, displayId: String) suspend fun dismissBanner(walletId: String, displayId: Int)
} }

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -35,12 +36,16 @@ internal fun PromoBannersBlock(state: PromoBannersBlockUM, modifier: Modifier =
modifier = modifier, modifier = modifier,
) )
} else { } else {
BannersCarousel( key(state.userWalletId) {
banners = state.banners, BannersCarousel(
onBannerShown = state.onBannerShown, banners = state.banners,
onCarouselScroll = state.onCarouselScrolled, initialPage = state.initialPage,
modifier = modifier, onBannerShown = state.onBannerShown,
) onCarouselScroll = state.onCarouselScrolled,
onPageChange = state.onPageChanged,
modifier = modifier,
)
}
} }
} }
@ -56,16 +61,22 @@ private fun SingleBanner(banner: PromoBannerNotificationUM, modifier: Modifier =
@Composable @Composable
private fun BannersCarousel( private fun BannersCarousel(
banners: List<PromoBannerNotificationUM>, banners: List<PromoBannerNotificationUM>,
onBannerShown: (String) -> Unit, initialPage: Int,
onCarouselScroll: (String) -> Unit, onBannerShown: (Int) -> Unit,
onCarouselScroll: (Int) -> Unit,
onPageChange: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val pagerState = rememberPagerState(pageCount = { banners.size }) val pagerState = rememberPagerState(
initialPage = initialPage,
pageCount = { banners.size },
)
LaunchedEffect(pagerState, banners) { LaunchedEffect(pagerState, banners) {
snapshotFlow { pagerState.currentPage } snapshotFlow { pagerState.currentPage }
.collect { page -> .collect { page ->
banners.getOrNull(page)?.let { banner -> banners.getOrNull(page)?.let { banner ->
onPageChange(banner.displayId)
onBannerShown(banner.displayId) onBannerShown(banner.displayId)
if (page == 1) { if (page == 1) {
// one-time event when user scrolls from first to second page, // one-time event when user scrolls from first to second page,

View file

@ -12,7 +12,7 @@ class PromoBannerDisplayDTOConverterTest {
@Test @Test
fun `should convert DTO to domain model`() { fun `should convert DTO to domain model`() {
val dto = PromoBannerDisplayDTO( val dto = PromoBannerDisplayDTO(
id = "123", id = 123,
placeholder = "MAIN", placeholder = "MAIN",
priority = "HIGH", priority = "HIGH",
title = "Test Banner", title = "Test Banner",
@ -26,7 +26,7 @@ class PromoBannerDisplayDTOConverterTest {
val result = converter.convert(dto) val result = converter.convert(dto)
assertThat(result.id).isEqualTo("123") assertThat(result.id).isEqualTo(123)
assertThat(result.placeholder).isEqualTo("MAIN") assertThat(result.placeholder).isEqualTo("MAIN")
assertThat(result.priority).isEqualTo(PromoBannerPriority.HIGH) assertThat(result.priority).isEqualTo(PromoBannerPriority.HIGH)
assertThat(result.title).isEqualTo("Test Banner") assertThat(result.title).isEqualTo("Test Banner")
@ -63,7 +63,7 @@ class PromoBannerDisplayDTOConverterTest {
placeholder: String = "MAIN", placeholder: String = "MAIN",
priority: String = "MEDIUM", priority: String = "MEDIUM",
) = PromoBannerDisplayDTO( ) = PromoBannerDisplayDTO(
id = "1", id = 1,
placeholder = placeholder, placeholder = placeholder,
priority = priority, priority = priority,
title = "Title", title = "Title",

View file

@ -13,14 +13,14 @@ class PromoBannerDisplayToNotificationConverterTest {
@Test @Test
fun `should convert banner with all fields`() { fun `should convert banner with all fields`() {
val banner = createBanner( val banner = createBanner(
id = "b1", id = 1,
deeplink = "tangem://wallet", deeplink = "tangem://wallet",
isButtonEnabled = true, isButtonEnabled = true,
buttonText = "Open", buttonText = "Open",
isDismissable = true, isDismissable = true,
) )
var clickedDeeplink: String? = "not_called" var clickedDeeplink: String? = "not_called"
var dismissedId: String? = null var dismissedId: Int? = null
val result = converter.convert( val result = converter.convert(
banner = banner, banner = banner,
@ -28,7 +28,7 @@ class PromoBannerDisplayToNotificationConverterTest {
onDismiss = { dismissedId = it }, onDismiss = { dismissedId = it },
) )
assertThat(result.displayId).isEqualTo("b1") assertThat(result.displayId).isEqualTo(1)
assertThat(result.config.title).isEqualTo(TextReference.Str("Title")) assertThat(result.config.title).isEqualTo(TextReference.Str("Title"))
assertThat(result.config.subtitle).isEqualTo(TextReference.Str("Subtitle")) assertThat(result.config.subtitle).isEqualTo(TextReference.Str("Subtitle"))
assertThat(result.config.iconUrl).isEqualTo("https://icon.png") assertThat(result.config.iconUrl).isEqualTo("https://icon.png")
@ -42,7 +42,7 @@ class PromoBannerDisplayToNotificationConverterTest {
assertThat(clickedDeeplink).isEqualTo("tangem://wallet") assertThat(clickedDeeplink).isEqualTo("tangem://wallet")
result.config.onCloseClick!!.invoke() result.config.onCloseClick!!.invoke()
assertThat(dismissedId).isEqualTo("b1") assertThat(dismissedId).isEqualTo(1)
} }
@Test @Test
@ -98,7 +98,7 @@ class PromoBannerDisplayToNotificationConverterTest {
} }
private fun createBanner( private fun createBanner(
id: String = "id", id: Int = 0,
deeplink: String? = "https://example.com", deeplink: String? = "https://example.com",
isButtonEnabled: Boolean = false, isButtonEnabled: Boolean = false,
buttonText: String? = null, buttonText: String? = null,