Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-28 17:24:23 +04:00
commit e6ec52e35a
83 changed files with 1846 additions and 301 deletions

View file

@ -44,6 +44,7 @@ dependencies {
/** Tangem libraries */
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
/** Common */
api(projects.common.routing)

View file

@ -1,5 +1,6 @@
package com.tangem.features.commonfeatures.impl.choosetoken.converter
import arrow.core.toNonEmptyListOrNull
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.common.ui.account.TokensListPortfolioItemConverter
import com.tangem.common.ui.account.toUM
@ -22,6 +23,7 @@ import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
@ -101,8 +103,9 @@ internal class ChooseTokenListItemConverter(
private fun AccountStatus.CryptoPortfolio.toPortfolioItem(
params: TokenConverterParams.Account,
): TokensListItemUM.Portfolio {
val tokenList: TokenList = this.tokenList
val account: Account.CryptoPortfolio = this.account
val displayedStatus = filterForDisplay()
val account: Account.CryptoPortfolio = displayedStatus.account
val displayedTokenList: TokenList = displayedStatus.tokenList
val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId)
val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount ->
onAccountItemClick(clickedAccount, isExpanded)
@ -116,10 +119,9 @@ internal class ChooseTokenListItemConverter(
fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) },
subtitle2StateProvider = { _ -> null },
)
val accountItem = converter.convert(tokenList.totalFiatBalance)
val tokenConverter = tokenStatusConverter(this)
val tokensListState = convertTokenList(tokenConverter, tokenList, this)
val items = tokensListState.tokensList
val accountItem = converter.convert(displayedTokenList.totalFiatBalance)
val items = displayedTokenList.toUmData(tokenStatusConverter(this)).tokensList
return TokensListPortfolioItemConverter(
tokenItemUM = accountItem,
isExpanded = isExpanded,
@ -128,29 +130,47 @@ internal class ChooseTokenListItemConverter(
).convert(Unit)
}
private fun AccountStatus.CryptoPortfolio.filterForDisplay(): AccountStatus.CryptoPortfolio {
val filteredTokenList = filterTokenList(tokenList, this)
return copy(
account = account.copy(cryptoCurrencies = filteredTokenList.flattenCurrencies().map { it.currency }),
tokenList = filteredTokenList,
)
}
private fun TokenList.recalculateBalance(): TokenList {
val statuses = flattenCurrencies().toNonEmptyListOrNull() ?: return this
val total = TotalFiatBalanceCalculator.calculate(statuses)
return when (this) {
TokenList.Empty -> this
is TokenList.Ungrouped -> copy(totalFiatBalance = total)
is TokenList.GroupedByNetwork -> copy(totalFiatBalance = total)
}
}
private fun convertTokenList(
tokenConverter: TokenItemStateConverter,
tokenListParam: TokenList,
account: AccountStatus.CryptoPortfolio,
): TokenListUMData {
return when (val tokenList = filterTokenList(tokenListParam, account)) {
is TokenList.Empty -> TokenListUMData.EmptyList
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = tokenList.flattenCurrencies().size,
)
is TokenList.Ungrouped -> TokenListUMData.TokenList(
tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = tokenList.flattenCurrencies().size,
)
}
): TokenListUMData = filterTokenList(tokenListParam, account).toUmData(tokenConverter)
private fun TokenList.toUmData(tokenConverter: TokenItemStateConverter): TokenListUMData = when (this) {
TokenList.Empty -> TokenListUMData.EmptyList
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
tokensList = toGroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = flattenCurrencies().size,
)
is TokenList.Ungrouped -> TokenListUMData.TokenList(
tokensList = toUngroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = flattenCurrencies().size,
)
}
private fun List<CryptoCurrencyStatus>.filterCurrencies(account: AccountStatus): List<CryptoCurrencyStatus> =
filter { currency -> currency.filterByQuery() && tokenFilter(account, currency) }
private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList {
return when (tokenList) {
val filtered = when (tokenList) {
TokenList.Empty -> TokenList.Empty
is TokenList.Ungrouped -> {
val filtered = tokenList.currencies.filterCurrencies(account)
@ -166,6 +186,8 @@ internal class ChooseTokenListItemConverter(
if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups)
}
}
return filtered.recalculateBalance()
}
private fun CryptoCurrencyStatus.filterByQuery(): Boolean {

View file

@ -7,6 +7,8 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.features.commonfeatures.api.R
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.*
@ -20,6 +22,7 @@ import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.state.ChooserBlockUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -32,6 +35,7 @@ internal class ChooseTokenModel @Inject constructor(
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
predefinedTokensBlockDelegateFactory: PredefinedTokensBlockDelegate.Factory,
addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
paramsContainer: ParamsContainer,
) : Model() {
@ -62,6 +66,13 @@ internal class ChooseTokenModel @Inject constructor(
)
}
/** Tokens the user already holds in the selected wallet — subtracted from the predefined "Other eligible" block. */
@OptIn(ExperimentalCoroutinesApi::class)
private val portfolioTokenKeysFlow: Flow<Set<Pair<String, String>>> = bridge.selectedWalletFlow
.flatMapLatest { wallet -> singleAccountStatusListSupplier(wallet.walletId) }
.map { accountStatusList -> accountStatusList.toTokenKeys() }
.onStart { emit(emptySet()) }
private val predefinedTokensBlockDelegate: PredefinedTokensBlockDelegate by lazy {
val block = bridge.settings.chooserBlock as ChooserBlock.Predefined
predefinedTokensBlockDelegateFactory.create(
@ -71,6 +82,7 @@ internal class ChooseTokenModel @Inject constructor(
addToPortfolioSlot = bottomSheetNavigation,
modelScope = modelScope,
tokenFilter = bridge.tokenFilter,
portfolioTokenKeys = portfolioTokenKeysFlow,
)
}
@ -104,7 +116,7 @@ internal class ChooseTokenModel @Inject constructor(
)
init {
if (bridge.settings.chooserBlock == ChooserBlock.Market) {
if (bridge.settings.chooserBlock is ChooserBlock.Market) {
modelScope.launch {
delay(MARKETS_INITIAL_LOAD_DELAY)
marketBlockDelegate.loadDefaultMarkets()
@ -124,6 +136,12 @@ internal class ChooseTokenModel @Inject constructor(
.launchIn(modelScope)
}
private fun AccountStatusList.toTokenKeys(): Set<Pair<String, String>> =
flattenCurrencies().mapNotNullTo(hashSetOf()) { status ->
val rawId = status.currency.id.rawCurrencyId?.value ?: return@mapNotNullTo null
rawId to status.currency.network.rawId
}
fun onBackClicked() {
bridge.onClose()
}

View file

@ -33,6 +33,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
@Assisted private val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
@Assisted private val portfolioTokenKeys: Flow<Set<Pair<String, String>>>,
) {
init {
@ -44,8 +45,13 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
val stateFlow: Flow<PredefinedTokensUM?> = combine(
predefinedTokens,
searchQueryState,
) { tokens, query ->
val filtered = tokens.filter { it.hasValidNetwork() && it.matchesQuery(query.value) }
portfolioTokenKeys,
) { tokens, query, portfolioKeys ->
val filtered = tokens.filter { token ->
token.hasValidNetwork() &&
token.matchesQuery(query.value) &&
!portfolioKeys.contains(token.toKey())
}
if (filtered.isEmpty()) {
null
} else {
@ -66,6 +72,9 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
}
}
/** Identity of a predefined token as `(rawCurrencyId, networkId)` — matches the portfolio token keys. */
private fun PredefinedTokenToAdd.toKey(): Pair<String, String> = token.id.value to network.networkId
private fun PredefinedTokenToAdd.hasValidNetwork(): Boolean =
network.networkId.isNotBlank() && network.decimalCount != null
@ -104,6 +113,7 @@ internal class PredefinedTokensBlockDelegate @AssistedInject constructor(
addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute>,
modelScope: CoroutineScope,
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>,
portfolioTokenKeys: Flow<Set<Pair<String, String>>>,
): PredefinedTokensBlockDelegate
}
}

View file

@ -740,7 +740,7 @@ private fun LazyListScope.predefinedTokensListItems(state: PredefinedTokensUM) {
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors2.surface.level1,
backgroundColor = TangemTheme.colors.background.primary,
)
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
.semantics { lazyListItemPosition = index },

View file

@ -125,6 +125,41 @@ internal class PredefinedTokensBlockDelegateTest {
assertThat(actual).isNull()
}
@Test
fun `GIVEN predefined token already in portfolio WHEN state emitted THEN it is excluded`() = runTest {
// Arrange
val tokens = listOf(
createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = ETHEREUM_NETWORK_ID),
createPredefinedToken(id = "tether", symbol = "USDT", networkId = ETHEREUM_NETWORK_ID),
)
val delegate = createDelegate(
predefinedTokens = MutableStateFlow(tokens),
portfolioTokenKeys = MutableStateFlow(setOf("usd-coin" to ETHEREUM_NETWORK_ID)),
)
// Act
val actual = lastState(delegate)
// Assert — usd-coin is already in the portfolio, so only tether stays in "Other eligible tokens"
assertThat(actual?.items?.map { it.id }).containsExactly("tether_$ETHEREUM_NETWORK_ID")
}
@Test
fun `GIVEN all predefined tokens already in portfolio WHEN state emitted THEN emits null`() = runTest {
// Arrange
val token = createPredefinedToken(id = "usd-coin", symbol = "USDC", networkId = ETHEREUM_NETWORK_ID)
val delegate = createDelegate(
predefinedTokens = MutableStateFlow(listOf(token)),
portfolioTokenKeys = MutableStateFlow(setOf("usd-coin" to ETHEREUM_NETWORK_ID)),
)
// Act
val actual = lastState(delegate)
// Assert
assertThat(actual).isNull()
}
@ParameterizedTest
@ProvideTestModels
fun filter(model: FilterModel) = runTest {
@ -234,6 +269,7 @@ internal class PredefinedTokensBlockDelegateTest {
searchQueryState: MutableStateFlow<SearchQuery> = MutableStateFlow(SearchQuery.Empty),
tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> =
MutableStateFlow({ _, _ -> true }),
portfolioTokenKeys: MutableStateFlow<Set<Pair<String, String>>> = MutableStateFlow(emptySet()),
): PredefinedTokensBlockDelegate = PredefinedTokensBlockDelegate(
predefinedTokens = predefinedTokens,
searchQueryState = searchQueryState,
@ -241,6 +277,7 @@ internal class PredefinedTokensBlockDelegateTest {
addToPortfolioSlot = addToPortfolioSlot,
modelScope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)),
tokenFilter = tokenFilter,
portfolioTokenKeys = portfolioTokenKeys,
)
private fun currency(rawId: String, networkId: String): CryptoCurrencyStatus =

View file

@ -17,6 +17,7 @@ dependencies {
api(projects.features.details.api)
api(projects.features.onboardingV2.api)
api(projects.features.wallet.api)
implementation(projects.features.virtualAccounts.details.api)
/* Project - Core */
api(projects.core.analytics)
@ -45,6 +46,7 @@ dependencies {
runtimeOnly(projects.domain.appCurrency)
runtimeOnly(projects.domain.balanceHiding)
runtimeOnly(projects.domain.tokens)
implementation(projects.domain.virtualAccount)
/* SDK */
// TODO: For TangemError model, should be removed after card domain scanning refactoring

View file

@ -39,6 +39,7 @@ import com.tangem.features.details.entity.SelectContactSupportTypeBS
import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
import com.tangem.features.details.utils.ItemsBuilder
import com.tangem.features.details.utils.SocialsBuilder
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.logging.TangemLogger
@ -71,6 +72,7 @@ internal class DetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles,
private val tangemPayEligibilityManager: TangemPayEligibilityManager,
private val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase,
) : Model() {
@ -335,8 +337,9 @@ internal class DetailsModel @Inject constructor(
private fun addVirtualAccountItemIfEligible() {
modelScope.launch {
val isVirtualAccountEnabled = virtualAccountFeatureToggles.isVirtualAccountsEnabled
val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS)
if (eligibility is VirtualAccountEligibility.Available) {
if (eligibility is VirtualAccountEligibility.Available && isVirtualAccountEnabled) {
items.update { items ->
itemsBuilder.addVirtualAccountItem(
items = items,

View file

@ -23,6 +23,7 @@ import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.utils.ItemsBuilder
@ -64,6 +65,7 @@ internal abstract class DetailsModelTestBase {
protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
protected val tangemPayEligibilityManager: TangemPayEligibilityManager = mockk()
protected val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase = mockk()
protected val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk()
// Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven.
protected val wcSlot = slot<Boolean>()
@ -88,6 +90,7 @@ internal abstract class DetailsModelTestBase {
every { appInfoProvider.appVersionCode } returns 456
coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList()
coEvery { getVirtualAccountEligibilityUseCase(any()) } returns VirtualAccountEligibility.NotAvailable
every { virtualAccountFeatureToggles.isVirtualAccountsEnabled } returns true
every {
itemsBuilder.buildAll(
@ -128,6 +131,7 @@ internal abstract class DetailsModelTestBase {
analyticsEventHandler = analyticsEventHandler,
tangemPayEligibilityManager = tangemPayEligibilityManager,
getVirtualAccountEligibilityUseCase = getVirtualAccountEligibilityUseCase,
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
)
protected fun stubBuildAllReturns(list: ImmutableList<DetailsItemUM>) {

View file

@ -28,6 +28,7 @@ dependencies {
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.material3)
implementation(deps.compose.coil)
implementation(deps.lifecycle.compose)

View file

@ -125,9 +125,12 @@ internal class MarketingBannerModel @Inject constructor(
campaignId = id,
text = banner.text,
iconUrl = banner.iconUrl,
// When the backend omits iconAlign, follow the design default: a dismissible banner keeps the icon
// on the left (the close button occupies the right slot), a non-dismissible one moves it to the right.
iconAlign = when (banner.iconAlign) {
MarketingBanner.IconAlign.RIGHT -> MarketingBannerUM.IconAlign.RIGHT
MarketingBanner.IconAlign.LEFT, null -> MarketingBannerUM.IconAlign.LEFT
MarketingBanner.IconAlign.LEFT -> MarketingBannerUM.IconAlign.LEFT
null -> if (banner.isDismissible) MarketingBannerUM.IconAlign.LEFT else MarketingBannerUM.IconAlign.RIGHT
},
isDismissible = banner.isDismissible,
deeplink = banner.deeplink,

View file

@ -21,7 +21,6 @@ import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.messagebanner.CloseButton
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -51,11 +50,10 @@ internal fun MarketingBanner(
TangemMessageBanner(
title = stringReference(banner.text.orEmpty()),
modifier = modifier.then(
if (hasDeeplink) Modifier.clickableSingle(onClick = onClick) else Modifier,
),
modifier = modifier,
variant = TangemMessageBanner.Variant.Default,
showGlowRing = false,
onClick = if (hasDeeplink) onClick else null,
slotStart = if (isIconAtStart) {
{ BannerIcon(banner.iconUrl, onLoadError = { isIconFailed = true }) }
} else {

View file

@ -16,6 +16,8 @@ import com.tangem.features.marketing.api.LinkedBannerRequest
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.marketing.api.MarketingBannerRequest
import com.tangem.features.marketing.impl.ui.state.MarketingBannerListUM
import com.tangem.features.marketing.impl.ui.state.MarketingBannerUM
import com.tangem.test.core.ProvideTestModels
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.Runs
import io.mockk.clearMocks
@ -33,6 +35,7 @@ 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
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MarketingBannerModelTest {
@ -280,4 +283,53 @@ internal class MarketingBannerModelTest {
// Assert
verify(exactly = 1) { deeplinkLauncher.launch("https://tangem.com/promo") }
}
@ParameterizedTest
@ProvideTestModels
fun `GIVEN iconAlign and dismissible WHEN mapped THEN align follows design default`(
model: IconAlignModel,
) = runTest {
// Arrange
coEvery { getMarketingBanner(onrampScreen, null) } returns listOf(
standaloneCampaign(id = 1, iconAlign = model.iconAlign, isDismissible = model.isDismissible),
).right()
val bannerModel = createModel(
MarketingBannerComponent.Params.Standalone(flowOf(MarketingBannerRequest(onrampScreen))),
)
// Act
advanceUntilIdle()
// Assert
val content = bannerModel.uiState.value as MarketingBannerListUM.Content
assertThat(content.banners.single().iconAlign).isEqualTo(model.expected)
}
private fun standaloneCampaign(id: Int, iconAlign: MarketingBanner.IconAlign?, isDismissible: Boolean) =
campaign(id, MarketingBanner.UiType.STANDALONE).let { base ->
base.copy(banner = base.banner.copy(iconAlign = iconAlign, isDismissible = isDismissible))
}
internal data class IconAlignModel(
val iconAlign: MarketingBanner.IconAlign?,
val isDismissible: Boolean,
val expected: MarketingBannerUM.IconAlign,
)
private fun provideTestModels() = listOf(
// Backend omits iconAlign -> derived from dismissible (design default)
IconAlignModel(iconAlign = null, isDismissible = false, expected = MarketingBannerUM.IconAlign.RIGHT),
IconAlignModel(iconAlign = null, isDismissible = true, expected = MarketingBannerUM.IconAlign.LEFT),
// Explicit backend value is always honored regardless of dismissible
IconAlignModel(
iconAlign = MarketingBanner.IconAlign.LEFT,
isDismissible = false,
expected = MarketingBannerUM.IconAlign.LEFT,
),
IconAlignModel(
iconAlign = MarketingBanner.IconAlign.RIGHT,
isDismissible = true,
expected = MarketingBannerUM.IconAlign.RIGHT,
),
)
}

View file

@ -17,8 +17,6 @@ dependencies {
api(projects.features.commonFeatures.api)
api(projects.features.onramp.api)
implementation(projects.features.marketing.api)
implementation(projects.domain.marketing.models)
implementation(projects.domain.quotes)
/** Project - Core */
api(projects.core.analytics)
@ -55,6 +53,8 @@ dependencies {
implementation(projects.domain.tokens.models)
implementation(projects.domain.transaction.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.marketing.models)
implementation(projects.domain.quotes)
runtimeOnly(projects.domain.card)
/** Data */

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.features.marketing.api.MarketingBannerComponent
internal interface AllOffersComponent : ComposableBottomSheetComponent {
@ -14,6 +15,12 @@ internal interface AllOffersComponent : ComposableBottomSheetComponent {
val onDismiss: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val amountCurrencyCode: String,
// Marketing banner components are created and owned by the parent onramp-main component and passed
// down so this sheet reuses their models (and their amount-gated request flows) instead of building
// its own: [marketingBannerComponent] renders the standalone banner, [linkedMarketingBannerComponent]
// renders the per-provider LINKED_TO_PROVIDER banner next to each offer.
val marketingBannerComponent: MarketingBannerComponent,
val linkedMarketingBannerComponent: MarketingBannerComponent,
)
interface Factory : ComponentFactory<Params, AllOffersComponent>

View file

@ -13,7 +13,7 @@ import dagger.assisted.AssistedInject
internal class DefaultAllOffersComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: AllOffersComponent.Params,
@Assisted private val params: AllOffersComponent.Params,
) : AllOffersComponent, AppComponentContext by context {
private val model: AllOffersModel = getOrCreateModel(params)
@ -27,6 +27,8 @@ internal class DefaultAllOffersComponent @AssistedInject constructor(
val state by model.state.collectAsState()
AllOffersContentSheet(
state = state,
marketingBannerComponent = params.marketingBannerComponent,
linkedMarketingBannerComponent = params.linkedMarketingBannerComponent,
onCloseClick = { dismiss() },
)
}

View file

@ -25,9 +25,11 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.marketing.api.MarketingBannerComponent
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
@ -35,13 +37,18 @@ import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampOfferAdvantagesUM
import com.tangem.features.onramp.main.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.main.entity.OnrampOfferUM
import com.tangem.features.onramp.main.ui.Offer
import com.tangem.features.onramp.main.ui.OfferWithLinkedBanner
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Composable
internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () -> Unit) {
internal fun AllOffersContentSheet(
state: AllOffersStateUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
onCloseClick: () -> Unit,
) {
val onBack = remember(state) {
{
if (state is AllOffersStateUM.Content && state.currentMethod != null) {
@ -71,37 +78,64 @@ internal fun AllOffersContentSheet(state: AllOffersStateUM, onCloseClick: () ->
}
},
content = {
Box(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp)
.animateContentSize(),
) {
AnimatedContent(
targetState = state is AllOffersStateUM.Content && state.currentMethod != null,
transitionSpec = {
fadeIn(tween(durationMillis = 220)) togetherWith
fadeOut(tween(durationMillis = 220))
},
label = "Change offers and payment method state",
) { shouldShowOffersScreen ->
when (state) {
AllOffersStateUM.Loading -> AllOffersContentLoading()
is AllOffersStateUM.Error -> AllOffersError(state.errorNotification)
is AllOffersStateUM.Content -> {
if (shouldShowOffersScreen) {
state.currentMethod?.let {
OffersBasedOnPaymentMethodContent(offers = it.offers)
}
} else {
PaymentMethodsContent(methods = state.methods)
AllOffersSheetContent(
state = state,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
},
)
}
@Composable
private fun AllOffersSheetContent(
state: AllOffersStateUM,
marketingBannerComponent: MarketingBannerComponent,
linkedMarketingBannerComponent: MarketingBannerComponent,
) {
Column(modifier = Modifier.fillMaxSize()) {
// Standalone marketing banner at the top of the sheet (DS3 -> wrap in the redesign theme).
// Renders nothing when no matching campaign, so it adds no space in the common case.
TangemThemeRedesign {
marketingBannerComponent.Content(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 8.dp)
.animateContentSize(),
) {
AnimatedContent(
targetState = state is AllOffersStateUM.Content && state.currentMethod != null,
transitionSpec = {
fadeIn(tween(durationMillis = 220)) togetherWith
fadeOut(tween(durationMillis = 220))
},
label = "Change offers and payment method state",
) { shouldShowOffersScreen ->
when (state) {
AllOffersStateUM.Loading -> AllOffersContentLoading()
is AllOffersStateUM.Error -> AllOffersError(state.errorNotification)
is AllOffersStateUM.Content -> {
if (shouldShowOffersScreen) {
state.currentMethod?.let { method ->
OffersBasedOnPaymentMethodContent(
offers = method.offers,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
)
}
} else {
PaymentMethodsContent(methods = state.methods)
}
}
}
}
},
)
}
}
}
@Composable
@ -127,7 +161,10 @@ private fun PaymentMethodTitle(onCloseClick: () -> Unit) {
}
@Composable
private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList<OnrampOfferUM>) {
private fun OffersBasedOnPaymentMethodContent(
offers: ImmutableList<OnrampOfferUM>,
linkedMarketingBannerComponent: MarketingBannerComponent,
) {
Column(
modifier = Modifier
.fillMaxWidth()
@ -136,7 +173,7 @@ private fun OffersBasedOnPaymentMethodContent(offers: ImmutableList<OnrampOfferU
) {
offers.fastForEach { offer ->
key("${offer.paymentMethod.id} ${offer.providerName} ${offer.rate}") {
Offer(offer)
OfferWithLinkedBanner(offer, linkedMarketingBannerComponent)
SpacerH(8.dp)
}
}
@ -250,11 +287,18 @@ private fun AllOffersContentSheetPaymentPreview() {
currentMethod = method,
onBackClicked = {},
),
marketingBannerComponent = PreviewMarketingBannerComponent,
linkedMarketingBannerComponent = PreviewMarketingBannerComponent,
onCloseClick = {},
)
}
}
private val PreviewMarketingBannerComponent = object : MarketingBannerComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -319,6 +363,8 @@ private fun AllOffersContentSheetOffersPreview() {
currentMethod = null,
onBackClicked = {},
),
marketingBannerComponent = PreviewMarketingBannerComponent,
linkedMarketingBannerComponent = PreviewMarketingBannerComponent,
onCloseClick = {},
)
}

View file

@ -106,6 +106,8 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
onDismiss = model.bottomSheetNavigation::dismiss,
openRedirectPage = params.openRedirectPage,
amountCurrencyCode = config.amountCurrencyCode,
marketingBannerComponent = marketingBannerComponent,
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
),
)
}

View file

@ -104,7 +104,7 @@ internal fun OnrampOffersContent(state: OnrampOffersBlockUM, linkedMarketingBann
}
@Composable
private fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
internal fun OfferWithLinkedBanner(offer: OnrampOfferUM, linkedMarketingBannerComponent: MarketingBannerComponent) {
val hasBanner = linkedMarketingBannerComponent.hasLinkedBanner(offer.providerId)
// Square the offer's bottom corners so the bottom-rounded banner glues to it as one card.
Offer(offer, roundBottom = !hasBanner)

View file

@ -25,9 +25,11 @@ dependencies {
implementation(projects.domain.account.status)
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
implementation(projects.domain.markets.models)
/** Data */
implementation(projects.data.common)
implementation(tangemDeps.blockchain)
/** Core */
api(projects.core.configToggles)
@ -43,6 +45,7 @@ dependencies {
api(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.material3)
implementation(deps.lifecycle.compose)
/** Other */

View file

@ -5,7 +5,6 @@ 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
@ -29,7 +28,6 @@ internal class ActivateCampaignBottomSheetComponent(
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
private val params: Params,
val onDismiss: () -> Unit,
val onFooterExtraHeightReady: (Dp) -> Unit,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: ActivateCampaignsModel = getOrCreateModel(params)
@ -65,7 +63,6 @@ internal class ActivateCampaignBottomSheetComponent(
ActivateCampaignFooter(
footerUM = state.footerUM,
onFooterTextHeightReady = onFooterExtraHeightReady,
)
}

View file

@ -2,23 +2,31 @@ 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.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
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.SpacerH32
import com.tangem.core.ui.components.bottomsheets.LocalBottomSheetContentScrollable
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
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.components.bottomsheets.modal.DEFAULT_FOOTER_HEIGHT
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.rememberLastNonNull
import com.tangem.core.ui.res.TangemTheme
@ -52,31 +60,44 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
val bottomSheet by bottomSheetSlot.subscribeAsState()
val activeChild = bottomSheet.child?.instance
val displayedChild = rememberLastNonNull(activeChild)
val footerExtraHeight by model.footerExtraHeightState.collectAsStateWithLifecycle()
TangemModalBottomSheetWithFooter<TangemBottomSheetConfigContent.Empty>(
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = activeChild != null,
onDismissRequest = model::onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors3.bg.primary,
footerHeight = DEFAULT_FOOTER_HEIGHT + footerExtraHeight,
containerColor = TangemTheme.colors3.bg.secondary,
type = TangemBottomSheetType.Modal,
onBack = model::onDismiss,
title = {
displayedChild?.Title()
},
content = {
Box(modifier = Modifier.animateContentSize()) {
displayedChild?.Content(modifier = Modifier)
val bottomInset = LocalTangemBottomSheetContentBottomInset.current
val bottomReserve = if (bottomInset > 0.dp) bottomInset else 16.dp
val scrollState = rememberScrollState()
val scrollableSignal = LocalBottomSheetContentScrollable.current
if (scrollableSignal != null) {
LaunchedEffect(scrollState) {
snapshotFlow { scrollState.canScrollForward || scrollState.canScrollBackward }
.collect { canScroll -> scrollableSignal.value = canScroll }
}
}
Column(modifier = Modifier.verticalScroll(state = scrollState)) {
Box(modifier = Modifier.animateContentSize()) {
displayedChild?.Content(modifier = Modifier)
}
if (scrollableSignal?.value != true) SpacerH32()
Spacer(modifier = Modifier.height(bottomReserve))
}
},
footer = {
Box(
modifier = Modifier
.navigationBarsPadding()
.padding(12.dp),
) {
Box(modifier = Modifier.padding(12.dp)) {
displayedChild?.Footer()
}
},
@ -102,7 +123,6 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
appComponentContext = context,
chooseTokenComponentFactory = chooseTokenComponentFactory,
onDismiss = model::onDismiss,
onFooterExtraHeightReady = model::onFooterExtraHeightReady,
params = ActivateCampaignBottomSheetComponent.Params(
campaignType = config.campaignType,
userWalletId = config.userWalletId,

View file

@ -11,11 +11,11 @@ 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.components.token.state.TokenItemState
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.account.supplier.MultiAccountListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
@ -46,6 +46,7 @@ import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
@ -60,7 +61,7 @@ internal class ActivateCampaignsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val multiAccountListSupplier: MultiAccountListSupplier,
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase,
private val urlOpener: UrlOpener,
@GlobalUiMessageSender private val messageSender: UiMessageSender,
@ -198,28 +199,46 @@ internal class ActivateCampaignsModel @Inject constructor(
urlOpener.openUrl(campaignContent.learnMoreUrl)
}
private suspend fun hasMultipleCryptoPortfolioAccounts(): Boolean {
return multiAccountListSupplier.invoke()
.first()
.any { accountList ->
accountList.accounts.filterIsInstance<Account.CryptoPortfolio>().size > 1
}
}
private fun onTokenChosen(result: ChooseTokenResult) {
val selectedToken = result.currency.currency as? CryptoCurrency.Token ?: return
val networkAddress = result.currency.value.networkAddress ?: return
modelScope.launch {
val selectedAccountUM = if (isAccountsModeEnabledUseCase.invokeSync()) {
val selectedAccountUM = if (hasMultipleCryptoPortfolioAccounts()) {
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
// Payment accounts are hidden in the chooser and don't count towards accounts mode,
// so there is no account label to show for them.
is Account.Payment,
is Account.Virtual,
-> null
}
} else {
null
}
val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency)
val tokenItem = TokenItemStateConverter(
appCurrency = appCurrency,
subtitleStateProvider = { status ->
TokenItemState.SubtitleState.TextContent(
value = resourceReference(
R.string.domain_receive_assets_onboarding_network_name,
wrappedList(status.currency.network.name),
),
)
},
).convert(result.currency)
uiState.update { state ->
state.copy(

View file

@ -1,7 +1,5 @@
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
@ -24,8 +22,6 @@ 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
@ -43,9 +39,6 @@ internal class CampaignsModel @Inject constructor(
val bottomSheetNavigation: SlotNavigation<CampaignsBottomSheetConfig> = SlotNavigation()
val footerExtraHeightState: StateFlow<Dp>
field = MutableStateFlow(0.dp)
init {
campaignsService.campaignFlow
.onEach { request ->
@ -89,22 +82,16 @@ internal class CampaignsModel @Inject constructor(
},
)
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))
}
}

View file

@ -78,8 +78,6 @@ internal fun ActivateCampaignContent(um: ActivateCampaignUM, modifier: Modifier
selectedAccount = um.selectedAccount,
onChooseTokenClick = um.onChooseTokenClick,
)
SpacerH32()
}
}
@ -94,8 +92,8 @@ private fun SelectedTokenContent(
Text(
text = stringResourceSafe(R.string.promo_campaign_select_cashback_account),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
modifier = Modifier.fillMaxWidth(),
)

View file

@ -8,8 +8,6 @@ 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
@ -18,7 +16,6 @@ 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
@ -29,28 +26,17 @@ 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,
) {
internal fun ActivateCampaignFooter(footerUM: FooterUM, 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,
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.onSizeChanged {
val termsBlockHeight = with(density) { it.height.toDp() } + 12.dp
onFooterTextHeightReady.invoke(termsBlockHeight)
},
modifier = Modifier.fillMaxWidth(),
)
SpacerH12()
@ -99,7 +85,6 @@ private fun Preview_ActivateCampaignFooter_WithTerms() {
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
onFooterTextHeightReady = {},
)
}
}
@ -114,7 +99,6 @@ private fun Preview_ActivateCampaignFooter_NoTerms() {
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
onFooterTextHeightReady = {},
)
}
}

View file

@ -54,8 +54,6 @@ internal fun AlreadyActivatedCampaignContent(message: TextReference, modifier: M
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
SpacerH32()
}
}

View file

@ -58,8 +58,6 @@ fun CampaignEnrolledMessageContent(message: TextReference, modifier: Modifier =
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerH32()
}
}

View file

@ -65,7 +65,5 @@ fun NotActiveCampaignMessageContent(modifier: Modifier = Modifier) {
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerH32()
}
}

View file

@ -2,10 +2,15 @@ package com.tangem.features.promobanners.impl.model
import androidx.core.net.toUri
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.deeplink.DeeplinkLauncher
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.promobanners.impl.analytics.PromoBannerAnalyticsEvent
@ -26,6 +31,7 @@ import javax.inject.Inject
private typealias ShownBannerKey = Pair<String, Int>
@Suppress("LongParameterList")
@ModelScoped
internal class PromoBannersBlockModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
@ -34,6 +40,7 @@ internal class PromoBannersBlockModel @Inject constructor(
private val deeplinkLauncher: DeeplinkLauncher,
private val analyticsEventHandler: AnalyticsEventHandler,
private val userWalletsListRepository: UserWalletsListRepository,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) : Model() {
private val params = paramsContainer.require<PromoBannersBlockComponent.Params>()
@ -206,7 +213,12 @@ internal class PromoBannersBlockModel @Inject constructor(
private fun onButtonClick(displayId: Int, deeplink: String?) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName))
deeplink?.let { deeplinkLauncher.launch(appendSurveyDisplayId(it, displayId)) }
if (deeplink.isNullOrBlank()) {
uiMessageSender.send(ToastMessage(message = resourceReference(R.string.common_something_went_wrong)))
} else {
deeplinkLauncher.launch(appendSurveyDisplayId(deeplink, displayId))
}
}
private fun appendSurveyDisplayId(deeplink: String, displayId: Int): String {

View file

@ -9,7 +9,8 @@ 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.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
@ -53,7 +54,7 @@ internal class ActivateCampaignsModelTest {
private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk()
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val enrollPromoCampaignUseCase: EnrollPromoCampaignUseCase = mockk()
private val urlOpener: UrlOpener = mockk(relaxed = true)
private val messageSender: UiMessageSender = mockk(relaxed = true)
@ -72,7 +73,7 @@ internal class ActivateCampaignsModelTest {
fun setup() {
clearMocks(
getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase,
multiAccountListSupplier,
enrollPromoCampaignUseCase,
getWalletsUseCase,
messageSender,
@ -258,7 +259,7 @@ internal class ActivateCampaignsModelTest {
}
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
every { getSelectedAppCurrencyUseCase.invokeOrDefault() } returns flowOf(AppCurrency.Default)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList<AccountList>())
coEvery { getPromoCampaignStateUseCase(any(), any(), any()) } returns Either.Left(Throwable())
every { getWalletsUseCase.invokeSync() } returns allWalletIds.map { walletId ->
mockk<UserWallet> { every { this@mockk.walletId } returns walletId }
@ -274,7 +275,7 @@ internal class ActivateCampaignsModelTest {
dispatchers = createTestingCoroutineDispatcherProvider(),
chooseTokenBridgeFactory = chooseTokenBridgeFactory,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
multiAccountListSupplier = multiAccountListSupplier,
enrollPromoCampaignUseCase = enrollPromoCampaignUseCase,
urlOpener = urlOpener,
messageSender = messageSender,

View file

@ -1,8 +1,6 @@
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
@ -108,11 +106,10 @@ internal class CampaignsModelTest {
}
@Test
fun `GIVEN footer height set WHEN onAlreadyActivated THEN analytics sent and height reset`() = runTest {
fun `WHEN onAlreadyActivated THEN analytics sent`() = runTest {
// Arrange
val model = createModel(campaignFlow = emptyFlow())
advanceUntilIdle()
model.onFooterExtraHeightReady(100.dp)
// Act
model.onAlreadyActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
@ -121,37 +118,20 @@ internal class CampaignsModelTest {
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 {
fun `WHEN onActivated THEN no analytics sent`() = 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()
}

View file

@ -69,6 +69,7 @@ dependencies {
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.marketing.models)
implementation(projects.domain.onramp.models)
/** Common */
implementation(projects.common.ui)

View file

@ -167,6 +167,9 @@ private fun StakingScreenContent(
amountState = uiState.amountState,
clickIntents = uiState.clickIntents,
modifier = Modifier.background(TangemTheme.colors.background.secondary),
extraContent = {
marketingBannerComponent.Content(Modifier.fillMaxWidth())
},
)
StakingStep.Confirmation -> StakingConfirmationContent(
amountState = uiState.amountState,

View file

@ -110,6 +110,7 @@ internal class TangemPayCardPageScreenComponent(
paymentAccountAddress = navigation.paymentAccountAddress,
onDismiss = model.bottomSheetNavigation::dismiss,
onShowDetails = model::onShowVirtualAccountRequisites,
onShowBankingDetailsError = model::showVaBankingDetailsError,
onOrderCreated = model::onVirtualAccountOrderCreated,
),
)
@ -126,6 +127,15 @@ internal class TangemPayCardPageScreenComponent(
onFieldCopied = model::onVaFieldCopied,
),
)
is TangemPayCardNavigation.VaBankingDetailsError -> TangemPayVaBankingDetailsErrorComponent(
appComponentContext = context,
params = TangemPayVaBankingDetailsErrorComponent.Params(
userWalletId = navigation.userWalletId,
onDismiss = model.bottomSheetNavigation::dismiss,
onContactSupport = model::onContactSupportClicked,
onResolved = model::onVaBankingDetailsResolved,
),
)
is TangemPayCardNavigation.Receive -> tokenReceiveComponentFactory.create(
context = context,
params = TokenReceiveComponent.Params(

View file

@ -149,6 +149,7 @@ internal class TangemPayDetailsComponent(
paymentAccountAddress = navigation.paymentAccountAddress,
onDismiss = model.bottomSheetNavigation::dismiss,
onShowDetails = model::onShowVirtualAccountRequisites,
onShowBankingDetailsError = model::showVaBankingDetailsError,
onOrderCreated = model::onVirtualAccountOrderCreated,
),
)
@ -165,6 +166,15 @@ internal class TangemPayDetailsComponent(
onFieldCopied = model::onVaFieldCopied,
),
)
is TangemPayDetailsNavigation.VaBankingDetailsError -> TangemPayVaBankingDetailsErrorComponent(
appComponentContext = context,
params = TangemPayVaBankingDetailsErrorComponent.Params(
userWalletId = navigation.userWalletId,
onDismiss = model.bottomSheetNavigation::dismiss,
onContactSupport = model::onContactSupportClicked,
onResolved = model::onVaBankingDetailsResolved,
),
)
is TangemPayDetailsNavigation.IssueAdditionalCard -> TangemPayIssueAdditionalCardComponent(
appComponentContext = context,
params = TangemPayIssueAdditionalCardComponent.Params(

View file

@ -0,0 +1,44 @@
package com.tangem.features.tangempay.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.tangempay.model.TangemPayVaBankingDetailsErrorModel
import com.tangem.features.tangempay.ui.TangemPayVaBankingDetailsErrorBottomSheet
/**
* Error bottom sheet shown when VA bank credentials fail to load ([VirtualAccountOnramp.BankCredentialsError]).
*
* "Try again" re-fetches the payment account status while showing a loader on the button; on success the
* resolved on-ramp is handed back via [Params.onResolved] (the parent opens the bank-transfer sheet), otherwise
* the error stays visible with the loader cleared.
*/
internal class TangemPayVaBankingDetailsErrorComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
private val model: TangemPayVaBankingDetailsErrorModel = getOrCreateModel(params = params)
override fun dismiss() {
model.onDismiss()
}
@Composable
override fun BottomSheet() {
val state by model.uiState.collectAsStateWithLifecycle()
TangemPayVaBankingDetailsErrorBottomSheet(state = state)
}
data class Params(
val userWalletId: UserWalletId,
val onDismiss: () -> Unit,
val onContactSupport: () -> Unit,
val onResolved: (VirtualAccountOnramp) -> Unit,
)
}

View file

@ -38,6 +38,7 @@ internal class TangemPayVirtualAccountDepositComponent(
val paymentAccountAddress: String,
val onDismiss: () -> Unit,
val onShowDetails: (VirtualAccountOnramp.Available) -> Unit,
val onShowBankingDetailsError: () -> Unit,
val onOrderCreated: () -> Unit,
)
}

View file

@ -53,6 +53,11 @@ internal interface TangemPayModelModule {
@ClassKey(TangemPayVirtualAccountDepositModel::class)
fun bindTangemPayVirtualAccountDepositModel(model: TangemPayVirtualAccountDepositModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayVaBankingDetailsErrorModel::class)
fun bindTangemPayVaBankingDetailsErrorModel(model: TangemPayVaBankingDetailsErrorModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayViewPinModel::class)

View file

@ -48,6 +48,11 @@ internal sealed class TangemPayCardNavigation {
val bankCredentials: BankCredentials,
) : TangemPayCardNavigation()
@Serializable
data class VaBankingDetailsError(
val userWalletId: UserWalletId,
) : TangemPayCardNavigation()
@Serializable
data class Receive(val config: TokenReceiveConfig) : TangemPayCardNavigation()
}

View file

@ -39,6 +39,11 @@ internal sealed class TangemPayDetailsNavigation {
val bankCredentials: BankCredentials,
) : TangemPayDetailsNavigation()
@Serializable
data class VaBankingDetailsError(
val userWalletId: UserWalletId,
) : TangemPayDetailsNavigation()
@Serializable
data class TransactionDetails(
val transaction: TangemPayTxHistoryItem,

View file

@ -0,0 +1,17 @@
package com.tangem.features.tangempay.entity
import androidx.compose.runtime.Immutable
/**
* UI state for the "couldn't load banking details" bottom sheet (VA MVP0, TWI-1638).
*
* @property isRetryLoading whether the "Try again" button shows a loader while the payment account status
* is being re-fetched. While `true` both actions are disabled.
*/
@Immutable
internal data class TangemPayVaBankingDetailsErrorUM(
val isRetryLoading: Boolean,
val onRetryClick: () -> Unit,
val onContactSupportClick: () -> Unit,
val onDismiss: () -> Unit,
)

View file

@ -7,6 +7,7 @@ import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -24,6 +25,9 @@ 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_arrow_refresh_20
import com.tangem.core.ui.test.TangemPayTestTags
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.AccountStatus
@ -67,16 +71,17 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
import com.tangem.core.ui.R as CoreUiR
@Suppress("LongParameterList", "LargeClass")
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
@Stable
@ModelScoped
internal class TangemPayCardPageModel @Inject constructor(
paramsContainer: ParamsContainer,
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val analytics: AnalyticsEventHandler,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val uiMessageSender: UiMessageSender,
private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase,
@ -446,7 +451,19 @@ internal class TangemPayCardPageModel @Inject constructor(
override fun onClickBankTransfer() {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
val onramp = loaded.virtualAccount ?: return
when (val onramp = loaded.virtualAccount) {
null -> return
VirtualAccountOnramp.Processing -> showVaPreparing()
// BankCredentialsError opens the deposit intro first; the retryable error sheet is shown from
// its "Show details" action (see onShowDetailsClick).
is VirtualAccountOnramp.Available,
VirtualAccountOnramp.Eligible,
is VirtualAccountOnramp.BankCredentialsError,
-> openVirtualAccountDeposit(onramp, loaded)
}
}
private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) {
analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked())
bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate(
@ -458,6 +475,43 @@ internal class TangemPayCardPageModel @Inject constructor(
)
}
fun showVaBankingDetailsError() {
bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate(
TangemPayCardNavigation.VaBankingDetailsError(userWalletId = userWalletId),
)
}
private fun showVaPreparing() {
bottomSheetNavigation.dismiss()
uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage())
}
fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) {
when (onramp) {
// Bank credentials just loaded on retry — show the requisites straight away ([REDACTED_TASK_KEY]),
// instead of the intro deposit sheet that would need another "Show details" tap.
is VirtualAccountOnramp.Available -> onShowVirtualAccountRequisites(onramp)
else -> {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
openVirtualAccountDeposit(onramp, loaded)
}
}
}
fun onContactSupportClicked() {
analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay))
val customerId = currentStatus.value.ifLoadedOrNull { it.customerId } ?: return
modelScope.launch {
sendFeedbackEmailUseCase.invoke(
type = FeedbackEmailType.Visa.FeatureIsBeta(
walletMetaInfo = WalletMetaInfo(userWalletId = userWalletId),
customerId = customerId,
),
)
}
}
fun onVirtualAccountOrderCreated() {
analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation())
bottomSheetNavigation.dismiss()

View file

@ -64,7 +64,7 @@ import javax.inject.Inject
@ModelScoped
internal class TangemPayDetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
override val dispatchers: CoroutineDispatcherProvider,
private val analytics: AnalyticsEventHandler,
private val router: Router,
@ -355,7 +355,19 @@ internal class TangemPayDetailsModel @Inject constructor(
override fun onClickBankTransfer() {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
val onramp = loaded.virtualAccount ?: return
when (val onramp = loaded.virtualAccount) {
null -> return
VirtualAccountOnramp.Processing -> showVaPreparing()
// BankCredentialsError opens the deposit intro first; the retryable error sheet is shown from
// its "Show details" action (see onShowDetailsClick).
is VirtualAccountOnramp.Available,
VirtualAccountOnramp.Eligible,
is VirtualAccountOnramp.BankCredentialsError,
-> openVirtualAccountDeposit(onramp, loaded)
}
}
private fun openVirtualAccountDeposit(onramp: VirtualAccountOnramp, loaded: PaymentAccountStatusValue.Loaded) {
analytics.send(TangemPayAnalyticsEvents.VaTopupButtonClicked())
bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate(
@ -367,6 +379,30 @@ internal class TangemPayDetailsModel @Inject constructor(
)
}
fun showVaBankingDetailsError() {
bottomSheetNavigation.dismiss()
bottomSheetNavigation.activate(
TangemPayDetailsNavigation.VaBankingDetailsError(userWalletId = userWalletId),
)
}
private fun showVaPreparing() {
bottomSheetNavigation.dismiss()
uiMessageSender.send(message = TangemPayMessagesFactory.createVaPreparingMessage())
}
fun onVaBankingDetailsResolved(onramp: VirtualAccountOnramp) {
when (onramp) {
// Bank credentials just loaded on retry — show the requisites straight away ([REDACTED_TASK_KEY]),
// instead of the intro deposit sheet that would need another "Show details" tap.
is VirtualAccountOnramp.Available -> onShowVirtualAccountRequisites(onramp)
else -> {
val loaded = currentStatus.value.ifLoadedOrNull { it } ?: return
openVirtualAccountDeposit(onramp, loaded)
}
}
}
fun onVirtualAccountOrderCreated() {
analytics.send(TangemPayAnalyticsEvents.VaSuccessScreenActivation())
bottomSheetNavigation.dismiss()

View file

@ -0,0 +1,63 @@
package com.tangem.features.tangempay.model
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.features.tangempay.components.TangemPayVaBankingDetailsErrorComponent
import com.tangem.features.tangempay.entity.TangemPayVaBankingDetailsErrorUM
import com.tangem.features.tangempay.utils.ifLoadedOrNull
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayVaBankingDetailsErrorModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
) : Model() {
private val params = paramsContainer.require<TangemPayVaBankingDetailsErrorComponent.Params>()
val uiState: StateFlow<TangemPayVaBankingDetailsErrorUM>
field = MutableStateFlow(
TangemPayVaBankingDetailsErrorUM(
isRetryLoading = false,
onRetryClick = ::onRetryClick,
onContactSupportClick = params.onContactSupport,
onDismiss = ::onDismiss,
),
)
fun onDismiss() {
params.onDismiss()
}
private fun onRetryClick() {
if (uiState.value.isRetryLoading) return
uiState.update { it.copy(isRetryLoading = true) }
modelScope.launch {
paymentAccountStatusFetcher.invoke(params.userWalletId)
val onramp = paymentAccountStatusSupplier.invoke(params.userWalletId)
.first()
.ifLoadedOrNull { it.virtualAccount }
when (onramp) {
is VirtualAccountOnramp.Available,
VirtualAccountOnramp.Eligible,
-> params.onResolved(onramp)
// Still failing (BankCredentialsError) or unavailable — keep the sheet, clear the loader.
else -> uiState.update { it.copy(isRetryLoading = false) }
}
}
}
}

View file

@ -81,6 +81,9 @@ internal class TangemPayVirtualAccountDepositModel @Inject constructor(
analytics.send(TangemPayAnalyticsEvents.VaShowDetailsFirstTimeClicked())
createVirtualAccountOrder()
}
VirtualAccountOnramp.BankCredentialsError -> params.onShowBankingDetailsError()
// Processing never reaches this sheet (the Preparing message is shown instead); defensive.
VirtualAccountOnramp.Processing -> onDismiss()
}
}

View file

@ -0,0 +1,171 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
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.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
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.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.button.Close
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
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_error_28
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayVaBankingDetailsErrorUM
@Composable
internal fun TangemPayVaBankingDetailsErrorBottomSheet(state: TangemPayVaBankingDetailsErrorUM) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = state.onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors3.bg.secondary,
title = {
TangemTopBar(
type = TangemTopBarType.BottomSheet,
title = null,
endContent = { TangemButton.Close(onClick = state.onDismiss) },
)
},
content = { _ -> Content(state) },
)
}
@Composable
private fun Content(state: TangemPayVaBankingDetailsErrorUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens2.x4)
.padding(bottom = TangemTheme.dimens2.x4),
horizontalAlignment = Alignment.CenterHorizontally,
) {
WarningIcon(modifier = Modifier.padding(top = TangemTheme.dimens2.x4))
TitleText(
text = resourceReference(R.string.tangempay_va_banking_details_error_title),
modifier = Modifier.padding(top = TangemTheme.dimens2.x8),
)
SubtitleText(
text = resourceReference(R.string.tangempay_va_banking_details_error_description),
modifier = Modifier.padding(top = TangemTheme.dimens2.x2),
)
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens2.x8),
text = resourceReference(R.string.common_contact_support),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X12,
isEnabled = !state.isRetryLoading,
onClick = state.onContactSupportClick,
)
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens2.x2),
text = resourceReference(R.string.common_retry),
variant = TangemButton.Variant.Primary,
size = TangemButton.Size.X12,
isLoading = state.isRetryLoading,
isEnabled = !state.isRetryLoading,
onClick = state.onRetryClick,
)
}
}
@Composable
private fun WarningIcon(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens2.x20)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.status.warningSubtle),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x7),
imageVector = Icons.ic_error_28,
contentDescription = null,
tint = TangemTheme.colors3.icon.status.warning,
)
}
}
@Composable
private fun TitleText(text: TextReference, modifier: Modifier = Modifier) {
Text(
modifier = modifier.fillMaxWidth(),
text = text.resolveReference(),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
)
}
@Composable
private fun SubtitleText(text: TextReference, modifier: Modifier = Modifier) {
Text(
modifier = modifier.fillMaxWidth(),
text = text.resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center,
)
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPayVaBankingDetailsErrorPreview(
@PreviewParameter(VaBankingDetailsErrorPreviewProvider::class) state: TangemPayVaBankingDetailsErrorUM,
) {
TangemThemePreviewRedesign {
Content(
state = state,
modifier = Modifier.background(TangemTheme.colors3.bg.secondary),
)
}
}
private class VaBankingDetailsErrorPreviewProvider :
CollectionPreviewParameterProvider<TangemPayVaBankingDetailsErrorUM>(
collection = listOf(
TangemPayVaBankingDetailsErrorUM(
isRetryLoading = false,
onRetryClick = {},
onContactSupportClick = {},
onDismiss = {},
),
TangemPayVaBankingDetailsErrorUM(
isRetryLoading = true,
onRetryClick = {},
onContactSupportClick = {},
onDismiss = {},
),
),
)

View file

@ -5,8 +5,10 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -73,6 +75,7 @@ private fun DepositContent(state: TangemPayVirtualAccountDepositUM, modifier: Mo
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens2.x4)
.padding(bottom = TangemTheme.dimens2.x4),
horizontalAlignment = Alignment.CenterHorizontally,
@ -255,10 +258,10 @@ private fun UsdcIcon(modifier: Modifier = Modifier) {
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x4),
modifier = Modifier.size(TangemTheme.dimens2.x6),
painter = painterResource(CoreUiR.drawable.ic_polygon_22),
contentDescription = null,
tint = TangemTheme.colors3.icon.inverse,
tint = TangemTheme.colors3.icon.staticDark,
)
}
}

View file

@ -5,7 +5,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
@ -16,6 +15,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
@ -25,6 +25,7 @@ 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
import com.tangem.features.tangempay.details.impl.R
import dev.chrisbanes.haze.HazeStyle
private const val DEFAULT_FADE_COLOR = 0xFF9FC824
private val BlurRadius = 192.dp
@ -46,7 +47,7 @@ internal fun TangemPaySuccessScreenWrapper(
Box(
modifier = Modifier
.matchParentSize()
.blur(BlurRadius)
.hazeForegroundEffectTangem(style = HazeStyle(blurRadius = BlurRadius, tint = null))
.drawBehind {
val w = size.width
drawRect(

View file

@ -169,6 +169,23 @@ internal object TangemPayMessagesFactory {
)
}
fun createVaPreparingMessage(): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {
icon(R.drawable.ic_clock_24) {
type = MessageBottomSheetUM.Icon.Type.Informative
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Informative
}
title = TextReference.Res(R.string.tangempay_bank_transfer_success_title)
body = TextReference.Res(R.string.tangempay_bank_transfer_success_subtitle)
}
secondaryButton {
text = resourceReference(R.string.common_got_it)
onClick { closeBs() }
}
}
}
fun createFutureFeature(onGotItClick: () -> Unit): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {

View file

@ -1,6 +1,8 @@
package com.tangem.features.tangempay.utils
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.BankCredentials
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent.RequisitesRow
/**
@ -16,22 +18,32 @@ internal const val VA_DAILY_DEPOSIT_LIMIT_PLACEHOLDER = "$10,000"
*/
internal fun BankCredentials.toRequisitesRows(): List<RequisitesRow> = listOf(
RequisitesRow(
title = "Beneficiary name and address",
titleForShare = "Beneficiary name and address",
value = "$beneficiaryName\n$beneficiaryAddress",
title = resourceReference(R.string.virtual_account_requisites_beneficiary_name),
titleForShare = "Beneficiary name",
value = beneficiaryName,
),
RequisitesRow(
title = "Bank name and address",
titleForShare = "Bank name and address",
value = "$beneficiaryBankName\n$beneficiaryBankAddress",
title = resourceReference(R.string.virtual_account_requisites_beneficiary_address),
titleForShare = "Beneficiary address",
value = beneficiaryAddress,
),
RequisitesRow(
title = "Account number",
title = resourceReference(R.string.virtual_account_requisites_bank_name),
titleForShare = "Bank name",
value = beneficiaryBankName,
),
RequisitesRow(
title = resourceReference(R.string.virtual_account_requisites_bank_address),
titleForShare = "Bank address",
value = beneficiaryBankAddress,
),
RequisitesRow(
title = resourceReference(R.string.virtual_account_requisites_account_number),
titleForShare = "Account number",
value = accountNumber,
),
RequisitesRow(
title = "Routing number",
title = resourceReference(R.string.virtual_account_requisites_routing_number),
titleForShare = "Routing number",
value = routingNumber,
),

View file

@ -0,0 +1,132 @@
package com.tangem.features.tangempay.model
import arrow.core.Either
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.features.tangempay.components.TangemPayVaBankingDetailsErrorComponent
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.CompletableDeferred
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
internal class TangemPayVaBankingDetailsErrorModelTest {
private val userWalletId = UserWalletId("1234567890ABCDEF")
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk()
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
private val onDismiss: () -> Unit = mockk(relaxed = true)
private val onContactSupport: () -> Unit = mockk(relaxed = true)
private val onResolved: (VirtualAccountOnramp) -> Unit = mockk(relaxed = true)
@BeforeEach
fun resetMocks() {
clearMocks(paymentAccountStatusFetcher, paymentAccountStatusSupplier, onDismiss, onContactSupport, onResolved)
}
@Test
fun `GIVEN refetch resolves to available WHEN retry THEN onResolved called`() = runTest {
// Arrange
val onramp = VirtualAccountOnramp.Available(productInstanceId = "pi_1", bankCredentials = mockk())
coEvery { paymentAccountStatusFetcher.invoke(userWalletId) } returns Unit.right()
stubSupplier(onramp)
val model = createModel()
// Act
model.uiState.value.onRetryClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) { onResolved(onramp) }
}
@Test
fun `GIVEN refetch still fails WHEN retry THEN onResolved not called and loading reset`() = runTest {
// Arrange
coEvery { paymentAccountStatusFetcher.invoke(userWalletId) } returns Unit.right()
stubSupplier(VirtualAccountOnramp.BankCredentialsError)
val model = createModel()
// Act
model.uiState.value.onRetryClick()
advanceUntilIdle()
// Assert
verify { onResolved wasNot Called }
assertThat(model.uiState.value.isRetryLoading).isFalse()
}
@Test
fun `GIVEN refetch in progress WHEN retry twice THEN fetch invoked once and loading shown`() = runTest {
// Arrange
val pending = CompletableDeferred<Either<Throwable, Unit>>()
coEvery { paymentAccountStatusFetcher.invoke(userWalletId) } coAnswers { pending.await() }
stubSupplier(VirtualAccountOnramp.BankCredentialsError)
val model = createModel()
// Act
model.uiState.value.onRetryClick() // starts loading, fetch suspends
advanceUntilIdle()
model.uiState.value.onRetryClick() // gated by isRetryLoading — must be ignored
advanceUntilIdle()
// Assert
assertThat(model.uiState.value.isRetryLoading).isTrue()
coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(userWalletId) }
pending.complete(Unit.right()) // let the in-flight call finish cleanly
advanceUntilIdle()
}
private fun stubSupplier(onramp: VirtualAccountOnramp) {
val loaded = mockk<PaymentAccountStatusValue.Loaded>()
every { loaded.virtualAccount } returns onramp
val status = mockk<AccountStatus.Payment>()
every { status.value } returns loaded
every { paymentAccountStatusSupplier.invoke(userWalletId) } returns flowOf(status)
}
private fun TestScope.createModel() = TangemPayVaBankingDetailsErrorModel(
paramsContainer = MutableParamsContainer(
TangemPayVaBankingDetailsErrorComponent.Params(
userWalletId = userWalletId,
onDismiss = onDismiss,
onContactSupport = onContactSupport,
onResolved = onResolved,
),
),
dispatchers = createTestingCoroutineDispatcherProvider(),
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
)
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -40,12 +40,20 @@ internal class TangemPayVirtualAccountDepositModelTest {
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val createVirtualAccountOrderUseCase: CreateVirtualAccountOrderUseCase = mockk()
private val onShowDetails: (VirtualAccountOnramp.Available) -> Unit = mockk(relaxed = true)
private val onShowBankingDetailsError: () -> Unit = mockk(relaxed = true)
private val onOrderCreated: () -> Unit = mockk(relaxed = true)
private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
@BeforeEach
fun resetMocks() {
clearMocks(createVirtualAccountOrderUseCase, onShowDetails, onOrderCreated, uiMessageSender, analytics)
clearMocks(
createVirtualAccountOrderUseCase,
onShowDetails,
onShowBankingDetailsError,
onOrderCreated,
uiMessageSender,
analytics,
)
}
@Test
@ -65,6 +73,21 @@ internal class TangemPayVirtualAccountDepositModelTest {
verify(exactly = 1) { analytics.send(ofType<TangemPayAnalyticsEvents.VaShowDetailsClicked>()) }
}
@Test
fun `GIVEN bank credentials error WHEN show details THEN shows banking details error sheet`() = runTest {
// Arrange
val model = createModel(VirtualAccountOnramp.BankCredentialsError)
// Act
model.uiState.value.onShowDetailsClick()
advanceUntilIdle()
// Assert
verify(exactly = 1) { onShowBankingDetailsError() }
verify(exactly = 0) { onShowDetails(any()) }
coVerify(exactly = 0) { createVirtualAccountOrderUseCase(any(), any()) }
}
@Test
fun `GIVEN eligible and create succeeds WHEN show details THEN order created and loading reset`() = runTest {
// Arrange
@ -130,6 +153,7 @@ internal class TangemPayVirtualAccountDepositModelTest {
paymentAccountAddress = paymentAccountAddress,
onDismiss = {},
onShowDetails = onShowDetails,
onShowBankingDetailsError = onShowBankingDetailsError,
onOrderCreated = onOrderCreated,
),
),

View file

@ -376,59 +376,6 @@ internal data class TangemTokenIconStory(
enum class UiStateVariant { Token, Shimmer, Error }
}
internal data class TangemGlowRingStory(
val variant: TangemGlowRing.Variant,
val quality: TangemGlowRing.Quality,
val background: Background,
val isAnimated: Boolean,
val onVariantChange: (TangemGlowRing.Variant) -> Unit,
val onQualityChange: (TangemGlowRing.Quality) -> Unit,
val onBackgroundChange: (Background) -> Unit,
val onAnimatedToggle: () -> Unit,
) : DsStoryBookPage {
/** Backdrop the glow-ring preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgInverse("bg.inverse"),
}
}
@Suppress("BooleanPropertyNaming")
internal data class TangemMessageBannerStory(
val variant: TangemMessageBanner.Variant,
val contentAlign: TangemMessageBanner.ContentAlign,
val hasGlowRing: Boolean,
val hasDescription: Boolean,
val hasSecondaryButton: Boolean,
val hasPrimaryButton: Boolean,
val hasCloseButton: Boolean,
val hasSlotStart: Boolean,
val hasSlotEnd: Boolean,
val hasExtraContent: Boolean,
val background: Background,
val onVariantChange: (TangemMessageBanner.Variant) -> Unit,
val onContentAlignChange: (TangemMessageBanner.ContentAlign) -> Unit,
val onGlowRingToggle: () -> Unit,
val onDescriptionToggle: () -> Unit,
val onSecondaryButtonToggle: () -> Unit,
val onPrimaryButtonToggle: () -> Unit,
val onCloseButtonToggle: () -> Unit,
val onSlotStartToggle: () -> Unit,
val onSlotEndToggle: () -> Unit,
val onExtraContentToggle: () -> Unit,
val onBackgroundChange: (Background) -> Unit,
) : DsStoryBookPage {
/** Backdrop the banner preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgInverse("bg.inverse"),
}
}
internal data class TextStyleStory(
val style: Style,
val textScale: Float,
@ -505,6 +452,61 @@ internal data class TangemTokenRowMarketStory(
val onLongTitleToggle: () -> Unit,
) : DsStoryBookPage
internal data class TangemGlowRingStory(
val variant: TangemGlowRing.Variant,
val quality: TangemGlowRing.Quality,
val background: Background,
val isAnimated: Boolean,
val onVariantChange: (TangemGlowRing.Variant) -> Unit,
val onQualityChange: (TangemGlowRing.Quality) -> Unit,
val onBackgroundChange: (Background) -> Unit,
val onAnimatedToggle: () -> Unit,
) : DsStoryBookPage {
/** Backdrop the glow-ring preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgInverse("bg.inverse"),
}
}
@Suppress("BooleanPropertyNaming")
internal data class TangemMessageBannerStory(
val variant: TangemMessageBanner.Variant,
val contentAlign: TangemMessageBanner.ContentAlign,
val hasGlowRing: Boolean,
val hasDescription: Boolean,
val hasSecondaryButton: Boolean,
val hasPrimaryButton: Boolean,
val hasCloseButton: Boolean,
val hasSlotStart: Boolean,
val hasSlotEnd: Boolean,
val hasExtraContent: Boolean,
val isClickable: Boolean,
val background: Background,
val onVariantChange: (TangemMessageBanner.Variant) -> Unit,
val onContentAlignChange: (TangemMessageBanner.ContentAlign) -> Unit,
val onGlowRingToggle: () -> Unit,
val onDescriptionToggle: () -> Unit,
val onSecondaryButtonToggle: () -> Unit,
val onPrimaryButtonToggle: () -> Unit,
val onCloseButtonToggle: () -> Unit,
val onSlotStartToggle: () -> Unit,
val onSlotEndToggle: () -> Unit,
val onExtraContentToggle: () -> Unit,
val onClickableToggle: () -> Unit,
val onBackgroundChange: (Background) -> Unit,
) : DsStoryBookPage {
/** Backdrop the banner preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgInverse("bg.inverse"),
}
}
internal data class TangemBadgeV2Story(
val variant: TangemBadge.Variant,
val status: TangemBadge.Status,

View file

@ -18,6 +18,7 @@ internal fun StateUpdater<TangemMessageBannerStory>.build(): TangemMessageBanner
hasSlotStart = true,
hasSlotEnd = true,
hasExtraContent = true,
isClickable = false,
background = Background.BgSecondary,
onVariantChange = { variant -> updateStory { it.copy(variant = variant) } },
onContentAlignChange = { align -> updateStory { it.copy(contentAlign = align) } },
@ -29,6 +30,7 @@ internal fun StateUpdater<TangemMessageBannerStory>.build(): TangemMessageBanner
onSlotStartToggle = { updateStory { it.copy(hasSlotStart = !it.hasSlotStart) } },
onSlotEndToggle = { updateStory { it.copy(hasSlotEnd = !it.hasSlotEnd) } },
onExtraContentToggle = { updateStory { it.copy(hasExtraContent = !it.hasExtraContent) } },
onClickableToggle = { updateStory { it.copy(isClickable = !it.isClickable) } },
onBackgroundChange = { background -> updateStory { it.copy(background = background) } },
)
}

View file

@ -95,6 +95,11 @@ private fun PreviewBanner(state: TangemMessageBannerStory) {
variant = state.variant,
contentAlign = state.contentAlign,
showGlowRing = state.hasGlowRing,
onClick = if (state.isClickable) {
{}
} else {
null
},
title = stringReference("Would you predict?"),
description = if (state.hasDescription) {
stringReference("France will win FIFA 2026")
@ -233,6 +238,11 @@ private fun Toggles(state: TangemMessageBannerStory) {
ToggleRow(label = "slotStart", checked = state.hasSlotStart, onToggle = state.onSlotStartToggle)
ToggleRow(label = "slotEnd", checked = state.hasSlotEnd, onToggle = state.onSlotEndToggle)
ToggleRow(label = "extraContent", checked = state.hasExtraContent, onToggle = state.onExtraContentToggle)
ToggleRow(
label = "clickable (no buttons only)",
checked = state.isClickable,
onToggle = state.onClickableToggle,
)
}
}
}

View file

@ -15,4 +15,7 @@ dependencies {
/** Domain */
api(projects.domain.models)
/** Compose */
implementation(deps.compose.runtime)
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.virtualaccount.details.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.wallet.UserWalletId
/**
@ -25,7 +26,7 @@ interface VirtualAccountAddFundsBottomSheetComponent : ComposableBottomSheetComp
)
data class RequisitesRow(
val title: String,
val title: TextReference,
val titleForShare: String,
val value: String,
)

View file

@ -57,22 +57,17 @@ internal class VirtualAccountMainModel @Inject constructor(
private fun buildRequisites(details: VirtualAccountDepositDetails) = listOf(
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
title = "Beneficiary name and address",
titleForShare = "Beneficiary name and address",
value = "${details.beneficiaryName}\n${details.beneficiaryAddress}",
title = resourceReference(R.string.virtual_account_requisites_beneficiary_name),
titleForShare = "Beneficiary name",
value = details.beneficiaryName,
),
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
title = "Bank name and address",
titleForShare = "Bank name and address",
value = "${details.bankName}\n${details.bankAddress}",
),
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
title = "Account number",
title = resourceReference(R.string.virtual_account_requisites_account_number),
titleForShare = "Account number",
value = details.accountNumber,
),
VirtualAccountAddFundsBottomSheetComponent.RequisitesRow(
title = "Routing number",
title = resourceReference(R.string.virtual_account_requisites_routing_number),
titleForShare = "Routing number",
value = details.routingNumber,
),

View file

@ -5,8 +5,10 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -110,6 +112,7 @@ private fun DetailsContent(content: VirtualAccountAddFundsUM.Content.Details, mo
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = TangemTheme.dimens2.x4),
) {
content.items.forEachIndexed { index, item ->

View file

@ -7,7 +7,6 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.virtualaccount.details.component.VirtualAccountAddFundsBottomSheetComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
@ -55,25 +54,31 @@ internal class VirtualAccountAddFundsModel @Inject constructor(
uiState.update { state -> state.copy(content = buildDetailsContent()) }
}
private fun buildDetailsContent() = VirtualAccountAddFundsUM.Content.Details(
items = params.requisites
.map { detailItem(label = it.title, value = it.value) }
.toImmutableList(),
dailyLimit = params.dailyDepositLimit,
onShareClick = {
params.onShareClicked()
shareManager.shareText(buildShareText())
},
)
private fun buildDetailsContent(): VirtualAccountAddFundsUM.Content.Details {
return VirtualAccountAddFundsUM.Content.Details(
items = params.requisites
.map(::detailItem)
.toImmutableList(),
dailyLimit = params.dailyDepositLimit,
onShareClick = {
params.onShareClicked()
shareManager.shareText(buildShareText())
},
)
}
private fun detailItem(label: String, value: String) = VirtualAccountAddFundsUM.DetailItem(
label = stringReference(label),
value = value,
onCopyClick = {
params.onFieldCopied(label)
clipboardManager.setText(text = value, isSensitive = true)
},
)
private fun detailItem(
requisitesRow: VirtualAccountAddFundsBottomSheetComponent.RequisitesRow,
): VirtualAccountAddFundsUM.DetailItem {
return VirtualAccountAddFundsUM.DetailItem(
label = requisitesRow.title,
value = requisitesRow.value,
onCopyClick = {
params.onFieldCopied(requisitesRow.titleForShare)
clipboardManager.setText(text = requisitesRow.value, isSensitive = true)
},
)
}
private fun buildShareText(): String {
return buildString {

View file

@ -61,6 +61,7 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.marketing.models)
implementation(projects.domain.onramp.models)
implementation(projects.libs.crypto)
/** Compose */