Updated on 2026-08-14
This commit is contained in:
commit
50fe9e603d
1919 changed files with 94940 additions and 15178 deletions
|
|
@ -9,6 +9,9 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.ui)
|
||||
api(deps.compose.runtime)
|
||||
api(deps.compose.ui)
|
||||
|
||||
api(projects.core.decompose)
|
||||
api(projects.core.ui)
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ interface PromoBannersBlockComponent {
|
|||
enum class Placeholder(val value: String) {
|
||||
MAIN("main"),
|
||||
FEED("shtorka"),
|
||||
PAYMENT_ACCOUNT_MAIN("payment_account_main"),
|
||||
}
|
||||
|
||||
interface Factory : ComponentFactory<Params, PromoBannersBlockComponent>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.features.promobanners.api.deeplink
|
||||
|
||||
interface CampaignsDeepLinkHandler {
|
||||
|
||||
interface Factory {
|
||||
fun create(queryParams: Map<String, String>): CampaignsDeepLinkHandler
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.features.promobanners.api.swapcashback
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
|
||||
/**
|
||||
|
||||
* once at app startup (hence [Unit] params) and reacts to campaign requests coming through the
|
||||
* promo-campaigns bus, not to navigation.
|
||||
*/
|
||||
interface CampaignsComponent : ComposableContentComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Unit, CampaignsComponent>
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.features.promobanners.api.toggles
|
||||
|
||||
interface PromoBannersFeatureToggles {
|
||||
|
||||
val isCampaignsToggleEnabled: Boolean
|
||||
}
|
||||
1
features/promo-banners/impl/.gitignore
vendored
Normal file
1
features/promo-banners/impl/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.serialization)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
id("configuration")
|
||||
|
|
@ -12,29 +13,44 @@ android {
|
|||
|
||||
dependencies {
|
||||
/** Project - API */
|
||||
implementation(projects.features.promoBanners.api)
|
||||
api(projects.features.promoBanners.api)
|
||||
implementation(projects.features.commonFeatures.api)
|
||||
implementation(projects.common.routing)
|
||||
implementation(projects.common.ui)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.common)
|
||||
api(projects.domain.common)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.appCurrency)
|
||||
implementation(projects.domain.account.status)
|
||||
implementation(projects.domain.promo)
|
||||
implementation(projects.domain.promo.models)
|
||||
|
||||
/** Data */
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.analytics)
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.analytics)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.decompose)
|
||||
api(projects.core.navigation)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.analytics.models)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
/** Compose */
|
||||
implementation(deps.compose.foundation)
|
||||
api(deps.compose.foundation)
|
||||
implementation(deps.compose.ui)
|
||||
implementation(deps.compose.ui.tooling)
|
||||
implementation(deps.lifecycle.compose)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.androidx.appCompat)
|
||||
implementation(deps.androidx.core.ktx)
|
||||
implementation(deps.decompose)
|
||||
implementation(deps.decompose.ext.compose)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
|
||||
/** DI */
|
||||
|
|
@ -42,6 +58,6 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(projects.common.test)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.analytics
|
||||
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
|
||||
internal sealed class PromoCampaignsAnalyticsEvent(
|
||||
event: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
) : AnalyticsEvent(category = "Promotion", event = event, params = params) {
|
||||
|
||||
class PromotionScreenOpened(campaignType: CampaignType) : PromoCampaignsAnalyticsEvent(
|
||||
event = "Promotion Screen Opened",
|
||||
params = mapOf("Screen" to campaignType.analyticsName),
|
||||
)
|
||||
|
||||
class EnrollButtonClicked(
|
||||
campaignType: CampaignType,
|
||||
token: String,
|
||||
blockchain: String,
|
||||
) : PromoCampaignsAnalyticsEvent(
|
||||
event = "Enroll Button Clicked",
|
||||
params = mapOf(
|
||||
"Campaign" to campaignType.analyticsName,
|
||||
"Token" to token,
|
||||
"Blockchain" to blockchain,
|
||||
),
|
||||
)
|
||||
|
||||
class AlreadyEnrolledScreenOpened : PromoCampaignsAnalyticsEvent(event = "Already Enrolled Screen Opened")
|
||||
}
|
||||
|
||||
private val CampaignType.analyticsName: String
|
||||
get() = when (this) {
|
||||
is CampaignType.WhaleSwapCashback -> "Cashback"
|
||||
is CampaignType.ReactivationCashback -> "Reactivation"
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.component
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.child
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.decompose.ComposableModularContentComponent
|
||||
import com.tangem.core.ui.ds2.button.Close
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel
|
||||
import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent
|
||||
import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignFooter
|
||||
|
||||
internal class ActivateCampaignBottomSheetComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
|
||||
private val params: Params,
|
||||
val onDismiss: () -> Unit,
|
||||
val onFooterExtraHeightReady: (Dp) -> Unit,
|
||||
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: ActivateCampaignsModel = getOrCreateModel(params)
|
||||
|
||||
private val chooseTokenComponent: ChooseTokenComponent = chooseTokenComponentFactory.create(
|
||||
context = child(key = "swapCashbackChooseToken"),
|
||||
params = ChooseTokenComponent.Params(bridge = model.bridge),
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Title() {
|
||||
TangemTopNavigation(
|
||||
windowInsets = WindowInsets(0),
|
||||
blurBackground = false,
|
||||
endButton = { TangemButton.Close(onClick = onDismiss) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
ActivateCampaignContent(um = state, modifier = modifier)
|
||||
|
||||
if (state.isChoosingToken) {
|
||||
ChooseTokenBottomSheet(state.onChooseTokenDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Footer() {
|
||||
val state by model.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
ActivateCampaignFooter(
|
||||
footerUM = state.footerUM,
|
||||
onFooterTextHeightReady = onFooterExtraHeightReady,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChooseTokenBottomSheet(onChooseTokenDismiss: () -> Unit) {
|
||||
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = onChooseTokenDismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
),
|
||||
onBack = onChooseTokenDismiss,
|
||||
content = { chooseTokenComponent.Content(modifier = Modifier.fillMaxWidth()) },
|
||||
)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val campaignType: CampaignType,
|
||||
val userWalletId: UserWalletId,
|
||||
val modelCallbacks: ActivateCampaignModelCallbacks,
|
||||
)
|
||||
|
||||
interface ActivateCampaignModelCallbacks {
|
||||
val onActivated: (CampaignType) -> Unit
|
||||
val onAlreadyActivated: (CampaignType) -> Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.component
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.decompose.ComposableModularContentComponent
|
||||
import com.tangem.core.ui.ds2.button.Close
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.features.promobanners.impl.R
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter
|
||||
import com.tangem.features.promobanners.impl.campaigns.ui.AlreadyActivatedCampaignContent
|
||||
|
||||
internal class CampaignAlreadyActivatedBottomSheetComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
val onDismiss: () -> Unit,
|
||||
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val campaignName = CampaignTypeToContentConverter().convert(params.campaignType).name
|
||||
|
||||
@Composable
|
||||
override fun Title() {
|
||||
TangemTopNavigation(
|
||||
windowInsets = WindowInsets(0),
|
||||
blurBackground = false,
|
||||
endButton = { TangemButton.Close(onClick = onDismiss) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
AlreadyActivatedCampaignContent(
|
||||
message = resourceReference(
|
||||
R.string.promo_campaign_already_activated_title,
|
||||
wrappedList(campaignName),
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Footer() {
|
||||
PrimaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(R.string.common_close),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val campaignType: CampaignType,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.component
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.decompose.ComposableModularContentComponent
|
||||
import com.tangem.core.ui.ds2.button.Close
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.features.promobanners.impl.R
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter
|
||||
import com.tangem.features.promobanners.impl.campaigns.ui.CampaignEnrolledMessageContent
|
||||
|
||||
internal class CampaignEnrolledBottomSheetComponent(
|
||||
params: Params,
|
||||
private val onDismissRequest: () -> Unit,
|
||||
) : ComposableModularContentComponent {
|
||||
|
||||
private val campaignName = CampaignTypeToContentConverter().convert(params.campaignType).name
|
||||
|
||||
@Composable
|
||||
override fun Title() {
|
||||
TangemTopNavigation(
|
||||
windowInsets = WindowInsets(0),
|
||||
blurBackground = false,
|
||||
endButton = { TangemButton.Close(onClick = onDismissRequest) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
CampaignEnrolledMessageContent(
|
||||
message = resourceReference(
|
||||
R.string.promo_campaign_enroll_success_title,
|
||||
wrappedList(campaignName),
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Footer() {
|
||||
PrimaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(R.string.common_close),
|
||||
onClick = { onDismissRequest() },
|
||||
)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val campaignType: CampaignType,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.component
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.slot.childSlot
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.components.bottomsheets.modal.DEFAULT_FOOTER_HEIGHT
|
||||
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
|
||||
import com.tangem.core.ui.decompose.ComposableModularContentComponent
|
||||
import com.tangem.core.ui.extensions.rememberLastNonNull
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
|
||||
import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent
|
||||
import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig
|
||||
import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultCampaignsComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted params: Unit,
|
||||
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
|
||||
) : CampaignsComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: CampaignsModel = getOrCreateModel()
|
||||
|
||||
private val bottomSheetSlot = childSlot(
|
||||
source = model.bottomSheetNavigation,
|
||||
serializer = null,
|
||||
handleBackButton = false,
|
||||
childFactory = ::bottomSheetChild,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val bottomSheet by bottomSheetSlot.subscribeAsState()
|
||||
val activeChild = bottomSheet.child?.instance
|
||||
val displayedChild = rememberLastNonNull(activeChild)
|
||||
val footerExtraHeight by model.footerExtraHeightState.collectAsStateWithLifecycle()
|
||||
|
||||
TangemModalBottomSheetWithFooter<TangemBottomSheetConfigContent.Empty>(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = activeChild != null,
|
||||
onDismissRequest = model::onDismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
),
|
||||
containerColor = TangemTheme.colors3.bg.primary,
|
||||
footerHeight = DEFAULT_FOOTER_HEIGHT + footerExtraHeight,
|
||||
onBack = model::onDismiss,
|
||||
title = {
|
||||
displayedChild?.Title()
|
||||
},
|
||||
content = {
|
||||
Box(modifier = Modifier.animateContentSize()) {
|
||||
displayedChild?.Content(modifier = Modifier)
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(12.dp),
|
||||
) {
|
||||
displayedChild?.Footer()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun bottomSheetChild(
|
||||
config: CampaignsBottomSheetConfig,
|
||||
componentContext: ComponentContext,
|
||||
): ComposableModularContentComponent {
|
||||
val context = childByContext(componentContext)
|
||||
return when (config) {
|
||||
CampaignsBottomSheetConfig.NotActive -> NotActiveCampaignBottomSheetComponent(
|
||||
onDismissRequest = model::onDismiss,
|
||||
)
|
||||
is CampaignsBottomSheetConfig.Enrolled -> CampaignEnrolledBottomSheetComponent(
|
||||
params = CampaignEnrolledBottomSheetComponent.Params(
|
||||
campaignType = config.campaignType,
|
||||
),
|
||||
onDismissRequest = model::onDismiss,
|
||||
)
|
||||
is CampaignsBottomSheetConfig.Activate -> ActivateCampaignBottomSheetComponent(
|
||||
appComponentContext = context,
|
||||
chooseTokenComponentFactory = chooseTokenComponentFactory,
|
||||
onDismiss = model::onDismiss,
|
||||
onFooterExtraHeightReady = model::onFooterExtraHeightReady,
|
||||
params = ActivateCampaignBottomSheetComponent.Params(
|
||||
campaignType = config.campaignType,
|
||||
userWalletId = config.userWalletId,
|
||||
modelCallbacks = object : ActivateCampaignModelCallbacks {
|
||||
override val onActivated: (CampaignType) -> Unit = model::onActivated
|
||||
override val onAlreadyActivated: (CampaignType) -> Unit = model::onAlreadyActivated
|
||||
},
|
||||
),
|
||||
)
|
||||
is CampaignsBottomSheetConfig.AlreadyActivated -> CampaignAlreadyActivatedBottomSheetComponent(
|
||||
appComponentContext = context,
|
||||
params = CampaignAlreadyActivatedBottomSheetComponent.Params(
|
||||
campaignType = config.campaignType,
|
||||
),
|
||||
onDismiss = model::onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : CampaignsComponent.Factory {
|
||||
override fun create(context: AppComponentContext, params: Unit): DefaultCampaignsComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.component
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.decompose.ComposableModularContentComponent
|
||||
import com.tangem.core.ui.ds2.button.Close
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.features.promobanners.impl.R
|
||||
import com.tangem.features.promobanners.impl.campaigns.ui.NotActiveCampaignMessageContent
|
||||
|
||||
internal class NotActiveCampaignBottomSheetComponent(
|
||||
private val onDismissRequest: () -> Unit,
|
||||
) : ComposableModularContentComponent {
|
||||
|
||||
@Composable
|
||||
override fun Title() {
|
||||
TangemTopNavigation(
|
||||
windowInsets = WindowInsets(0),
|
||||
blurBackground = false,
|
||||
endButton = { TangemButton.Close(onClick = onDismissRequest) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
NotActiveCampaignMessageContent(modifier = modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Footer() {
|
||||
PrimaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(R.string.common_close),
|
||||
onClick = { onDismissRequest() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.converters
|
||||
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import com.tangem.utils.converter.Converter
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class CampaignIdConverter @Inject constructor() :
|
||||
Converter<String, CampaignType?> {
|
||||
|
||||
override fun convert(value: String): CampaignType? {
|
||||
return when (value) {
|
||||
CAMPAIGN_ID_REACTIVATION -> CampaignType.ReactivationCashback(campaignId = value)
|
||||
CAMPAIGN_ID_WHALE -> CampaignType.WhaleSwapCashback(campaignId = value)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CAMPAIGN_ID_WHALE = "1"
|
||||
const val CAMPAIGN_ID_REACTIVATION = "2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.deeplink
|
||||
|
||||
import com.tangem.common.routing.deeplink.DeeplinkConst
|
||||
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
|
||||
import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler
|
||||
import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles
|
||||
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
/**
|
||||
* Handles the campaigns deeplink (`tangem://campaigns?campaignId=1&lang=ru`): extracts the `campaignId` and
|
||||
* pushes it to the promo-campaigns bus. The always-alive [com.tangem.features.promobanners.api.swapcashback
|
||||
* .SwapCashbackCampaignComponent] listens to the bus, maps the id to a campaign type, resolves the campaign
|
||||
* state and shows the right sheet over the current screen.
|
||||
*/
|
||||
internal class DefaultCampaignsDeepLinkHandler @AssistedInject constructor(
|
||||
@Assisted private val queryParams: Map<String, String>,
|
||||
campaignsService: CampaignsService,
|
||||
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
|
||||
promoBannersFeatureToggles: PromoBannersFeatureToggles,
|
||||
) : CampaignsDeepLinkHandler {
|
||||
|
||||
init {
|
||||
if (promoBannersFeatureToggles.isCampaignsToggleEnabled) {
|
||||
// It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
|
||||
getSelectedWalletSyncUseCase().fold(
|
||||
ifLeft = {
|
||||
TangemLogger.e("Error on getting user wallet")
|
||||
},
|
||||
ifRight = { userWallet ->
|
||||
val campaignId = queryParams[DeeplinkConst.CAMPAIGN_ID_KEY].orEmpty()
|
||||
val userWalletId = userWallet.walletId
|
||||
|
||||
campaignsService.show(campaignId = campaignId, userWalletId = userWalletId)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
TangemLogger.i("Campaigns feature is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : CampaignsDeepLinkHandler.Factory {
|
||||
override fun create(queryParams: Map<String, String>): DefaultCampaignsDeepLinkHandler
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.di
|
||||
|
||||
import com.tangem.core.decompose.di.ModelComponent
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.promobanners.api.deeplink.CampaignsDeepLinkHandler
|
||||
import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent
|
||||
import com.tangem.features.promobanners.impl.campaigns.component.DefaultCampaignsComponent
|
||||
import com.tangem.features.promobanners.impl.campaigns.deeplink.DefaultCampaignsDeepLinkHandler
|
||||
import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel
|
||||
import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel
|
||||
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
|
||||
import com.tangem.features.promobanners.impl.campaigns.service.DefaultCampaignsService
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
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 CampaignsModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindCampaignsComponentFactory(factory: DefaultCampaignsComponent.Factory): CampaignsComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindCampaignsDeepLinkHandlerFactory(
|
||||
factory: DefaultCampaignsDeepLinkHandler.Factory,
|
||||
): CampaignsDeepLinkHandler.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindCampaignsService(service: DefaultCampaignsService): CampaignsService
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(ModelComponent::class)
|
||||
internal interface CampaignsModelModule {
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(CampaignsModel::class)
|
||||
fun bindCampaignModel(model: CampaignsModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(ActivateCampaignsModel::class)
|
||||
fun bindCampaignActivateModel(model: ActivateCampaignsModel): Model
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.entity
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
/**
|
||||
* State of the campaign activation bottom sheet.
|
||||
*
|
||||
* The intro promo screen (image + title + description) is always shown. When [selectedToken] is `null`
|
||||
* the footer shows "Select token"; once a token is chosen it shows the account block, the terms agreement
|
||||
* and the "Enroll" button. When [isChoosingToken] is `true` the token selector is shown on top of the
|
||||
* intro (as a stacked bottom sheet), not instead of it.
|
||||
*
|
||||
* [selectedAccount] is shown above the token only in accounts (multi-account) mode — it names the account
|
||||
* the asset was picked from. It is `null` in single-account mode or before a token is chosen.
|
||||
*/
|
||||
|
||||
@Immutable
|
||||
internal data class ActivateCampaignUM(
|
||||
val logo: TangemIconUM,
|
||||
val title: TextReference,
|
||||
val description: TextReference,
|
||||
val selectedToken: TokenItemState?,
|
||||
val selectedAccount: SelectedAccountUM?,
|
||||
val isChoosingToken: Boolean,
|
||||
val footerUM: FooterUM,
|
||||
val onChooseTokenDismiss: () -> Unit,
|
||||
val onLearnMoreClick: () -> Unit,
|
||||
val onChooseTokenClick: () -> Unit,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
internal data class FooterUM(
|
||||
val label: TextReference,
|
||||
val onPrimaryButtonClick: () -> Unit,
|
||||
val terms: TermsUM? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class TermsUM(
|
||||
val text: TextReference,
|
||||
val linkText: TextReference,
|
||||
val onTermsClick: () -> Unit,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
internal data class SelectedAccountUM(
|
||||
val iconState: CurrencyIconState,
|
||||
val name: TextReference,
|
||||
)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.entity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal sealed class CampaignType {
|
||||
|
||||
abstract val campaignId: String
|
||||
|
||||
@Serializable
|
||||
data class ReactivationCashback(override val campaignId: String) : CampaignType()
|
||||
|
||||
@Serializable
|
||||
data class WhaleSwapCashback(override val campaignId: String) : CampaignType()
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.entity
|
||||
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.features.promobanners.impl.R
|
||||
import com.tangem.features.promobanners.impl.campaigns.model.CampaignContent
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class CampaignTypeToContentConverter : Converter<CampaignType, CampaignContent> {
|
||||
|
||||
override fun convert(value: CampaignType): CampaignContent = when (value) {
|
||||
is CampaignType.ReactivationCashback -> CampaignContent(
|
||||
name = "Summer Swap Cashback",
|
||||
logo = TangemIconUM.Url(
|
||||
url = "https://s3.dualstack.eu-central-1.amazonaws.com/tangem.api/stories/Reactivation_Cashback.webp",
|
||||
fallbackRes = R.drawable.ic_alert_24,
|
||||
),
|
||||
description = resourceReference(R.string.promo_campaign_reactivation_summary_description),
|
||||
termsUrl = "https://tangem.com/docs/en/summer-swap-cashback-terms.pdf",
|
||||
learnMoreUrl = "https://tangem.com/en/blog/post/summer-swap",
|
||||
)
|
||||
is CampaignType.WhaleSwapCashback -> CampaignContent(
|
||||
name = "Whale Swap Cashback",
|
||||
logo = TangemIconUM.Url(
|
||||
url = "https://s3.dualstack.eu-central-1.amazonaws.com/tangem.api/stories/Whale_Swap_Cashback.webp",
|
||||
fallbackRes = R.drawable.ic_alert_24,
|
||||
),
|
||||
description = resourceReference(R.string.promo_campaign_whale_swap_summary_description),
|
||||
termsUrl = "https://tangem.com/docs/en/whale-swap-cashback-terms.pdf",
|
||||
learnMoreUrl = "https://tangem.com/en/blog/post/whale-swap",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun CampaignType.toPromoCampaignId(): PromoCampaignId = when (this) {
|
||||
is CampaignType.ReactivationCashback -> PromoCampaignId.ReactivationCashback
|
||||
is CampaignType.WhaleSwapCashback -> PromoCampaignId.WhaleSwapCashback
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.entity
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal sealed class CampaignsBottomSheetConfig {
|
||||
|
||||
@Serializable
|
||||
data object NotActive : CampaignsBottomSheetConfig()
|
||||
|
||||
@Serializable
|
||||
data class Enrolled(
|
||||
val campaignType: CampaignType,
|
||||
) : CampaignsBottomSheetConfig()
|
||||
|
||||
@Serializable
|
||||
data class Activate(
|
||||
val campaignType: CampaignType,
|
||||
val userWalletId: UserWalletId,
|
||||
) : CampaignsBottomSheetConfig()
|
||||
|
||||
@Serializable
|
||||
data class AlreadyActivated(
|
||||
val campaignType: CampaignType,
|
||||
) : CampaignsBottomSheetConfig()
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconItemStateConverter
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.tokens.TokenItemStateConverter
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.di.GlobalUiMessageSender
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.message.ToastMessage
|
||||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.models.EnrollResult
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.PromoCampaignState
|
||||
import com.tangem.domain.promo.models.TokenReward
|
||||
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
|
||||
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooserBlock
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd
|
||||
import com.tangem.features.promobanners.impl.R
|
||||
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
|
||||
import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignTypeToContentConverter
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.toPromoCampaignId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@ModelScoped
|
||||
internal class ActivateCampaignsModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
|
||||
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
|
||||
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
|
||||
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase,
|
||||
private val urlOpener: UrlOpener,
|
||||
@GlobalUiMessageSender private val messageSender: UiMessageSender,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase,
|
||||
private val predefinedTokenResolver: PredefinedTokenResolver,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<ActivateCampaignBottomSheetComponent.Params>()
|
||||
private val campaignType = params.campaignType
|
||||
private val accountIconConverter = AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall)
|
||||
private var appCurrency: AppCurrency = AppCurrency.Default
|
||||
private val campaignId: PromoCampaignId = params.campaignType.toPromoCampaignId()
|
||||
private val campaignContent = CampaignTypeToContentConverter().convert(campaignType)
|
||||
|
||||
private val predefinedTokensFlow = MutableStateFlow<List<PredefinedTokenToAdd>>(emptyList())
|
||||
|
||||
val uiState: StateFlow<ActivateCampaignUM>
|
||||
field = MutableStateFlow(buildInitialModel())
|
||||
|
||||
val bridge: ChooseTokenBridge = chooseTokenBridgeFactory.create(
|
||||
modelScope = modelScope,
|
||||
settings = ChooseTokenBridge.Settings(
|
||||
title = resourceReference(R.string.common_choose_token),
|
||||
chooserBlock = ChooserBlock.Predefined(predefinedTokensFlow),
|
||||
isShowPaymentAccount = false,
|
||||
isShowSingleCurrencyWallets = true,
|
||||
),
|
||||
)
|
||||
|
||||
init {
|
||||
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType))
|
||||
|
||||
getSelectedAppCurrencyUseCase.invokeOrDefault()
|
||||
.onEach { appCurrency = it }
|
||||
.launchIn(modelScope)
|
||||
|
||||
bridge.onCurrencyChosen.receiveAsFlow()
|
||||
.onEach { result -> onTokenChosen(result) }
|
||||
.launchIn(modelScope)
|
||||
|
||||
bridge.onClose.receiveAsFlow()
|
||||
.onEach { onChooseTokenDismiss() }
|
||||
.launchIn(modelScope)
|
||||
|
||||
modelScope.launch { loadPredefinedTokens() }
|
||||
}
|
||||
|
||||
private suspend fun loadPredefinedTokens() {
|
||||
getPromoCampaignStateUseCase(campaignId, params.userWalletId)
|
||||
.onLeft { error -> TangemLogger.e("Error loading campaign ${campaignType.campaignId} state", error) }
|
||||
.onRight { state ->
|
||||
if (state is PromoCampaignState.Available) {
|
||||
predefinedTokensFlow.value = predefinedTokenResolver.resolve(state.payoutTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildInitialModel(): ActivateCampaignUM = ActivateCampaignUM(
|
||||
logo = campaignContent.logo,
|
||||
title = resourceReference(
|
||||
R.string.promo_campaign_summary_title,
|
||||
wrappedList(campaignContent.name),
|
||||
),
|
||||
description = campaignContent.description,
|
||||
selectedToken = null,
|
||||
selectedAccount = null,
|
||||
isChoosingToken = false,
|
||||
footerUM = FooterUM(
|
||||
label = resourceReference(R.string.promo_campaign_select_token),
|
||||
onPrimaryButtonClick = ::onChooseTokenClick,
|
||||
),
|
||||
onChooseTokenDismiss = ::onChooseTokenDismiss,
|
||||
onLearnMoreClick = ::onLearnMoreClick,
|
||||
onChooseTokenClick = ::onChooseTokenClick,
|
||||
)
|
||||
|
||||
private fun onChooseTokenClick() {
|
||||
uiState.update { it.copy(isChoosingToken = true) }
|
||||
}
|
||||
|
||||
private fun onChooseTokenDismiss() {
|
||||
uiState.update { it.copy(isChoosingToken = false) }
|
||||
}
|
||||
|
||||
private fun onEnrollClick(selectedWalletId: UserWalletId, selectedToken: CryptoCurrency.Token) {
|
||||
analyticsEventHandler.send(
|
||||
PromoCampaignsAnalyticsEvent.EnrollButtonClicked(
|
||||
campaignType = campaignType,
|
||||
token = selectedToken.symbol,
|
||||
blockchain = selectedToken.network.name,
|
||||
),
|
||||
)
|
||||
|
||||
modelScope.launch {
|
||||
enrollPromoCampaignUseCase.invoke(
|
||||
campaign = campaignId,
|
||||
tokenReward = TokenReward(
|
||||
tokenAddress = selectedToken.contractAddress,
|
||||
networkId = selectedToken.network.rawId,
|
||||
),
|
||||
walletIds = listOf(selectedWalletId),
|
||||
).onLeft { error ->
|
||||
TangemLogger.e("Error enrolling campaign ${campaignType.campaignId}", error)
|
||||
messageSender.send(ToastMessage(message = resourceReference(R.string.common_unknown_error)))
|
||||
}.onRight {
|
||||
handleEnrollResponse(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEnrollResponse(enrollResult: EnrollResult) {
|
||||
when (enrollResult) {
|
||||
is EnrollResult.AlreadyEnrolled -> params.modelCallbacks.onAlreadyActivated(campaignType)
|
||||
is EnrollResult.Success -> params.modelCallbacks.onActivated(campaignType)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onTermsClick() {
|
||||
urlOpener.openUrl(campaignContent.termsUrl)
|
||||
}
|
||||
|
||||
private fun onLearnMoreClick() {
|
||||
urlOpener.openUrl(campaignContent.learnMoreUrl)
|
||||
}
|
||||
|
||||
private fun onTokenChosen(result: ChooseTokenResult) {
|
||||
val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return
|
||||
|
||||
modelScope.launch {
|
||||
val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) {
|
||||
when (val account = result.account.account) {
|
||||
is Account.CryptoPortfolio -> SelectedAccountUM(
|
||||
iconState = accountIconConverter.convert(account),
|
||||
name = account.accountName.toUM().value,
|
||||
)
|
||||
is Account.Payment -> SelectedAccountUM(
|
||||
iconState = CurrencyIconState.PaymentAccount(size = AccountIconSize.ExtraSmall),
|
||||
name = account.accountName.toUM().value,
|
||||
)
|
||||
is Account.Virtual -> null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency)
|
||||
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
isChoosingToken = false,
|
||||
selectedToken = tokenItem,
|
||||
selectedAccount = selectedAccountUM,
|
||||
footerUM = FooterUM(
|
||||
label = resourceReference(R.string.promo_campaign_enroll),
|
||||
onPrimaryButtonClick = {
|
||||
onEnrollClick(
|
||||
selectedWalletId = result.walletId,
|
||||
selectedToken = selectedToken,
|
||||
)
|
||||
},
|
||||
terms = TermsUM(
|
||||
text = resourceReference(R.string.promo_campaign_terms_agreement_android),
|
||||
linkText = resourceReference(
|
||||
R.string.promo_campaign_terms_link,
|
||||
wrappedList(campaignContent.name),
|
||||
),
|
||||
onTermsClick = ::onTermsClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
internal data class CampaignContent(
|
||||
val name: String,
|
||||
val logo: TangemIconUM,
|
||||
val description: TextReference,
|
||||
val termsUrl: String,
|
||||
val learnMoreUrl: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.di.GlobalUiMessageSender
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.message.SnackbarMessage
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.models.PromoCampaignState
|
||||
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
|
||||
import com.tangem.features.promobanners.impl.R
|
||||
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
|
||||
import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.toPromoCampaignId
|
||||
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class CampaignsModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val campaignIdConverter: CampaignIdConverter,
|
||||
campaignsService: CampaignsService,
|
||||
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase,
|
||||
@GlobalUiMessageSender private val messageSender: UiMessageSender,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
) : Model() {
|
||||
|
||||
val bottomSheetNavigation: SlotNavigation<CampaignsBottomSheetConfig> = SlotNavigation()
|
||||
|
||||
val footerExtraHeightState: StateFlow<Dp>
|
||||
field = MutableStateFlow(0.dp)
|
||||
|
||||
init {
|
||||
campaignsService.campaignFlow
|
||||
.onEach { request ->
|
||||
resolveStartNavigation(
|
||||
campaignType = campaignIdConverter.convert(request.campaignId),
|
||||
userWalletId = request.userWalletId,
|
||||
)
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun resolveStartNavigation(campaignType: CampaignType?, userWalletId: UserWalletId) {
|
||||
modelScope.launch {
|
||||
val config = if (campaignType == null) {
|
||||
CampaignsBottomSheetConfig.NotActive
|
||||
} else {
|
||||
checkCampaignState(campaignType, userWalletId)
|
||||
}
|
||||
|
||||
config?.let { bottomSheetNavigation.activate(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkCampaignState(
|
||||
campaignType: CampaignType,
|
||||
userWalletId: UserWalletId,
|
||||
): CampaignsBottomSheetConfig? = getPromoCampaignStateUseCase.invoke(
|
||||
campaign = campaignType.toPromoCampaignId(),
|
||||
userWalletId = userWalletId,
|
||||
).fold(
|
||||
ifLeft = { error ->
|
||||
TangemLogger.e("Error getting campaign ${campaignType.campaignId} state", error)
|
||||
messageSender.send(SnackbarMessage(message = resourceReference(R.string.common_unknown_error)))
|
||||
null
|
||||
},
|
||||
ifRight = { campaignState ->
|
||||
when (campaignState) {
|
||||
is PromoCampaignState.Enrolled,
|
||||
is PromoCampaignState.Available,
|
||||
-> CampaignsBottomSheetConfig.Activate(campaignType, userWalletId)
|
||||
is PromoCampaignState.NotActive -> CampaignsBottomSheetConfig.NotActive
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fun onFooterExtraHeightReady(height: Dp) {
|
||||
footerExtraHeightState.value = height
|
||||
}
|
||||
|
||||
fun onDismiss() {
|
||||
bottomSheetNavigation.dismiss()
|
||||
}
|
||||
|
||||
fun onActivated(campaignType: CampaignType) {
|
||||
footerExtraHeightState.value = 0.dp
|
||||
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.Enrolled(campaignType))
|
||||
}
|
||||
|
||||
fun onAlreadyActivated(campaignType: CampaignType) {
|
||||
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
|
||||
footerExtraHeightState.value = 0.dp
|
||||
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.AlreadyActivated(campaignType = campaignType))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
|
||||
import com.tangem.domain.markets.RawMarketToken
|
||||
import com.tangem.domain.markets.TokenMarketInfo
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.promo.models.PromoPayoutToken
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Maps backend promo payout tokens into [PredefinedTokenToAdd] for the token chooser.
|
||||
*
|
||||
* The backend payload already carries everything needed — `tokenId` (the market raw id), decimals,
|
||||
* symbol, name, contract address and network id — so no catalog lookup is required. The icon url is
|
||||
* derived from the raw id via the canonical host helper, matching what the add-to-portfolio flow uses.
|
||||
*/
|
||||
internal class PredefinedTokenResolver @Inject constructor() {
|
||||
|
||||
fun resolve(payoutTokens: List<PromoPayoutToken>): List<PredefinedTokenToAdd> = payoutTokens
|
||||
.map { payoutToken -> payoutToken.toPredefinedToken() }
|
||||
.distinctBy { it.token.id.value to it.network.networkId }
|
||||
|
||||
private fun PromoPayoutToken.toPredefinedToken(): PredefinedTokenToAdd {
|
||||
val rawId = CryptoCurrency.RawID(tokenId)
|
||||
return PredefinedTokenToAdd(
|
||||
token = RawMarketToken(
|
||||
id = rawId,
|
||||
name = tokenName,
|
||||
symbol = tokenSymbol,
|
||||
),
|
||||
network = TokenMarketInfo.Network(
|
||||
networkId = networkId,
|
||||
isExchangeable = false,
|
||||
contractAddress = tokenAddress,
|
||||
decimalCount = decimals,
|
||||
),
|
||||
iconUrl = getTokenIconUrlFromDefaultHost(rawId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.service
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* App-wide bus that decouples the promo-campaigns deeplink handler from the UI that shows the campaign
|
||||
* bottom sheet. A producer (deeplink handler) calls [show]; the always-alive campaign component listens
|
||||
* to [campaignFlow] and activates the appropriate sheet over the current screen.
|
||||
*/
|
||||
internal interface CampaignsService {
|
||||
|
||||
/** Emits the campaign requested via [show]. */
|
||||
val campaignFlow: Flow<CampaignRequest>
|
||||
|
||||
/** Requests showing the campaign identified by [campaignId] for the given [userWalletId]. */
|
||||
fun show(campaignId: String, userWalletId: UserWalletId)
|
||||
}
|
||||
|
||||
/** Payload of the campaigns bus: the campaign id and the wallet the campaign should be activated for. */
|
||||
internal data class CampaignRequest(
|
||||
val campaignId: String,
|
||||
val userWalletId: UserWalletId,
|
||||
)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.service
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
internal class DefaultCampaignsService @Inject constructor() : CampaignsService {
|
||||
|
||||
private val _campaignFlow: Channel<CampaignRequest> = Channel(Channel.BUFFERED)
|
||||
override val campaignFlow: Flow<CampaignRequest> = _campaignFlow.receiveAsFlow()
|
||||
|
||||
override fun show(campaignId: String, userWalletId: UserWalletId) {
|
||||
_campaignFlow.trySend(CampaignRequest(campaignId = campaignId, userWalletId = userWalletId))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.components.SpacerH24
|
||||
import com.tangem.core.ui.components.SpacerH32
|
||||
import com.tangem.core.ui.components.SpacerH8
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.promobanners.impl.R
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM
|
||||
|
||||
@Composable
|
||||
internal fun ActivateCampaignContent(um: ActivateCampaignUM, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TangemIcon(
|
||||
tangemIconUM = um.logo,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
|
||||
Text(
|
||||
text = um.title.resolveReference(),
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH8()
|
||||
|
||||
Text(
|
||||
text = um.description.resolveReference(),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH12()
|
||||
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.common_learn_more),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.clickable(onClick = um.onLearnMoreClick),
|
||||
)
|
||||
|
||||
SelectedTokenContent(
|
||||
selectedToken = um.selectedToken,
|
||||
selectedAccount = um.selectedAccount,
|
||||
onChooseTokenClick = um.onChooseTokenClick,
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedTokenContent(
|
||||
selectedToken: TokenItemState?,
|
||||
selectedAccount: SelectedAccountUM?,
|
||||
onChooseTokenClick: () -> Unit,
|
||||
) {
|
||||
if (selectedToken != null) {
|
||||
SpacerH24()
|
||||
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.promo_campaign_select_cashback_account),
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH12()
|
||||
|
||||
PromoCampaignTokenItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(TangemTheme.colors3.bg.tertiary)
|
||||
.clickable {
|
||||
onChooseTokenClick.invoke()
|
||||
},
|
||||
selectedToken = selectedToken,
|
||||
selectedAccount = selectedAccount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_ActivateCampaignContent_WithToken() {
|
||||
TangemThemePreviewRedesign {
|
||||
Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
|
||||
ActivateCampaignContent(um = CampaignPreviewData.activateCampaign)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_ActivateCampaignContent_NoToken() {
|
||||
TangemThemePreviewRedesign {
|
||||
Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
|
||||
ActivateCampaignContent(
|
||||
um = CampaignPreviewData.activateCampaign.copy(
|
||||
selectedToken = null,
|
||||
selectedAccount = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM
|
||||
|
||||
@Composable
|
||||
internal fun ActivateCampaignFooter(
|
||||
footerUM: FooterUM,
|
||||
onFooterTextHeightReady: (Dp) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
val terms = footerUM.terms
|
||||
|
||||
if (terms != null) {
|
||||
val density = LocalDensity.current
|
||||
|
||||
Text(
|
||||
text = termsAnnotatedString(terms),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged {
|
||||
val termsBlockHeight = with(density) { it.height.toDp() } + 12.dp
|
||||
onFooterTextHeightReady.invoke(termsBlockHeight)
|
||||
},
|
||||
)
|
||||
|
||||
SpacerH12()
|
||||
}
|
||||
|
||||
PrimaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = footerUM.label.resolveReference(),
|
||||
onClick = footerUM.onPrimaryButtonClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun termsAnnotatedString(terms: TermsUM) = buildAnnotatedString {
|
||||
val startText = terms.text.resolveReference()
|
||||
val linkText = terms.linkText.resolveReference()
|
||||
|
||||
append(startText)
|
||||
append(" ")
|
||||
withLink(
|
||||
link = LinkAnnotation.Clickable(
|
||||
tag = "CAMPAIGN_TERMS",
|
||||
linkInteractionListener = { terms.onTermsClick() },
|
||||
),
|
||||
) {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
textDecoration = TextDecoration.None,
|
||||
),
|
||||
) {
|
||||
append(linkText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_ActivateCampaignFooter_WithTerms() {
|
||||
TangemThemePreviewRedesign {
|
||||
ActivateCampaignFooter(
|
||||
footerUM = CampaignPreviewData.footer,
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(16.dp),
|
||||
onFooterTextHeightReady = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_ActivateCampaignFooter_NoTerms() {
|
||||
TangemThemePreviewRedesign {
|
||||
ActivateCampaignFooter(
|
||||
footerUM = CampaignPreviewData.footer.copy(terms = null),
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(16.dp),
|
||||
onFooterTextHeightReady = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.SpacerH32
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_info_24
|
||||
|
||||
@Composable
|
||||
internal fun AlreadyActivatedCampaignContent(message: TextReference, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape)
|
||||
.background(TangemTheme.colors3.bg.status.infoSubtle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = Icons.ic_info_24,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.status.info,
|
||||
)
|
||||
}
|
||||
|
||||
SpacerH32()
|
||||
|
||||
Text(
|
||||
text = message.resolveReference(),
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_AlreadyActivatedCampaignContent() {
|
||||
TangemThemePreviewRedesign {
|
||||
Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
|
||||
AlreadyActivatedCampaignContent(message = stringReference("You’re already enrolled in Whale Swap Cashback"))
|
||||
}
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.SpacerH32
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_success_24
|
||||
|
||||
@Composable
|
||||
fun CampaignEnrolledMessageContent(message: TextReference, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape)
|
||||
.background(TangemTheme.colors3.bg.status.successSubtle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = Icons.ic_success_24,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.status.success,
|
||||
)
|
||||
}
|
||||
|
||||
SpacerH32()
|
||||
|
||||
Text(
|
||||
text = message.resolveReference(),
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_CampaignEnrolledMessageContent() {
|
||||
TangemThemePreviewRedesign {
|
||||
Box(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) {
|
||||
CampaignEnrolledMessageContent(
|
||||
message = stringReference("You’re successfully enrolled in Enroll in Whale Swap Cashback"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.features.promobanners.impl.R
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM
|
||||
|
||||
/**
|
||||
* Shared preview fixtures for the campaign UI `@Preview`s. Not used in production code.
|
||||
*/
|
||||
internal object CampaignPreviewData {
|
||||
|
||||
val tokenItem: TokenItemState.Content = TokenItemState.Content(
|
||||
id = "preview-token",
|
||||
iconState = CurrencyIconState.CoinIcon(
|
||||
url = null,
|
||||
fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22,
|
||||
isGrayscale = false,
|
||||
shouldShowCustomBadge = false,
|
||||
),
|
||||
titleState = TokenItemState.TitleState.Content(text = stringReference("Polygon")),
|
||||
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("MATIC")),
|
||||
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $"),
|
||||
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "5,412 MATIC"),
|
||||
onItemClick = {},
|
||||
onItemLongClick = {},
|
||||
)
|
||||
|
||||
val selectedAccount: SelectedAccountUM = SelectedAccountUM(
|
||||
iconState = CurrencyIconState.CryptoPortfolio.Icon(
|
||||
resId = com.tangem.core.ui.R.drawable.ic_rounded_star_24,
|
||||
color = Color(color = 0xFF0099FF),
|
||||
isGrayscale = false,
|
||||
),
|
||||
name = stringReference("Main account"),
|
||||
)
|
||||
|
||||
val footer: FooterUM = FooterUM(
|
||||
label = stringReference("Enroll"),
|
||||
onPrimaryButtonClick = {},
|
||||
terms = TermsUM(
|
||||
text = stringReference("By enrolling you agree to the"),
|
||||
linkText = stringReference("Terms & Conditions"),
|
||||
onTermsClick = {},
|
||||
),
|
||||
)
|
||||
|
||||
val activateCampaign: ActivateCampaignUM = ActivateCampaignUM(
|
||||
logo = TangemIconUM.Icon(R.drawable.ic_alert_24),
|
||||
title = stringReference("Whale Swap Cashback"),
|
||||
description = stringReference(
|
||||
"Get cashback on every swap. Pick a token and the account where your rewards will be paid out.",
|
||||
),
|
||||
selectedToken = tokenItem,
|
||||
selectedAccount = selectedAccount,
|
||||
isChoosingToken = false,
|
||||
footerUM = footer,
|
||||
onChooseTokenDismiss = {},
|
||||
onLearnMoreClick = {},
|
||||
onChooseTokenClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.features.promobanners.impl.R
|
||||
import com.tangem.core.ui.components.SpacerH32
|
||||
import com.tangem.core.ui.components.SpacerH8
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_warning_24
|
||||
|
||||
@Composable
|
||||
fun NotActiveCampaignMessageContent(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape)
|
||||
.background(TangemTheme.colors3.bg.status.warningSubtle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = Icons.ic_warning_24,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.status.warning,
|
||||
)
|
||||
}
|
||||
|
||||
SpacerH32()
|
||||
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.promo_campaign_not_active_title),
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH8()
|
||||
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.promo_campaign_not_active_subtitle),
|
||||
style = TangemTheme.typography3.subheading.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.SpacerH8
|
||||
import com.tangem.core.ui.components.SpacerW4
|
||||
import com.tangem.core.ui.components.account.AccountCharIcon
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.components.account.AccountResIcon
|
||||
import com.tangem.core.ui.components.account.PaymentAccountIcon
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.token.TokenItem
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.SelectedAccountUM
|
||||
|
||||
@Composable
|
||||
internal fun PromoCampaignTokenItem(
|
||||
selectedToken: TokenItemState,
|
||||
selectedAccount: SelectedAccountUM?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (selectedAccount != null) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (val icon = selectedAccount.iconState) {
|
||||
is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(size = icon.size)
|
||||
is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon(
|
||||
resId = icon.resId,
|
||||
color = icon.color,
|
||||
size = AccountIconSize.ExtraSmall,
|
||||
)
|
||||
is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon(
|
||||
char = icon.char.resolveReference().first(),
|
||||
color = icon.color,
|
||||
size = AccountIconSize.ExtraSmall,
|
||||
)
|
||||
is CurrencyIconState.CoinIcon,
|
||||
is CurrencyIconState.CustomTokenIcon,
|
||||
is CurrencyIconState.Empty,
|
||||
is CurrencyIconState.FiatIcon,
|
||||
CurrencyIconState.Loading,
|
||||
CurrencyIconState.Locked,
|
||||
is CurrencyIconState.TokenIcon,
|
||||
-> Unit
|
||||
}
|
||||
|
||||
SpacerW4()
|
||||
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 2.dp)
|
||||
.alignByBaseline(),
|
||||
text = selectedAccount.name.resolveReference(),
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.caption1,
|
||||
)
|
||||
}
|
||||
|
||||
SpacerH8()
|
||||
}
|
||||
|
||||
TokenItem(
|
||||
state = selectedToken,
|
||||
isBalanceHidden = false,
|
||||
itemPaddingValues = PaddingValues(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_PromoCampaignTokenItem_WithAccount() {
|
||||
TangemThemePreviewRedesign {
|
||||
PromoCampaignTokenItem(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(TangemTheme.colors.background.primary),
|
||||
selectedToken = CampaignPreviewData.tokenItem,
|
||||
selectedAccount = CampaignPreviewData.selectedAccount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_PromoCampaignTokenItem_NoAccount() {
|
||||
TangemThemePreviewRedesign {
|
||||
PromoCampaignTokenItem(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(TangemTheme.colors.background.primary),
|
||||
selectedToken = CampaignPreviewData.tokenItem,
|
||||
selectedAccount = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -5,10 +5,12 @@ 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.PromoBannersBlockComponent
|
||||
import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles
|
||||
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.DefaultPromoBannersFeatureToggles
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
|
|
@ -29,6 +31,10 @@ internal interface PromoBannersFeatureModule {
|
|||
factory: DefaultPromoBannersBlockComponent.Factory,
|
||||
): PromoBannersBlockComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindPromoBannersFeatureToggles(impl: DefaultPromoBannersFeatureToggles): PromoBannersFeatureToggles
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.features.promobanners.impl.toggles
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles
|
||||
import javax.inject.Inject
|
||||
|
||||
class DefaultPromoBannersFeatureToggles @Inject constructor(
|
||||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
) : PromoBannersFeatureToggles {
|
||||
|
||||
override val isCampaignsToggleEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
FeatureToggles.TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED,
|
||||
)
|
||||
}
|
||||
|
|
@ -71,10 +71,13 @@ private fun bannerContainerColor(placeholder: Placeholder): Color = if (LocalRed
|
|||
when (placeholder) {
|
||||
Placeholder.MAIN -> TangemTheme.colors2.surface.level1
|
||||
Placeholder.FEED -> TangemTheme.colors2.surface.level3
|
||||
Placeholder.PAYMENT_ACCOUNT_MAIN -> TangemTheme.colors3.bg.opaque.primary
|
||||
}
|
||||
} else {
|
||||
when (placeholder) {
|
||||
Placeholder.MAIN -> TangemTheme.colors.background.primary
|
||||
Placeholder.MAIN,
|
||||
Placeholder.PAYMENT_ACCOUNT_MAIN,
|
||||
-> TangemTheme.colors.background.primary
|
||||
Placeholder.FEED -> TangemTheme.colors.background.action
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.analytics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class PromoCampaignsAnalyticsEventTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN any event WHEN created THEN category is Promotion`() {
|
||||
// Arrange
|
||||
val events = listOf(
|
||||
PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType = whaleSwap),
|
||||
PromoCampaignsAnalyticsEvent.EnrollButtonClicked(
|
||||
campaignType = whaleSwap,
|
||||
token = "USDT",
|
||||
blockchain = "Ethereum",
|
||||
),
|
||||
PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened(),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(events.map { it.category }).containsExactly("Promotion", "Promotion", "Promotion")
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideCampaignTypes")
|
||||
fun `GIVEN campaign type WHEN PromotionScreenOpened THEN event name and Screen param are correct`(
|
||||
model: CampaignNameModel,
|
||||
) {
|
||||
// Act
|
||||
val event = PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType = model.campaignType)
|
||||
|
||||
// Assert
|
||||
assertThat(event.event).isEqualTo("Promotion Screen Opened")
|
||||
assertThat(event.params).containsExactly("Screen", model.expectedAnalyticsName)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideCampaignTypes")
|
||||
fun `GIVEN campaign type WHEN EnrollButtonClicked THEN event name and params are correct`(
|
||||
model: CampaignNameModel,
|
||||
) {
|
||||
// Act
|
||||
val event = PromoCampaignsAnalyticsEvent.EnrollButtonClicked(
|
||||
campaignType = model.campaignType,
|
||||
token = "USDT",
|
||||
blockchain = "Ethereum",
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(event.event).isEqualTo("Enroll Button Clicked")
|
||||
assertThat(event.params).containsExactly(
|
||||
"Campaign", model.expectedAnalyticsName,
|
||||
"Token", "USDT",
|
||||
"Blockchain", "Ethereum",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN AlreadyEnrolledScreenOpened WHEN created THEN event name is correct and no params`() {
|
||||
// Act
|
||||
val event = PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened()
|
||||
|
||||
// Assert
|
||||
assertThat(event.event).isEqualTo("Already Enrolled Screen Opened")
|
||||
assertThat(event.params).isEmpty()
|
||||
}
|
||||
|
||||
private fun provideCampaignTypes() = listOf(
|
||||
CampaignNameModel(campaignType = whaleSwap, expectedAnalyticsName = "Cashback"),
|
||||
CampaignNameModel(campaignType = reactivation, expectedAnalyticsName = "Reactivation"),
|
||||
)
|
||||
|
||||
internal data class CampaignNameModel(
|
||||
val campaignType: CampaignType,
|
||||
val expectedAnalyticsName: String,
|
||||
) {
|
||||
override fun toString(): String = "${campaignType::class.simpleName} -> $expectedAnalyticsName"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val whaleSwap = CampaignType.WhaleSwapCashback(campaignId = "whale")
|
||||
val reactivation = CampaignType.ReactivationCashback(campaignId = "reactivation")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.converters
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class CampaignIdConverterTest {
|
||||
|
||||
private val converter = CampaignIdConverter()
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideConvertModels")
|
||||
fun `GIVEN campaign id WHEN convert THEN correct campaign type is returned`(model: ConvertModel) {
|
||||
// Act
|
||||
val actual = converter.convert(model.id)
|
||||
|
||||
// Assert
|
||||
assertThat(actual).isEqualTo(model.expected)
|
||||
}
|
||||
|
||||
private fun provideConvertModels() = listOf(
|
||||
ConvertModel(id = "1", expected = CampaignType.WhaleSwapCashback(campaignId = "1")),
|
||||
ConvertModel(id = "2", expected = CampaignType.ReactivationCashback(campaignId = "2")),
|
||||
ConvertModel(id = "0", expected = null),
|
||||
ConvertModel(id = "unknown", expected = null),
|
||||
ConvertModel(id = "", expected = null),
|
||||
)
|
||||
|
||||
internal data class ConvertModel(
|
||||
val id: String,
|
||||
val expected: CampaignType?,
|
||||
) {
|
||||
override fun toString(): String = "\"$id\" -> ${expected?.let { it::class.simpleName } ?: "null"}"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import arrow.core.Either
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.models.EnrollResult
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.TokenReward
|
||||
import com.tangem.domain.promo.usecase.EnrollPromoCampaignUseCase
|
||||
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
||||
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
|
||||
import com.tangem.features.promobanners.impl.campaigns.component.ActivateCampaignBottomSheetComponent
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class ActivateCampaignsModelTest {
|
||||
|
||||
private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
|
||||
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
|
||||
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk()
|
||||
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase = mockk()
|
||||
private val urlOpener: UrlOpener = mockk(relaxed = true)
|
||||
private val messageSender: UiMessageSender = mockk(relaxed = true)
|
||||
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk()
|
||||
private val predefinedTokenResolver: PredefinedTokenResolver = mockk(relaxed = true)
|
||||
private val modelCallbacks: ActivateCampaignBottomSheetComponent.ActivateCampaignModelCallbacks =
|
||||
mockk(relaxed = true)
|
||||
|
||||
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
|
||||
|
||||
private lateinit var onCurrencyChosen: Channel<ChooseTokenResult>
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(
|
||||
getSelectedAppCurrencyUseCase,
|
||||
isAccountsModeEnabledUseCase,
|
||||
enrollPromoCampaignUseCase,
|
||||
messageSender,
|
||||
analyticsEventHandler,
|
||||
modelCallbacks,
|
||||
)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideCampaignTypes")
|
||||
fun `GIVEN campaign type WHEN model created THEN PromotionScreenOpened is sent`(campaignType: CampaignType) =
|
||||
runTest {
|
||||
// Act
|
||||
val model = createModel(campaignType)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) {
|
||||
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.PromotionScreenOpened(campaignType))
|
||||
}
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN choose token clicked and dismissed THEN isChoosingToken toggles`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1"))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act & Assert
|
||||
model.uiState.value.onChooseTokenClick()
|
||||
assertThat(model.uiState.value.isChoosingToken).isTrue()
|
||||
|
||||
model.uiState.value.onChooseTokenDismiss()
|
||||
assertThat(model.uiState.value.isChoosingToken).isFalse()
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a coin is chosen WHEN currency chosen THEN it is ignored`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1"))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
onCurrencyChosen.send(chooseTokenResult(currency = coin()))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.selectedToken).isNull()
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a token chosen and enroll succeeds WHEN enroll clicked THEN event carries symbol and blockchain`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val token = token()
|
||||
val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1")
|
||||
coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns
|
||||
Either.Right(EnrollResult.Success(TokenReward(tokenAddress = "a", networkId = "b")))
|
||||
val model = createModel(campaignType)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
onCurrencyChosen.send(chooseTokenResult(currency = token))
|
||||
advanceUntilIdle()
|
||||
model.uiState.value.footerUM.onPrimaryButtonClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — Token param is the symbol (TTK), Blockchain param is the network name (Ethereum)
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analyticsEventHandler.send(capture(events)) }
|
||||
val enrollEvent = events.filterIsInstance<PromoCampaignsAnalyticsEvent.EnrollButtonClicked>().single()
|
||||
assertThat(enrollEvent.event).isEqualTo("Enroll Button Clicked")
|
||||
assertThat(enrollEvent.params).containsExactly(
|
||||
"Campaign", "Cashback",
|
||||
"Token", "TTK",
|
||||
"Blockchain", "Ethereum",
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
enrollPromoCampaignUseCase.invoke(
|
||||
campaign = PromoCampaignId.WhaleSwapCashback,
|
||||
tokenReward = TokenReward(tokenAddress = token.contractAddress, networkId = token.network.rawId),
|
||||
walletIds = listOf(userWalletId),
|
||||
)
|
||||
}
|
||||
verify(exactly = 1) { modelCallbacks.onActivated(campaignType) }
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN enroll returns AlreadyEnrolled WHEN enroll clicked THEN onAlreadyActivated called`() = runTest {
|
||||
// Arrange
|
||||
val campaignType = CampaignType.WhaleSwapCashback(campaignId = "1")
|
||||
coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns
|
||||
Either.Right(EnrollResult.AlreadyEnrolled(TokenReward(tokenAddress = "a", networkId = "b")))
|
||||
val model = createModel(campaignType)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
onCurrencyChosen.send(chooseTokenResult(currency = token()))
|
||||
advanceUntilIdle()
|
||||
model.uiState.value.footerUM.onPrimaryButtonClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { modelCallbacks.onAlreadyActivated(campaignType) }
|
||||
verify(exactly = 0) { modelCallbacks.onActivated(any()) }
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN enroll fails WHEN enroll clicked THEN error message is sent and no callback`() = runTest {
|
||||
// Arrange
|
||||
coEvery { enrollPromoCampaignUseCase.invoke(any(), any(), any()) } returns
|
||||
Either.Left(RuntimeException("network"))
|
||||
val model = createModel(CampaignType.WhaleSwapCashback(campaignId = "1"))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
onCurrencyChosen.send(chooseTokenResult(currency = token()))
|
||||
advanceUntilIdle()
|
||||
model.uiState.value.footerUM.onPrimaryButtonClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { messageSender.send(any()) }
|
||||
verify(exactly = 0) { modelCallbacks.onActivated(any()) }
|
||||
verify(exactly = 0) { modelCallbacks.onAlreadyActivated(any()) }
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
private fun chooseTokenResult(currency: CryptoCurrency): ChooseTokenResult {
|
||||
val status = CryptoCurrencyStatus(
|
||||
currency = currency,
|
||||
value = CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null),
|
||||
)
|
||||
val wallet: UserWallet = mockk {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
return ChooseTokenResult(currency = status, account = mockk(relaxed = true), wallet = wallet)
|
||||
}
|
||||
|
||||
private fun TestScope.createModel(campaignType: CampaignType): ActivateCampaignsModel {
|
||||
onCurrencyChosen = Channel(capacity = Channel.UNLIMITED)
|
||||
val bridge = mockk<ChooseTokenBridge>(relaxed = true) {
|
||||
every { onCurrencyChosen } returns this@ActivateCampaignsModelTest.onCurrencyChosen
|
||||
every { onClose } returns Channel()
|
||||
}
|
||||
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
|
||||
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default)
|
||||
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
|
||||
coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable())
|
||||
return ActivateCampaignsModel(
|
||||
paramsContainer = MutableParamsContainer(
|
||||
ActivateCampaignBottomSheetComponent.Params(
|
||||
campaignType = campaignType,
|
||||
userWalletId = userWalletId,
|
||||
modelCallbacks = modelCallbacks,
|
||||
),
|
||||
),
|
||||
dispatchers = createTestingCoroutineDispatcherProvider(),
|
||||
chooseTokenBridgeFactory = chooseTokenBridgeFactory,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
|
||||
enrollPromoCampaignUseCase = enrollPromoCampaignUseCase,
|
||||
urlOpener = urlOpener,
|
||||
messageSender = messageSender,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
getPromoCampaignStateUseCase = getPromoCampaignStateUseCase,
|
||||
predefinedTokenResolver = predefinedTokenResolver,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
main = testDispatcher,
|
||||
mainImmediate = testDispatcher,
|
||||
io = testDispatcher,
|
||||
default = testDispatcher,
|
||||
single = testDispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
// Override name/symbol so analytics assertions pin the symbol ("TTK"), not the name ("TEST_TOKEN").
|
||||
private fun token(): CryptoCurrency.Token = cryptoCurrencyFactory
|
||||
.createToken(blockchain = Blockchain.Ethereum, contractAddress = "0xToken")
|
||||
.copy(name = "TEST_TOKEN", symbol = "TTK")
|
||||
|
||||
private fun coin(): CryptoCurrency.Coin = cryptoCurrencyFactory.ethereum
|
||||
|
||||
private fun provideCampaignTypes() = listOf(
|
||||
CampaignType.WhaleSwapCashback(campaignId = "1"),
|
||||
CampaignType.ReactivationCashback(campaignId = "2"),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val userWalletId = UserWalletId("0011223344556677")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import arrow.core.Either
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.PromoCampaignState
|
||||
import com.tangem.domain.promo.usecase.GetPromoCampaignStateUseCase
|
||||
import com.tangem.features.promobanners.impl.campaigns.analytics.PromoCampaignsAnalyticsEvent
|
||||
import com.tangem.features.promobanners.impl.campaigns.converters.CampaignIdConverter
|
||||
import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType
|
||||
import com.tangem.features.promobanners.impl.campaigns.service.CampaignRequest
|
||||
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.Called
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class CampaignsModelTest {
|
||||
|
||||
private val getPromoCampaignStateUseCase: GetPromoCampaignStateUseCase = mockk()
|
||||
private val messageSender: UiMessageSender = mockk(relaxed = true)
|
||||
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
|
||||
// Real converter: "1" -> WhaleSwapCashback, "2" -> ReactivationCashback, else null.
|
||||
private val campaignIdConverter = CampaignIdConverter()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(getPromoCampaignStateUseCase, messageSender, analyticsEventHandler)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideKnownCampaignModels")
|
||||
fun `GIVEN known campaign request WHEN emitted THEN campaign state is checked with mapped id`(
|
||||
model: KnownCampaignModel,
|
||||
) = runTest {
|
||||
// Arrange
|
||||
coEvery { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } returns
|
||||
Either.Right(PromoCampaignState.NotActive(model.expectedPromoId))
|
||||
val campaignsModel = createModel(
|
||||
campaignFlow = flowOf(CampaignRequest(campaignId = model.campaignId, userWalletId = userWalletId)),
|
||||
)
|
||||
|
||||
// Act
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) {
|
||||
getPromoCampaignStateUseCase.invoke(
|
||||
campaign = model.expectedPromoId,
|
||||
userWalletId = userWalletId,
|
||||
forceRefresh = any(),
|
||||
)
|
||||
}
|
||||
verify { messageSender wasNot Called }
|
||||
campaignsModel.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN campaign state fails WHEN emitted THEN error message is sent`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getPromoCampaignStateUseCase.invoke(any(), any(), any()) } returns
|
||||
Either.Left(RuntimeException("network"))
|
||||
val model = createModel(campaignFlow = flowOf(CampaignRequest(campaignId = "1", userWalletId = userWalletId)))
|
||||
|
||||
// Act
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { messageSender.send(any()) }
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unknown campaign id WHEN emitted THEN campaign state is not checked`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(campaignFlow = flowOf(CampaignRequest(campaignId = "unknown", userWalletId = userWalletId)))
|
||||
|
||||
// Act
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) { getPromoCampaignStateUseCase.invoke(any(), any(), any()) }
|
||||
verify { messageSender wasNot Called }
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN footer height set WHEN onAlreadyActivated THEN analytics sent and height reset`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(campaignFlow = emptyFlow())
|
||||
advanceUntilIdle()
|
||||
model.onFooterExtraHeightReady(100.dp)
|
||||
|
||||
// Act
|
||||
model.onAlreadyActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) {
|
||||
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
|
||||
}
|
||||
assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp)
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN footer height set WHEN onActivated THEN no analytics and height reset`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(campaignFlow = emptyFlow())
|
||||
advanceUntilIdle()
|
||||
model.onFooterExtraHeightReady(100.dp)
|
||||
|
||||
// Act
|
||||
model.onActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
|
||||
|
||||
// Assert
|
||||
verify { analyticsEventHandler wasNot Called }
|
||||
assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp)
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN onFooterExtraHeightReady THEN height state is updated`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(campaignFlow = emptyFlow())
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onFooterExtraHeightReady(42.dp)
|
||||
|
||||
// Assert
|
||||
assertThat(model.footerExtraHeightState.value).isEqualTo(42.dp)
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
private fun TestScope.createModel(campaignFlow: Flow<CampaignRequest>): CampaignsModel {
|
||||
val campaignsService: CampaignsService = mockk {
|
||||
every { this@mockk.campaignFlow } returns campaignFlow
|
||||
}
|
||||
return CampaignsModel(
|
||||
dispatchers = createTestingCoroutineDispatcherProvider(),
|
||||
campaignIdConverter = campaignIdConverter,
|
||||
campaignsService = campaignsService,
|
||||
getPromoCampaignStateUseCase = getPromoCampaignStateUseCase,
|
||||
messageSender = messageSender,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
main = testDispatcher,
|
||||
mainImmediate = testDispatcher,
|
||||
io = testDispatcher,
|
||||
default = testDispatcher,
|
||||
single = testDispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
private fun provideKnownCampaignModels() = listOf(
|
||||
KnownCampaignModel(campaignId = "1", expectedPromoId = PromoCampaignId.WhaleSwapCashback),
|
||||
KnownCampaignModel(campaignId = "2", expectedPromoId = PromoCampaignId.ReactivationCashback),
|
||||
)
|
||||
|
||||
internal data class KnownCampaignModel(
|
||||
val campaignId: String,
|
||||
val expectedPromoId: PromoCampaignId,
|
||||
) {
|
||||
override fun toString(): String = "\"$campaignId\" -> $expectedPromoId"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val userWalletId = UserWalletId("0011223344556677")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package com.tangem.features.promobanners.impl.campaigns.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
|
||||
import com.tangem.domain.markets.RawMarketToken
|
||||
import com.tangem.domain.markets.TokenMarketInfo
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.promo.models.PromoPayoutToken
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.PredefinedTokenToAdd
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class PredefinedTokenResolverTest {
|
||||
|
||||
private val resolver = PredefinedTokenResolver()
|
||||
|
||||
@Test
|
||||
fun `GIVEN payout token WHEN resolve THEN mapped from payload`() {
|
||||
// Arrange
|
||||
val payoutToken = createPayoutToken(
|
||||
tokenId = "cat-token",
|
||||
tokenAddress = "0xContract",
|
||||
tokenSymbol = "CAT",
|
||||
tokenName = "Cat Token",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
)
|
||||
|
||||
// Act
|
||||
val actual = resolver.resolve(listOf(payoutToken))
|
||||
|
||||
// Assert
|
||||
val rawId = CryptoCurrency.RawID("cat-token")
|
||||
val expected = PredefinedTokenToAdd(
|
||||
token = RawMarketToken(id = rawId, name = "Cat Token", symbol = "CAT"),
|
||||
network = TokenMarketInfo.Network(
|
||||
networkId = "ethereum",
|
||||
isExchangeable = false,
|
||||
contractAddress = "0xContract",
|
||||
decimalCount = 6,
|
||||
),
|
||||
iconUrl = getTokenIconUrlFromDefaultHost(rawId),
|
||||
)
|
||||
assertThat(actual).containsExactly(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple payout tokens WHEN resolve THEN input order preserved`() {
|
||||
// Arrange
|
||||
val first = createPayoutToken(tokenId = "first-token", tokenSymbol = "AAA", networkId = "ethereum")
|
||||
val second = createPayoutToken(tokenId = "second-token", tokenSymbol = "BBB", networkId = "polygon")
|
||||
|
||||
// Act
|
||||
val actual = resolver.resolve(listOf(first, second)).map { it.token.id.value }
|
||||
|
||||
// Assert
|
||||
assertThat(actual).containsExactly("first-token", "second-token").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two payouts with same id on same network WHEN resolve THEN duplicate removed`() {
|
||||
// Arrange
|
||||
val first = createPayoutToken(tokenId = "usd-coin", tokenAddress = "0xFirst", networkId = "ethereum")
|
||||
val second = createPayoutToken(tokenId = "usd-coin", tokenAddress = "0xSecond", networkId = "ethereum")
|
||||
|
||||
// Act
|
||||
val actual = resolver.resolve(listOf(first, second))
|
||||
|
||||
// Assert
|
||||
assertThat(actual).hasSize(1)
|
||||
assertThat(actual.single().network.contractAddress).isEqualTo("0xFirst")
|
||||
}
|
||||
|
||||
private fun createPayoutToken(
|
||||
tokenId: String = "cat-token",
|
||||
tokenAddress: String = "0xContract",
|
||||
tokenSymbol: String = "CAT",
|
||||
tokenName: String = "Cat Token",
|
||||
networkId: String = "ethereum",
|
||||
decimals: Int = 6,
|
||||
) = PromoPayoutToken(
|
||||
tokenId = tokenId,
|
||||
tokenAddress = tokenAddress,
|
||||
tokenSymbol = tokenSymbol,
|
||||
tokenName = tokenName,
|
||||
networkId = networkId,
|
||||
decimals = decimals,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue