diff --git a/common/routing/build.gradle.kts b/common/routing/build.gradle.kts index b34c827c74..df9824a0d4 100644 --- a/common/routing/build.gradle.kts +++ b/common/routing/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { /* Core */ implementation(projects.core.decompose) implementation(projects.core.configToggles) + implementation(projects.core.navigation) /* Domain */ implementation(projects.domain.qrScanning.models) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/LinkHandler.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/LinkHandler.kt new file mode 100644 index 0000000000..094ecd23eb --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/LinkHandler.kt @@ -0,0 +1,35 @@ +package com.tangem.common.routing + +import android.net.Uri +import timber.log.Timber + +/** + * Routes in-app content links (deep links and external URLs) + * - `tangem://` scheme → parsed to AppRoute and pushed via AppRouter + * - `https://` / `http://` → opened in external browser via UrlOpener + * - Unknown scheme → logged and ignored + */ +class LinkHandler( + private val appRouter: AppRouter, +) { + + fun navigate(link: String) { + val uri = Uri.parse(link) + handleTangemDeepLink(uri) + } + + private fun handleTangemDeepLink(uri: Uri) { + val route = parseDeepLinkToRoute(uri) + if (route != null) { + appRouter.push(route) + } else { + Timber.w("ContentLinkHandler: unrecognized tangem deep link: %s", uri) + } + } + + @Suppress("UnusedParameter", "FunctionOnlyReturningConstant") + private fun parseDeepLinkToRoute(uri: Uri): AppRoute? { + // TODO [REDACTED_TASK_KEY] refactor deepling routing + return null + } +} \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 30e91a42ae..46aea1e987 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -59,5 +59,9 @@ { "name": "MAIN_SCREEN_QR_SCANNING_ENABLED", "version": "undefined" + }, + { + "name": "NEW_PROMO_BANNERS_ENABLED", + "version": "undefined" } ] diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index aa8748eb2c..916be59d74 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -5,6 +5,9 @@ import com.tangem.datasource.api.promotion.models.PromoBannerResponse import com.tangem.datasource.api.promotion.models.PromoBannerV2Response import com.tangem.datasource.api.promotion.models.StoryContentResponse import com.tangem.datasource.api.tangemTech.models.* +import com.tangem.datasource.api.tangemTech.models.promobanners.PromoBannerDisplaysResponse +import com.tangem.datasource.api.tangemTech.models.promobanners.DismissPromoBannerRequest +import com.tangem.datasource.api.tangemTech.models.promobanners.DismissPromoBannerResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletArchivedAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse @@ -197,6 +200,21 @@ interface TangemTechApi { ): ApiResponse // endregion + // region promo banners + @GET("v1/banner/displays") + suspend fun getPromoBannerDisplays( + @Query("walletId") walletId: String, + @Query("placeholder") placeholder: String, + @Query("locale") locale: String, + ): ApiResponse + + @PATCH("v1/displays/{displayId}") + suspend fun dismissPromoBannerDisplay( + @Path("displayId") displayId: String, + @Body body: DismissPromoBannerRequest, + ): ApiResponse + // endregion + /** * Stores transaction hash in cache to prevent duplicate push * notifications for yield operations (deposit, withdraw, send). diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerRequest.kt new file mode 100644 index 0000000000..1bb3d5ac18 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerRequest.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.tangemTech.models.promobanners + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DismissPromoBannerRequest( + @Json(name = "walletId") val walletId: String, + @Json(name = "isDismissed") val isDismissed: Boolean, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerResponse.kt new file mode 100644 index 0000000000..7434487ff5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/DismissPromoBannerResponse.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.tangemTech.models.promobanners + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DismissPromoBannerResponse( + @Json(name = "displayId") val displayId: String, + @Json(name = "walletId") val walletId: String, + @Json(name = "isDismissed") val isDismissed: Boolean, + @Json(name = "dismissedAt") val dismissedAt: String?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/PromoBannerDisplaysResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/PromoBannerDisplaysResponse.kt new file mode 100644 index 0000000000..ca1a8e9e5e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/promobanners/PromoBannerDisplaysResponse.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.api.tangemTech.models.promobanners + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PromoBannerDisplaysResponse( + @Json(name = "items") val items: List, +) + +@Suppress("BooleanPropertyNaming") +@JsonClass(generateAdapter = true) +data class PromoBannerDisplayDTO( + @Json(name = "id") val id: String, + @Json(name = "placeholder") val placeholder: String, + @Json(name = "priority") val priority: String, + @Json(name = "title") val title: String, + @Json(name = "subtitle") 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, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index d1b06a5538..bd01f82aaa 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -2,6 +2,7 @@ package com.tangem.core.ui.components.notifications import android.content.res.Configuration import androidx.annotation.DrawableRes +import coil.compose.SubcomposeAsyncImage import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.LocalIndication @@ -86,6 +87,7 @@ fun Notification( subtitleColor = subtitleColor, showArrowIcon = isEnabled && config.shouldShowArrowIcon, hasCloseButton = config.onCloseClick != null, + iconUrl = config.iconUrl, ) } } @@ -150,16 +152,29 @@ private fun MainContent( subtitleColor: Color, showArrowIcon: Boolean, hasCloseButton: Boolean, + iconUrl: String? = null, ) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - iconResId = iconResId, - tint = iconTint, - modifier = Modifier - .size(size = iconSize) - .align(alignment = Alignment.Top) - .testTag(NotificationTestTags.ICON), - ) + if (iconUrl != null) { + UrlIcon( + iconUrl = iconUrl, + fallbackIconResId = iconResId, + fallbackIconTint = iconTint, + modifier = Modifier + .size(size = iconSize) + .align(alignment = Alignment.Top) + .testTag(NotificationTestTags.ICON), + ) + } else { + Icon( + iconResId = iconResId, + tint = iconTint, + modifier = Modifier + .size(size = iconSize) + .align(alignment = Alignment.Top) + .testTag(NotificationTestTags.ICON), + ) + } SpacerW(width = TangemTheme.dimens.spacing10) @@ -212,6 +227,30 @@ private fun Icon(@DrawableRes iconResId: Int, tint: Color?, modifier: Modifier = } } +@Composable +private fun UrlIcon( + iconUrl: String, + @DrawableRes fallbackIconResId: Int, + fallbackIconTint: Color?, + modifier: Modifier = Modifier, +) { + SubcomposeAsyncImage( + model = iconUrl, + contentDescription = null, + modifier = modifier.clip(CircleShape), + loading = { + CircleShimmer(modifier = Modifier.matchParentSize()) + }, + error = { + Icon( + iconResId = fallbackIconResId, + tint = fallbackIconTint, + modifier = Modifier.matchParentSize(), + ) + }, + ) +} + @Composable internal fun TextsBlock( title: TextReference?, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt index 9da575b6c2..d15a939542 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.TextReference * * @property subtitle subtitle * @property iconResId icon resource id + * @property iconUrl icon URL for remote images * @property title title * @property backgroundResId background resource id * @property buttonsState buttons state @@ -21,6 +22,7 @@ import com.tangem.core.ui.extensions.TextReference data class NotificationConfig( val subtitle: TextReference, @DrawableRes val iconResId: Int, + val iconUrl: String? = null, val title: TextReference? = null, @DrawableRes val backgroundResId: Int? = null, val buttonsState: ButtonsState? = null, diff --git a/features/promo-banners/api/build.gradle.kts b/features/promo-banners/api/build.gradle.kts new file mode 100644 index 0000000000..a81f1d0c23 --- /dev/null +++ b/features/promo-banners/api/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/NewPromoBannersFeatureToggles.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/NewPromoBannersFeatureToggles.kt new file mode 100644 index 0000000000..bc392c04ba --- /dev/null +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/NewPromoBannersFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.promobanners.api + +interface NewPromoBannersFeatureToggles { + val isNewPromoBannersEnabled: Boolean +} \ No newline at end of file diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt new file mode 100644 index 0000000000..f9525a6ac5 --- /dev/null +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt @@ -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 +} \ No newline at end of file diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts new file mode 100644 index 0000000000..cee5719108 --- /dev/null +++ b/features/promo-banners/impl/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt new file mode 100644 index 0000000000..fd6944d386 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/analytics/PromoBannerAnalyticsEvent.kt @@ -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 = 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), + ) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverter.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverter.kt new file mode 100644 index 0000000000..13e19d5361 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverter.kt @@ -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 { + + 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 + } + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverter.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverter.kt new file mode 100644 index 0000000000..0e81007163 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverter.kt @@ -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) }, + ) + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerDisplay.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerDisplay.kt new file mode 100644 index 0000000000..6f96a1cf00 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerDisplay.kt @@ -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, +) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerNotificationUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerNotificationUM.kt new file mode 100644 index 0000000000..1d244d419c --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerNotificationUM.kt @@ -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, +) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerPriority.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerPriority.kt new file mode 100644 index 0000000000..959f3c3b44 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannerPriority.kt @@ -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), +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt new file mode 100644 index 0000000000..8a311ac7d7 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockUM.kt @@ -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 = persistentListOf(), +) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt new file mode 100644 index 0000000000..166ef40064 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/DefaultPromoBannersRepository.kt @@ -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 = + 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() + } + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt new file mode 100644 index 0000000000..564b71625f --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/repository/PromoBannersRepository.kt @@ -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 + + suspend fun dismissBanner(walletId: String, displayId: String) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultNewPromoBannersFeatureToggles.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultNewPromoBannersFeatureToggles.kt new file mode 100644 index 0000000000..a93f31bc75 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultNewPromoBannersFeatureToggles.kt @@ -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") +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverterTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverterTest.kt new file mode 100644 index 0000000000..91eb08c41a --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayDTOConverterTest.kt @@ -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, + ) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt new file mode 100644 index 0000000000..3ae0b738dc --- /dev/null +++ b/features/promo-banners/impl/src/test/kotlin/com/tangem/features/promobanners/impl/converters/PromoBannerDisplayToNotificationConverterTest.kt @@ -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, + ) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index f77aab113a..76cb4af3e4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -305,6 +305,9 @@ include(":features:approval:impl") include(":features:feed:api") include(":features:feed:impl") + +include(":features:promo-banners:api") +include(":features:promo-banners:impl") // endregion Feature modules // region Domain modules