Updated on 2026-08-14
This commit is contained in:
parent
18da968728
commit
aba2f43c34
11 changed files with 224 additions and 4 deletions
|
|
@ -68,6 +68,7 @@ dependencies {
|
||||||
implementation(projects.domain.notifications.models)
|
implementation(projects.domain.notifications.models)
|
||||||
implementation(projects.domain.account)
|
implementation(projects.domain.account)
|
||||||
implementation(projects.domain.account.status)
|
implementation(projects.domain.account.status)
|
||||||
|
implementation(projects.domain.marketing.models)
|
||||||
|
|
||||||
/** Common */
|
/** Common */
|
||||||
implementation(projects.common.ui)
|
implementation(projects.common.ui)
|
||||||
|
|
@ -80,6 +81,7 @@ dependencies {
|
||||||
implementation(projects.features.staking.api)
|
implementation(projects.features.staking.api)
|
||||||
implementation(projects.features.txhistory.api)
|
implementation(projects.features.txhistory.api)
|
||||||
implementation(projects.features.approval.api)
|
implementation(projects.features.approval.api)
|
||||||
|
implementation(projects.features.marketing.api)
|
||||||
|
|
||||||
/** Decompose */
|
/** Decompose */
|
||||||
implementation(deps.decompose.ext.compose)
|
implementation(deps.decompose.ext.compose)
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||||
import com.arkivanov.decompose.router.slot.childSlot
|
import com.arkivanov.decompose.router.slot.childSlot
|
||||||
import com.tangem.core.decompose.context.AppComponentContext
|
import com.tangem.core.decompose.context.AppComponentContext
|
||||||
|
import com.tangem.core.decompose.context.child
|
||||||
import com.tangem.core.decompose.context.childByContext
|
import com.tangem.core.decompose.context.childByContext
|
||||||
import com.tangem.core.decompose.model.getOrCreateModel
|
import com.tangem.core.decompose.model.getOrCreateModel
|
||||||
import com.tangem.features.approval.api.GiveApprovalComponent
|
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||||
import com.tangem.features.staking.api.StakingComponent
|
import com.tangem.features.staking.api.StakingComponent
|
||||||
import com.tangem.features.staking.impl.presentation.model.StakingModel
|
import com.tangem.features.staking.impl.presentation.model.StakingModel
|
||||||
import com.tangem.features.staking.impl.presentation.ui.StakingScreen
|
import com.tangem.features.staking.impl.presentation.ui.StakingScreen
|
||||||
|
|
@ -21,10 +23,19 @@ internal class DefaultStakingComponent @AssistedInject constructor(
|
||||||
@Assisted appComponentContext: AppComponentContext,
|
@Assisted appComponentContext: AppComponentContext,
|
||||||
@Assisted params: StakingComponent.Params,
|
@Assisted params: StakingComponent.Params,
|
||||||
private val giveApprovalComponentFactory: GiveApprovalComponent.Factory,
|
private val giveApprovalComponentFactory: GiveApprovalComponent.Factory,
|
||||||
|
private val marketingBannerComponentFactory: MarketingBannerComponent.Factory,
|
||||||
) : StakingComponent, AppComponentContext by appComponentContext {
|
) : StakingComponent, AppComponentContext by appComponentContext {
|
||||||
|
|
||||||
private val model: StakingModel = getOrCreateModel(params)
|
private val model: StakingModel = getOrCreateModel(params)
|
||||||
|
|
||||||
|
private val marketingBannerComponent: MarketingBannerComponent = marketingBannerComponentFactory.create(
|
||||||
|
context = child("stakingMarketingBanner"),
|
||||||
|
params = MarketingBannerComponent.Params.Standalone(
|
||||||
|
requestFlow = model.marketingRequest,
|
||||||
|
onDeeplinkClick = model::onMarketingBannerDeeplink,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private val approvalSlot = childSlot(
|
private val approvalSlot = childSlot(
|
||||||
key = "stakingApprovalSlot",
|
key = "stakingApprovalSlot",
|
||||||
source = model.approvalSlotNavigation,
|
source = model.approvalSlotNavigation,
|
||||||
|
|
@ -45,7 +56,7 @@ internal class DefaultStakingComponent @AssistedInject constructor(
|
||||||
val currentState by model.uiState.collectAsStateWithLifecycle()
|
val currentState by model.uiState.collectAsStateWithLifecycle()
|
||||||
val approvalSlotState by approvalSlot.subscribeAsState()
|
val approvalSlotState by approvalSlot.subscribeAsState()
|
||||||
|
|
||||||
StakingScreen(currentState)
|
StakingScreen(currentState, marketingBannerComponent)
|
||||||
|
|
||||||
approvalSlotState.child?.instance?.BottomSheet()
|
approvalSlotState.child?.instance?.BottomSheet()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import com.tangem.blockchain.common.transaction.Fee
|
||||||
import com.tangem.common.TangemBlogUrlBuilder
|
import com.tangem.common.TangemBlogUrlBuilder
|
||||||
import com.tangem.common.getValidatorsCount
|
import com.tangem.common.getValidatorsCount
|
||||||
import com.tangem.common.routing.AppRouter
|
import com.tangem.common.routing.AppRouter
|
||||||
|
import com.tangem.common.routing.deeplink.resolveMarketingDeeplink
|
||||||
|
import com.tangem.common.routing.deeplink.toContextualRoute
|
||||||
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData
|
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData
|
||||||
import com.tangem.common.ui.amountScreen.models.AmountState
|
import com.tangem.common.ui.amountScreen.models.AmountState
|
||||||
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
|
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
|
||||||
|
|
@ -42,6 +44,7 @@ import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
|
||||||
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
|
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
|
||||||
import com.tangem.domain.feedback.models.BlockchainErrorInfo
|
import com.tangem.domain.feedback.models.BlockchainErrorInfo
|
||||||
import com.tangem.domain.feedback.models.FeedbackEmailType
|
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||||
|
import com.tangem.domain.marketing.models.MarketingScreen
|
||||||
import com.tangem.domain.models.account.Account
|
import com.tangem.domain.models.account.Account
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
|
|
@ -67,6 +70,7 @@ import com.tangem.domain.transaction.usecase.SendTransactionUseCase
|
||||||
import com.tangem.domain.utils.convertToSdkAmount
|
import com.tangem.domain.utils.convertToSdkAmount
|
||||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||||
import com.tangem.features.approval.api.GiveApprovalComponent
|
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerRequest
|
||||||
import com.tangem.features.staking.api.StakingComponent
|
import com.tangem.features.staking.api.StakingComponent
|
||||||
import com.tangem.features.staking.impl.R
|
import com.tangem.features.staking.impl.R
|
||||||
import com.tangem.features.staking.impl.analytics.StakingParamsInterceptor
|
import com.tangem.features.staking.impl.analytics.StakingParamsInterceptor
|
||||||
|
|
@ -153,7 +157,7 @@ internal class StakingModel @Inject constructor(
|
||||||
private val innerRouter: InnerStakingRouter,
|
private val innerRouter: InnerStakingRouter,
|
||||||
private val messageSender: UiMessageSender,
|
private val messageSender: UiMessageSender,
|
||||||
private val stakingFeatureToggles: StakingFeatureToggles,
|
private val stakingFeatureToggles: StakingFeatureToggles,
|
||||||
appRouter: AppRouter,
|
private val appRouter: AppRouter,
|
||||||
) : Model(), StakingClickIntents {
|
) : Model(), StakingClickIntents {
|
||||||
|
|
||||||
val uiState: StateFlow<StakingUiState> = stateController.uiState
|
val uiState: StateFlow<StakingUiState> = stateController.uiState
|
||||||
|
|
@ -163,6 +167,15 @@ internal class StakingModel @Inject constructor(
|
||||||
|
|
||||||
private val params = paramsContainer.require<StakingComponent.Params>()
|
private val params = paramsContainer.require<StakingComponent.Params>()
|
||||||
|
|
||||||
|
val marketingRequest: Flow<MarketingBannerRequest?> = flowOf(
|
||||||
|
MarketingBannerRequest(
|
||||||
|
screen = MarketingScreen.Staking(
|
||||||
|
networkId = params.cryptoCurrency.network.rawId,
|
||||||
|
contractAddress = (params.cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private val stakingStateRouter: StakingStateRouter = StakingStateRouter(
|
private val stakingStateRouter: StakingStateRouter = StakingStateRouter(
|
||||||
appRouter = appRouter,
|
appRouter = appRouter,
|
||||||
stateController = stateController,
|
stateController = stateController,
|
||||||
|
|
@ -317,6 +330,16 @@ internal class StakingModel @Inject constructor(
|
||||||
stateController.initializeWithUserWallet(userWallet)
|
stateController.initializeWithUserWallet(userWallet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onMarketingBannerDeeplink(deeplink: String): Boolean {
|
||||||
|
val route = resolveMarketingDeeplink(deeplink).toContextualRoute(
|
||||||
|
userWalletId = params.userWalletId,
|
||||||
|
currency = params.cryptoCurrency,
|
||||||
|
screenSource = AnalyticsParam.ScreensSources.Staking,
|
||||||
|
) ?: return false
|
||||||
|
appRouter.push(route)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
paramsInterceptorHolder.removeParamsInterceptor(StakingParamsInterceptor.ID)
|
paramsInterceptorHolder.removeParamsInterceptor(StakingParamsInterceptor.ID)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
|
import com.tangem.common.ui.navigationButtons.NavigationButtonsState
|
||||||
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
|
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
|
||||||
import com.tangem.core.ui.components.SpacerH12
|
import com.tangem.core.ui.components.SpacerH12
|
||||||
|
|
@ -51,6 +52,7 @@ import com.tangem.core.ui.test.StakingDetailsScreenTestTags
|
||||||
import com.tangem.domain.models.staking.BalanceType
|
import com.tangem.domain.models.staking.BalanceType
|
||||||
import com.tangem.domain.models.staking.RewardBlockType
|
import com.tangem.domain.models.staking.RewardBlockType
|
||||||
import com.tangem.domain.staking.model.common.RewardType
|
import com.tangem.domain.staking.model.common.RewardType
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||||
import com.tangem.features.staking.impl.R
|
import com.tangem.features.staking.impl.R
|
||||||
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
|
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
|
||||||
import com.tangem.features.staking.impl.presentation.state.BalanceState
|
import com.tangem.features.staking.impl.presentation.state.BalanceState
|
||||||
|
|
@ -67,6 +69,7 @@ private const val BANNER_BLOCK_KEY = "BannerBlock"
|
||||||
private const val STAKING_REWARD_BLOCK_KEY = "StakingRewardBlock"
|
private const val STAKING_REWARD_BLOCK_KEY = "StakingRewardBlock"
|
||||||
private const val ACTIVE_STAKING_BLOCK_KEY = "ActiveStakingBlock"
|
private const val ACTIVE_STAKING_BLOCK_KEY = "ActiveStakingBlock"
|
||||||
private const val STAKE_PRIMARY_BUTTON_KEY = "StakePrimaryButton"
|
private const val STAKE_PRIMARY_BUTTON_KEY = "StakePrimaryButton"
|
||||||
|
private const val MARKETING_BANNER_BLOCK_KEY = "MarketingBannerBlock"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun StakingInitialInfoContent(
|
internal fun StakingInitialInfoContent(
|
||||||
|
|
@ -74,6 +77,7 @@ internal fun StakingInitialInfoContent(
|
||||||
buttonState: NavigationButtonsState,
|
buttonState: NavigationButtonsState,
|
||||||
clickIntents: StakingClickIntents,
|
clickIntents: StakingClickIntents,
|
||||||
isBalanceHidden: Boolean,
|
isBalanceHidden: Boolean,
|
||||||
|
marketingBannerComponent: MarketingBannerComponent,
|
||||||
) {
|
) {
|
||||||
if (state !is StakingStates.InitialInfoState.Data) return
|
if (state !is StakingStates.InitialInfoState.Data) return
|
||||||
|
|
||||||
|
|
@ -104,6 +108,10 @@ internal fun StakingInitialInfoContent(
|
||||||
hideEndText = isBalanceHidden,
|
hideEndText = isBalanceHidden,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
item(key = MARKETING_BANNER_BLOCK_KEY) {
|
||||||
|
marketingBannerComponent.Content(Modifier.fillMaxWidth().padding(bottom = 12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
activeStakingBlock(
|
activeStakingBlock(
|
||||||
state = state,
|
state = state,
|
||||||
clickIntents = clickIntents,
|
clickIntents = clickIntents,
|
||||||
|
|
@ -491,6 +499,10 @@ private fun StakingInitialInfoContent_Preview(
|
||||||
buttonState = NavigationButtonsState.Empty,
|
buttonState = NavigationButtonsState.Empty,
|
||||||
clickIntents = StakingClickIntentsStub,
|
clickIntents = StakingClickIntentsStub,
|
||||||
isBalanceHidden = false,
|
isBalanceHidden = false,
|
||||||
|
marketingBannerComponent = object : MarketingBannerComponent {
|
||||||
|
@Composable
|
||||||
|
override fun Content(modifier: Modifier) = Unit
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||||
import com.tangem.core.ui.extensions.resolveReference
|
import com.tangem.core.ui.extensions.resolveReference
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.test.SendScreenTestTags
|
import com.tangem.core.ui.test.SendScreenTestTags
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||||
import com.tangem.features.staking.impl.R
|
import com.tangem.features.staking.impl.R
|
||||||
import com.tangem.features.staking.impl.presentation.state.StakingStates
|
import com.tangem.features.staking.impl.presentation.state.StakingStates
|
||||||
import com.tangem.features.staking.impl.presentation.state.StakingStep
|
import com.tangem.features.staking.impl.presentation.state.StakingStep
|
||||||
|
|
@ -35,7 +36,7 @@ import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.withIndex
|
import kotlinx.coroutines.flow.withIndex
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun StakingScreen(uiState: StakingUiState) {
|
internal fun StakingScreen(uiState: StakingUiState, marketingBannerComponent: MarketingBannerComponent) {
|
||||||
val confirmationState = uiState.confirmationState as? StakingStates.ConfirmationState.Data
|
val confirmationState = uiState.confirmationState as? StakingStates.ConfirmationState.Data
|
||||||
|
|
||||||
BackHandler(onBack = uiState.clickIntents::onPrevClick)
|
BackHandler(onBack = uiState.clickIntents::onPrevClick)
|
||||||
|
|
@ -53,6 +54,7 @@ internal fun StakingScreen(uiState: StakingUiState) {
|
||||||
)
|
)
|
||||||
StakingScreenContent(
|
StakingScreenContent(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
marketingBannerComponent = marketingBannerComponent,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
NavigationButtonsBlock(
|
NavigationButtonsBlock(
|
||||||
|
|
@ -104,7 +106,11 @@ private fun StakingAppBar(uiState: StakingUiState) {
|
||||||
|
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod")
|
||||||
@Composable
|
@Composable
|
||||||
private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = Modifier) {
|
private fun StakingScreenContent(
|
||||||
|
uiState: StakingUiState,
|
||||||
|
marketingBannerComponent: MarketingBannerComponent,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
val currentScreen = uiState.currentStep
|
val currentScreen = uiState.currentStep
|
||||||
var currentStateProxy by remember { mutableStateOf(currentScreen) }
|
var currentStateProxy by remember { mutableStateOf(currentScreen) }
|
||||||
var isTransitionAnimationRunning by remember { mutableStateOf(false) }
|
var isTransitionAnimationRunning by remember { mutableStateOf(false) }
|
||||||
|
|
@ -149,6 +155,7 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M
|
||||||
buttonState = uiState.buttonsState,
|
buttonState = uiState.buttonsState,
|
||||||
clickIntents = uiState.clickIntents,
|
clickIntents = uiState.clickIntents,
|
||||||
isBalanceHidden = uiState.isBalanceHidden,
|
isBalanceHidden = uiState.isBalanceHidden,
|
||||||
|
marketingBannerComponent = marketingBannerComponent,
|
||||||
)
|
)
|
||||||
StakingStep.RewardsValidators -> {
|
StakingStep.RewardsValidators -> {
|
||||||
StakingClaimRewardsValidatorContent(
|
StakingClaimRewardsValidatorContent(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package com.tangem.features.staking.impl.presentation.model
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.common.routing.AppRoute
|
||||||
|
import com.tangem.core.analytics.models.AnalyticsParam
|
||||||
|
import com.tangem.domain.onramp.model.OnrampSource
|
||||||
|
import io.mockk.Runs
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
internal class StakingModelMarketingDeeplinkTest : StakingModelTestBase() {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN swap deeplink WHEN onMarketingBannerDeeplink THEN pushes Swap for current token`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
every { appRouter.push(any(), any()) } just Runs
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val handled = model.onMarketingBannerDeeplink("tangem://swap")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(handled).isTrue()
|
||||||
|
verify {
|
||||||
|
appRouter.push(
|
||||||
|
match {
|
||||||
|
it is AppRoute.Swap &&
|
||||||
|
it.userWalletId == testUserWalletId &&
|
||||||
|
it.fromCryptoCurrency == testCryptoCurrency &&
|
||||||
|
it.screenSource == AnalyticsParam.ScreensSources.Staking.value
|
||||||
|
},
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN buy deeplink WHEN onMarketingBannerDeeplink THEN pushes Onramp for current token`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
every { appRouter.push(any(), any()) } just Runs
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val handled = model.onMarketingBannerDeeplink("tangem://buy")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(handled).isTrue()
|
||||||
|
verify {
|
||||||
|
appRouter.push(
|
||||||
|
match {
|
||||||
|
it is AppRoute.Onramp &&
|
||||||
|
it.userWalletId == testUserWalletId &&
|
||||||
|
it.currency == testCryptoCurrency &&
|
||||||
|
it.source == OnrampSource.MARKETING_BANNER
|
||||||
|
},
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN external deeplink WHEN onMarketingBannerDeeplink THEN not handled and no navigation`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
every { appRouter.push(any(), any()) } just Runs
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val handled = model.onMarketingBannerDeeplink("https://tangem.com/promo")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(handled).isFalse()
|
||||||
|
verify(exactly = 0) { appRouter.push(any(), any()) }
|
||||||
|
|
||||||
|
model.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,7 @@ dependencies {
|
||||||
implementation(projects.domain.dynamicAddresses.models)
|
implementation(projects.domain.dynamicAddresses.models)
|
||||||
implementation(projects.domain.feedback)
|
implementation(projects.domain.feedback)
|
||||||
implementation(projects.domain.markets.models)
|
implementation(projects.domain.markets.models)
|
||||||
|
implementation(projects.domain.marketing.models)
|
||||||
implementation(projects.domain.models)
|
implementation(projects.domain.models)
|
||||||
implementation(projects.domain.notifications.models)
|
implementation(projects.domain.notifications.models)
|
||||||
implementation(projects.domain.offramp)
|
implementation(projects.domain.offramp)
|
||||||
|
|
@ -101,6 +102,7 @@ dependencies {
|
||||||
implementation(projects.features.wallet.api)
|
implementation(projects.features.wallet.api)
|
||||||
implementation(projects.features.staking.api)
|
implementation(projects.features.staking.api)
|
||||||
implementation(projects.features.markets.api)
|
implementation(projects.features.markets.api)
|
||||||
|
implementation(projects.features.marketing.api)
|
||||||
implementation(projects.features.onramp.api)
|
implementation(projects.features.onramp.api)
|
||||||
implementation(projects.features.pushNotifications.api)
|
implementation(projects.features.pushNotifications.api)
|
||||||
implementation(projects.features.swap.api)
|
implementation(projects.features.swap.api)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
|
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent
|
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent
|
||||||
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
|
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||||
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
|
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
|
||||||
import com.tangem.features.rating.RatingComponent
|
import com.tangem.features.rating.RatingComponent
|
||||||
import com.tangem.features.tokendetails.ExpressTransactionsComponent
|
import com.tangem.features.tokendetails.ExpressTransactionsComponent
|
||||||
|
|
@ -54,6 +55,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
|
||||||
private val manageFundsComponentFactory: ManageFundsComponent.Factory,
|
private val manageFundsComponentFactory: ManageFundsComponent.Factory,
|
||||||
yieldSupplyComponentFactory: YieldSupplyComponent.Factory,
|
yieldSupplyComponentFactory: YieldSupplyComponent.Factory,
|
||||||
private val ratingComponentFactory: RatingComponent.Factory,
|
private val ratingComponentFactory: RatingComponent.Factory,
|
||||||
|
marketingBannerComponentFactory: MarketingBannerComponent.Factory,
|
||||||
) : TokenDetailsComponent, AppComponentContext by appComponentContext {
|
) : TokenDetailsComponent, AppComponentContext by appComponentContext {
|
||||||
|
|
||||||
private val model: TokenDetailsModel = getOrCreateModel(params)
|
private val model: TokenDetailsModel = getOrCreateModel(params)
|
||||||
|
|
@ -136,6 +138,14 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val marketingBannerComponent = marketingBannerComponentFactory.create(
|
||||||
|
context = child("tokenDetailsMarketingBanner"),
|
||||||
|
params = MarketingBannerComponent.Params.Standalone(
|
||||||
|
requestFlow = model.marketingRequest,
|
||||||
|
onDeeplinkClick = model::onMarketingBannerDeeplink,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun Content(modifier: Modifier) {
|
override fun Content(modifier: Modifier) {
|
||||||
val bottomSheet by bottomSheetSlot.subscribeAsState()
|
val bottomSheet by bottomSheetSlot.subscribeAsState()
|
||||||
|
|
@ -153,6 +163,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
|
||||||
txHistoryComponent = txHistoryComponent,
|
txHistoryComponent = txHistoryComponent,
|
||||||
expressTransactionsComponent = expressTransactionsComponent,
|
expressTransactionsComponent = expressTransactionsComponent,
|
||||||
ratingComponent = ratingSlotState.child?.instance,
|
ratingComponent = ratingSlotState.child?.instance,
|
||||||
|
marketingBannerComponent = marketingBannerComponent,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -164,6 +175,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
|
||||||
yieldSupplyComponent = yieldSupplyComponent,
|
yieldSupplyComponent = yieldSupplyComponent,
|
||||||
expressTransactionsComponent = expressTransactionsComponent,
|
expressTransactionsComponent = expressTransactionsComponent,
|
||||||
ratingComponent = ratingSlotState.child?.instance,
|
ratingComponent = ratingSlotState.child?.instance,
|
||||||
|
marketingBannerComponent = marketingBannerComponent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,11 @@ import com.tangem.common.extensions.hexToBytes
|
||||||
import com.tangem.common.extensions.toHexString
|
import com.tangem.common.extensions.toHexString
|
||||||
import com.tangem.common.routing.AppRoute
|
import com.tangem.common.routing.AppRoute
|
||||||
import com.tangem.common.routing.AppRouter
|
import com.tangem.common.routing.AppRouter
|
||||||
|
import com.tangem.common.routing.deeplink.MarketingDeeplink
|
||||||
|
import com.tangem.common.routing.deeplink.resolveMarketingDeeplink
|
||||||
import com.tangem.common.ui.bottomsheet.receive.AddressModel
|
import com.tangem.common.ui.bottomsheet.receive.AddressModel
|
||||||
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
|
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerRequest
|
||||||
import com.tangem.features.rating.RatingComponent
|
import com.tangem.features.rating.RatingComponent
|
||||||
import com.tangem.feature.swap.domain.SwapFeedbackUseCase
|
import com.tangem.feature.swap.domain.SwapFeedbackUseCase
|
||||||
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
|
import com.tangem.feature.swap.domain.models.domain.SwapFeedbackParams
|
||||||
|
|
@ -58,6 +61,7 @@ import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
|
||||||
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
|
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
|
||||||
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
|
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
|
||||||
import com.tangem.domain.models.StatusSource
|
import com.tangem.domain.models.StatusSource
|
||||||
|
import com.tangem.domain.marketing.models.MarketingScreen
|
||||||
import com.tangem.domain.models.TokenReceiveNotification
|
import com.tangem.domain.models.TokenReceiveNotification
|
||||||
import com.tangem.domain.models.account.Account
|
import com.tangem.domain.models.account.Account
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
|
|
@ -192,6 +196,16 @@ internal class TokenDetailsModel @Inject constructor(
|
||||||
private val userWalletId: UserWalletId = params.userWalletId
|
private val userWalletId: UserWalletId = params.userWalletId
|
||||||
private val cryptoCurrency: CryptoCurrency = params.currency
|
private val cryptoCurrency: CryptoCurrency = params.currency
|
||||||
|
|
||||||
|
/** Token details context is static (single currency) — no amount filter. */
|
||||||
|
val marketingRequest: Flow<MarketingBannerRequest?> = flowOf(
|
||||||
|
MarketingBannerRequest(
|
||||||
|
screen = MarketingScreen.TokenDetails(
|
||||||
|
networkId = cryptoCurrency.network.rawId,
|
||||||
|
contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress.orEmpty(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private val userWallet: UserWallet = getUserWalletUseCase(userWalletId).getOrNull()
|
private val userWallet: UserWallet = getUserWalletUseCase(userWalletId).getOrNull()
|
||||||
?: error("UserWallet not found")
|
?: error("UserWallet not found")
|
||||||
|
|
||||||
|
|
@ -208,6 +222,7 @@ internal class TokenDetailsModel @Inject constructor(
|
||||||
private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null
|
private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null
|
||||||
private var account: Account.CryptoPortfolio? = null
|
private var account: Account.CryptoPortfolio? = null
|
||||||
private var isBalanceLoadedEventSent = false
|
private var isBalanceLoadedEventSent = false
|
||||||
|
private var latestTokenActions: List<TokenActionsState.ActionState> = emptyList()
|
||||||
|
|
||||||
val bottomSheetNavigation: SlotNavigation<TokenDetailsBottomSheetConfig> = SlotNavigation()
|
val bottomSheetNavigation: SlotNavigation<TokenDetailsBottomSheetConfig> = SlotNavigation()
|
||||||
val ratingSlotNavigation = SlotNavigation<RatingComponent.Params>()
|
val ratingSlotNavigation = SlotNavigation<RatingComponent.Params>()
|
||||||
|
|
@ -346,6 +361,7 @@ internal class TokenDetailsModel @Inject constructor(
|
||||||
.conflate()
|
.conflate()
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.onEach { state ->
|
.onEach { state ->
|
||||||
|
latestTokenActions = state.states
|
||||||
sendButtonsEvents(state.states)
|
sendButtonsEvents(state.states)
|
||||||
uiState.value = stateFactory.getManageButtonsState(actions = state.states)
|
uiState.value = stateFactory.getManageButtonsState(actions = state.states)
|
||||||
if (designFeatureToggles.isRedesignEnabled) {
|
if (designFeatureToggles.isRedesignEnabled) {
|
||||||
|
|
@ -818,6 +834,29 @@ internal class TokenDetailsModel @Inject constructor(
|
||||||
handleSwap(unavailabilityReason, AppRoute.Swap.CurrencyPosition.ANY, checkYieldSupply = true)
|
handleSwap(unavailabilityReason, AppRoute.Swap.CurrencyPosition.ANY, checkYieldSupply = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a tapped marketing-banner deeplink contextually for the current token. Reuses the regular
|
||||||
|
* swap/buy intents (availability checks, yield-supply warning, analytics). Returns `false` for
|
||||||
|
* external links so the banner falls back to the generic deeplink launcher.
|
||||||
|
*/
|
||||||
|
fun onMarketingBannerDeeplink(deeplink: String): Boolean = when (resolveMarketingDeeplink(deeplink)) {
|
||||||
|
MarketingDeeplink.SWAP -> {
|
||||||
|
val reason = latestTokenActions
|
||||||
|
.filterIsInstance<TokenActionsState.ActionState.Swap>()
|
||||||
|
.firstOrNull()?.unavailabilityReason ?: ScenarioUnavailabilityReason.None
|
||||||
|
onSwapClick(reason)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MarketingDeeplink.BUY -> {
|
||||||
|
val reason = latestTokenActions
|
||||||
|
.filterIsInstance<TokenActionsState.ActionState.Buy>()
|
||||||
|
.firstOrNull()?.unavailabilityReason ?: ScenarioUnavailabilityReason.None
|
||||||
|
onBuyClick(reason)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MarketingDeeplink.EXTERNAL -> false
|
||||||
|
}
|
||||||
|
|
||||||
override fun onSwapFromClick(unavailabilityReason: ScenarioUnavailabilityReason) {
|
override fun onSwapFromClick(unavailabilityReason: ScenarioUnavailabilityReason) {
|
||||||
handleSwap(unavailabilityReason, AppRoute.Swap.CurrencyPosition.FROM, checkYieldSupply = true)
|
handleSwap(unavailabilityReason, AppRoute.Swap.CurrencyPosition.FROM, checkYieldSupply = true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock
|
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.ZeroBalanceActionsBlock
|
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.ZeroBalanceActionsBlock
|
||||||
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
|
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||||
import com.tangem.features.rating.RatingComponent
|
import com.tangem.features.rating.RatingComponent
|
||||||
import com.tangem.features.tokendetails.ExpressTransactionsComponent
|
import com.tangem.features.tokendetails.ExpressTransactionsComponent
|
||||||
import com.tangem.features.txhistory.component.TxHistoryComponent
|
import com.tangem.features.txhistory.component.TxHistoryComponent
|
||||||
|
|
@ -73,6 +74,7 @@ internal fun TokenDetailsScreen(
|
||||||
txHistoryComponent: TxHistoryComponent,
|
txHistoryComponent: TxHistoryComponent,
|
||||||
expressTransactionsComponent: ExpressTransactionsComponent,
|
expressTransactionsComponent: ExpressTransactionsComponent,
|
||||||
ratingComponent: RatingComponent?,
|
ratingComponent: RatingComponent?,
|
||||||
|
marketingBannerComponent: MarketingBannerComponent,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle()
|
val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle()
|
||||||
|
|
@ -100,6 +102,7 @@ internal fun TokenDetailsScreen(
|
||||||
txHistoryComponent = txHistoryComponent,
|
txHistoryComponent = txHistoryComponent,
|
||||||
expressTransactionsComponent = expressTransactionsComponent,
|
expressTransactionsComponent = expressTransactionsComponent,
|
||||||
expressTransactionsToDisplay = expressState.transactionsToDisplay,
|
expressTransactionsToDisplay = expressState.transactionsToDisplay,
|
||||||
|
marketingBannerComponent = marketingBannerComponent,
|
||||||
rootBackground = rootBackground,
|
rootBackground = rootBackground,
|
||||||
topContentPadding = topBarTotalHeight,
|
topContentPadding = topBarTotalHeight,
|
||||||
bottomContentPadding = effectiveBottomPadding,
|
bottomContentPadding = effectiveBottomPadding,
|
||||||
|
|
@ -161,6 +164,7 @@ private fun TokenDetailsBody(
|
||||||
txHistoryComponent: TxHistoryComponent,
|
txHistoryComponent: TxHistoryComponent,
|
||||||
expressTransactionsComponent: ExpressTransactionsComponent,
|
expressTransactionsComponent: ExpressTransactionsComponent,
|
||||||
expressTransactionsToDisplay: PersistentList<ExpressTransactionStateUM>,
|
expressTransactionsToDisplay: PersistentList<ExpressTransactionStateUM>,
|
||||||
|
marketingBannerComponent: MarketingBannerComponent,
|
||||||
rootBackground: Color,
|
rootBackground: Color,
|
||||||
topContentPadding: Dp,
|
topContentPadding: Dp,
|
||||||
bottomContentPadding: Dp,
|
bottomContentPadding: Dp,
|
||||||
|
|
@ -230,6 +234,9 @@ private fun TokenDetailsBody(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item(key = "marketing_banner_block") {
|
||||||
|
marketingBannerComponent.Content(modifier = itemModifier.padding(vertical = 8.dp))
|
||||||
|
}
|
||||||
if (balance is TokenDetailsBalanceBlockUM.Content && balance.isBalanceZero) {
|
if (balance is TokenDetailsBalanceBlockUM.Content && balance.isBalanceZero) {
|
||||||
item(key = "zero_balance_actions") {
|
item(key = "zero_balance_actions") {
|
||||||
ZeroBalanceActionsBlock(
|
ZeroBalanceActionsBlock(
|
||||||
|
|
@ -317,6 +324,10 @@ private fun TokenDetailsScreen_Preview() {
|
||||||
},
|
},
|
||||||
expressTransactionsComponent = PreviewExpressTransactionsComponent,
|
expressTransactionsComponent = PreviewExpressTransactionsComponent,
|
||||||
ratingComponent = null,
|
ratingComponent = null,
|
||||||
|
marketingBannerComponent = object : MarketingBannerComponent {
|
||||||
|
@Composable
|
||||||
|
override fun Content(modifier: Modifier) = Unit
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenInfoBlock
|
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenInfoBlock
|
||||||
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlockLegacy
|
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlockLegacy
|
||||||
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
|
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
|
||||||
|
import com.tangem.features.marketing.api.MarketingBannerComponent
|
||||||
import com.tangem.features.tokendetails.ExpressTransactionsComponent
|
import com.tangem.features.tokendetails.ExpressTransactionsComponent
|
||||||
import com.tangem.features.txhistory.component.TxHistoryComponent
|
import com.tangem.features.txhistory.component.TxHistoryComponent
|
||||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
||||||
|
|
@ -54,6 +55,7 @@ internal fun TokenDetailsScreenLegacy(
|
||||||
yieldSupplyComponent: YieldSupplyComponent,
|
yieldSupplyComponent: YieldSupplyComponent,
|
||||||
expressTransactionsComponent: ExpressTransactionsComponent,
|
expressTransactionsComponent: ExpressTransactionsComponent,
|
||||||
ratingComponent: RatingComponent?,
|
ratingComponent: RatingComponent?,
|
||||||
|
marketingBannerComponent: MarketingBannerComponent,
|
||||||
) {
|
) {
|
||||||
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
|
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
|
||||||
|
|
||||||
|
|
@ -154,6 +156,12 @@ internal fun TokenDetailsScreenLegacy(
|
||||||
yieldSupplyComponent.Content(modifier = itemModifier)
|
yieldSupplyComponent.Content(modifier = itemModifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item(key = "marketing_banner_block") {
|
||||||
|
TangemThemeRedesign {
|
||||||
|
marketingBannerComponent.Content(modifier = itemModifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
with(expressTransactionsComponent) {
|
with(expressTransactionsComponent) {
|
||||||
expressTransactionsContentLegacy(
|
expressTransactionsContentLegacy(
|
||||||
state = expressState.transactionsToDisplay,
|
state = expressState.transactionsToDisplay,
|
||||||
|
|
@ -215,6 +223,10 @@ private fun TokenDetailsScreenPreview(
|
||||||
},
|
},
|
||||||
expressTransactionsComponent = PreviewExpressTransactionsComponent,
|
expressTransactionsComponent = PreviewExpressTransactionsComponent,
|
||||||
ratingComponent = null,
|
ratingComponent = null,
|
||||||
|
marketingBannerComponent = object : MarketingBannerComponent {
|
||||||
|
@Composable
|
||||||
|
override fun Content(modifier: Modifier) = Unit
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue