Updated on 2026-08-14
This commit is contained in:
commit
e6ec52e35a
83 changed files with 1846 additions and 301 deletions
|
|
@ -44,6 +44,7 @@ dependencies {
|
|||
|
||||
/** Tangem libraries */
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.blockchain)
|
||||
|
||||
/** Common */
|
||||
api(projects.common.routing)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ internal class DefaultOnrampMainComponent @AssistedInject constructor(
|
|||
onDismiss = model.bottomSheetNavigation::dismiss,
|
||||
openRedirectPage = params.openRedirectPage,
|
||||
amountCurrencyCode = config.amountCurrencyCode,
|
||||
marketingBannerComponent = marketingBannerComponent,
|
||||
linkedMarketingBannerComponent = linkedMarketingBannerComponent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,8 +54,6 @@ internal fun AlreadyActivatedCampaignContent(message: TextReference, modifier: M
|
|||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,6 @@ fun CampaignEnrolledMessageContent(message: TextReference, modifier: Modifier =
|
|||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,5 @@ fun NotActiveCampaignMessageContent(modifier: Modifier = Modifier) {
|
|||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
SpacerH32()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ internal class TangemPayVirtualAccountDepositComponent(
|
|||
val paymentAccountAddress: String,
|
||||
val onDismiss: () -> Unit,
|
||||
val onShowDetails: (VirtualAccountOnramp.Available) -> Unit,
|
||||
val onShowBankingDetailsError: () -> Unit,
|
||||
val onOrderCreated: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) } },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,7 @@ dependencies {
|
|||
|
||||
/** Domain */
|
||||
api(projects.domain.models)
|
||||
|
||||
/** Compose */
|
||||
implementation(deps.compose.runtime)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue