Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-19 16:07:08 +05:00
parent a0e5656330
commit d82dbdd1e6
12 changed files with 256 additions and 129 deletions

View file

@ -40,6 +40,18 @@ import kotlin.time.Duration.Companion.minutes
private const val TAG = "PaymentAccountStatusFetcher" private const val TAG = "PaymentAccountStatusFetcher"
/**
* Reorders cards to match [previousOrder] (by [TangemPayCard.id]), appending any card absent from it at the
* end while preserving the relative order among the new ones. Keeps the card layout stable when the backend
* reorders `productInstances` (e.g. after a rename bumps `updated_at`). Returns the receiver unchanged when
* [previousOrder] is empty (first load backend order).
*/
internal fun List<TangemPayCard>.stableOrder(previousOrder: List<String>): List<TangemPayCard> {
if (previousOrder.isEmpty()) return this
val indexById = previousOrder.withIndex().associate { (index, id) -> id to index }
return sortedBy { indexById[it.id] ?: Int.MAX_VALUE }
}
@Suppress("LongParameterList", "LargeClass") @Suppress("LongParameterList", "LargeClass")
internal class DefaultPaymentAccountStatusFetcher @Inject constructor( internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val paymentAccountStatusesStore: PaymentAccountStatusesStore, private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
@ -365,13 +377,18 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
// placeholder for every locally tracked in-flight issuance order alongside the real cards. // placeholder for every locally tracked in-flight issuance order alongside the real cards.
val issuingCards = buildIssuingCards(userWalletId) val issuingCards = buildIssuingCards(userWalletId)
// Keep the card order stable across refetches: the backend orders `productInstances` by a mutable
// field (a rename bumps `updated_at`), which would otherwise make the renamed card jump. Anchor on
// the previously shown order and append newly seen cards at the end.
val orderedCards = tangemPayCards.stableOrder(previousRealCardOrder(userWalletId))
return PaymentAccountStatusValue.Loaded( return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,
customerId = customerId, customerId = customerId,
depositAddress = cryptoBalance.depositAddress, depositAddress = cryptoBalance.depositAddress,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = fiatRate, fiatRate = fiatRate,
cards = tangemPayCards + issuingCards, cards = orderedCards + issuingCards,
balance = PaymentAccountStatusValue.Balance( balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance, fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance, cryptoBalance = cryptoBalance,
@ -381,6 +398,21 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
) )
} }
/**
* Order of real (product-instance-backed) cards from the previously stored status, used as the stable
* anchor for [stableOrder]. Issuing placeholders are excluded they carry synthetic order ids and are
* always appended last. Empty on the first load (no prior [PaymentAccountStatusValue.Loaded]), which makes
* [stableOrder] fall back to the backend order.
*/
private suspend fun previousRealCardOrder(userWalletId: UserWalletId): List<String> {
val previousValue = paymentAccountStatusesStore.getSyncOrNull(userWalletId)?.value
return (previousValue as? PaymentAccountStatusValue.Loaded)
?.cards
?.filterNot { it.state == TangemPayCardState.Issuing }
?.map { it.id }
.orEmpty()
}
private suspend fun getCardState(cardId: String, userWalletId: UserWalletId): TangemPayCardState { private suspend fun getCardState(cardId: String, userWalletId: UserWalletId): TangemPayCardState {
val closingOrderId = closeCardRepository.getCloseOrderId(userWalletId, cardId).getOrNull() val closingOrderId = closeCardRepository.getCloseOrderId(userWalletId, cardId).getOrNull()
val reissueOrderId = reissueCardRepository.getReissueOrderId(userWalletId, cardId).getOrNull() val reissueOrderId = reissueCardRepository.getReissueOrderId(userWalletId, cardId).getOrNull()

View file

@ -0,0 +1,80 @@
package com.tangem.data.pay.flow
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class StableOrderTest {
@ParameterizedTest
@ProvideTestModels
fun stableOrder(model: Model) {
// Act
val result = model.cards.stableOrder(model.previousOrder).map { it.id }
// Assert
assertThat(result).containsExactlyElementsIn(model.expectedIds).inOrder()
}
private fun provideTestModels() = listOf(
Model(
name = "GIVEN no previous order WHEN stableOrder THEN backend order is kept",
cards = listOf(card("a"), card("b"), card("c")),
previousOrder = emptyList(),
expectedIds = listOf("a", "b", "c"),
),
Model(
// The reported bug: a rename bumps updated_at, so the backend moves the renamed card.
name = "GIVEN backend reordered existing cards WHEN stableOrder THEN previous order is preserved",
cards = listOf(card("b"), card("c"), card("a")),
previousOrder = listOf("a", "b", "c"),
expectedIds = listOf("a", "b", "c"),
),
Model(
name = "GIVEN a newly seen card WHEN stableOrder THEN it is appended at the end",
cards = listOf(card("c"), card("a"), card("b")),
previousOrder = listOf("a", "b"),
expectedIds = listOf("a", "b", "c"),
),
Model(
name = "GIVEN several new cards WHEN stableOrder THEN they keep their backend order at the end",
cards = listOf(card("d"), card("b"), card("a"), card("c")),
previousOrder = listOf("a", "b"),
expectedIds = listOf("a", "b", "d", "c"),
),
Model(
name = "GIVEN previous order references a gone card WHEN stableOrder THEN the stale id is ignored",
cards = listOf(card("b"), card("a")),
previousOrder = listOf("a", "x", "b"),
expectedIds = listOf("a", "b"),
),
)
internal data class Model(
val name: String,
val cards: List<TangemPayCard>,
val previousOrder: List<String>,
val expectedIds: List<String>,
) {
override fun toString(): String = name
}
private companion object {
fun card(id: String): TangemPayCard = TangemPayCard(
id = id,
productInstanceId = id,
cardStatus = TangemPayCard.Status.ACTIVE,
hasPinCode = false,
displayName = null,
limit = null,
frozenState = TangemPayCardFrozenState.Unfrozen,
lastDigits = "0000",
state = TangemPayCardState.Active,
)
}
}

View file

@ -66,8 +66,8 @@ object OrderConflictRules {
} }
private fun OrderType.isIssuing(): Boolean { private fun OrderType.isIssuing(): Boolean {
return this == OrderType.CARD_ISSUE || return this == OrderType.CARD_ISSUE_ADDITIONAL ||
this == OrderType.CARD_ISSUE_ADDITIONAL || this == OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC ||
this == OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2 this == OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2
} }

View file

@ -9,9 +9,9 @@ import com.tangem.domain.pay.model.OrderType.Companion.fromString
* so the app never crashes on a new server-side type. * so the app never crashes on a new server-side type.
*/ */
enum class OrderType(val wireValue: String) { enum class OrderType(val wireValue: String) {
CARD_ISSUE("CARD_ISSUE_VIRTUAL_RAIN_KYC"),
CARD_ISSUE_ADDITIONAL("CARD_ISSUE_ADDITIONAL"), CARD_ISSUE_ADDITIONAL("CARD_ISSUE_ADDITIONAL"),
CARD_ISSUE_VIRTUAL_RAIN_KYC_V2("CARD_ISSUE_VIRTUAL_RAIN_KYC_V2"), CARD_ISSUE_VIRTUAL_RAIN_KYC_V2("CARD_ISSUE_VIRTUAL_RAIN_KYC_V2"),
CARD_ISSUE_VIRTUAL_RAIN_KYC("CARD_ISSUE_VIRTUAL_RAIN_KYC"),
CARD_REISSUE("CARD_REISSUE"), CARD_REISSUE("CARD_REISSUE"),
CARD_FREEZE("CARD_FREEZE"), CARD_FREEZE("CARD_FREEZE"),
CARD_UNFREEZE("CARD_UNFREEZE"), CARD_UNFREEZE("CARD_UNFREEZE"),

View file

@ -6,6 +6,7 @@ import arrow.core.raise.catch
import arrow.core.raise.either import arrow.core.raise.either
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Offer import com.tangem.domain.pay.model.Offer
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.CustomerOffersRepository import com.tangem.domain.pay.repository.CustomerOffersRepository
import com.tangem.domain.pay.repository.CustomerOrderRepository import com.tangem.domain.pay.repository.CustomerOrderRepository
@ -53,7 +54,14 @@ class IssueAdditionalCardUseCase(
val activeOrders = catch( val activeOrders = catch(
block = { block = {
customerOrderRepository customerOrderRepository
.findOrders(userWalletId = userWalletId, types = setOf(offer.data.orderType)) .findOrders(
userWalletId = userWalletId,
types = setOf(
offer.data.orderType,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
),
)
.bind() .bind()
}, },
catch = { handleError(it) }, catch = { handleError(it) },

View file

@ -10,7 +10,7 @@ internal class OrderConflictRulesTest {
@Test @Test
fun `IssueCard is blocked by an active issue order`() { fun `IssueCard is blocked by an active issue order`() {
val active = listOf(order(type = OrderType.CARD_ISSUE, status = OrderStatus.PROCESSING)) val active = listOf(order(type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING))
val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, active) val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, active)
@ -104,7 +104,7 @@ internal class OrderConflictRulesTest {
val active = listOf( val active = listOf(
order(type = OrderType.CARD_FREEZE, status = OrderStatus.PROCESSING, productInstanceId = cardA), order(type = OrderType.CARD_FREEZE, status = OrderStatus.PROCESSING, productInstanceId = cardA),
order(type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING), order(type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING),
order(type = OrderType.CARD_ISSUE, status = OrderStatus.PROCESSING), order(type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING),
) )
val resolution = OrderConflictRules.resolve(OrderIntent.Rename(cardA), active) val resolution = OrderConflictRules.resolve(OrderIntent.Rename(cardA), active)
@ -115,8 +115,8 @@ internal class OrderConflictRulesTest {
@Test @Test
fun `Terminal-status orders never block`() { fun `Terminal-status orders never block`() {
val terminal = listOf( val terminal = listOf(
order(type = OrderType.CARD_ISSUE, status = OrderStatus.COMPLETED), order(type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.COMPLETED),
order(type = OrderType.CARD_ISSUE, status = OrderStatus.CANCELED), order(type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.CANCELED),
) )
val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, terminal) val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, terminal)

View file

@ -65,7 +65,11 @@ internal class IssueAdditionalCardUseCaseTest {
coEvery { coEvery {
orderRepository.findOrders( orderRepository.findOrders(
userWalletId, userWalletId,
types = setOf(OrderType.CARD_ISSUE_ADDITIONAL), types = setOf(
OrderType.CARD_ISSUE_ADDITIONAL,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
),
statuses = emptySet(), statuses = emptySet(),
) )
} returns listOf(existing).right() } returns listOf(existing).right()
@ -83,7 +87,11 @@ internal class IssueAdditionalCardUseCaseTest {
coEvery { coEvery {
orderRepository.findOrders( orderRepository.findOrders(
userWalletId, userWalletId,
types = setOf(OrderType.CARD_ISSUE_ADDITIONAL), types = setOf(
OrderType.CARD_ISSUE_ADDITIONAL,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
),
statuses = emptySet(), statuses = emptySet(),
) )
} returns emptyList<Order>().right() } returns emptyList<Order>().right()
@ -107,7 +115,11 @@ internal class IssueAdditionalCardUseCaseTest {
coEvery { coEvery {
orderRepository.findOrders( orderRepository.findOrders(
userWalletId, userWalletId,
types = setOf(OrderType.CARD_ISSUE_ADDITIONAL), types = setOf(
OrderType.CARD_ISSUE_ADDITIONAL,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
),
statuses = emptySet(), statuses = emptySet(),
) )
} returns emptyList<Order>().right() } returns emptyList<Order>().right()

View file

@ -35,7 +35,7 @@ internal class RestoreActiveOrdersUseCaseTest {
@Test @Test
fun `returns the orders found by the repository`() = runTest { fun `returns the orders found by the repository`() = runTest {
val orders = listOf( val orders = listOf(
order(id = "issue", type = OrderType.CARD_ISSUE, status = OrderStatus.PROCESSING), order(id = "issue", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING),
order(id = "withdraw", type = OrderType.WITHDRAW, status = OrderStatus.NEW), order(id = "withdraw", type = OrderType.WITHDRAW, status = OrderStatus.NEW),
) )
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = any()) } returns orders.right() coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = any()) } returns orders.right()

View file

@ -12,11 +12,11 @@ internal class OrderResolverTest {
fun `selectActive filters by type and active status`() { fun `selectActive filters by type and active status`() {
val orders = listOf( val orders = listOf(
order(id = "1", type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING, updatedAt = "2026-01-01"), order(id = "1", type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING, updatedAt = "2026-01-01"),
order(id = "2", type = OrderType.CARD_ISSUE, status = OrderStatus.PROCESSING, updatedAt = "2026-01-02"), order(id = "2", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING, updatedAt = "2026-01-02"),
order(id = "3", type = OrderType.CARD_ISSUE, status = OrderStatus.COMPLETED, updatedAt = "2026-01-03"), order(id = "3", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.COMPLETED, updatedAt = "2026-01-03"),
) )
val result = OrderResolver.selectActive(orders = orders, type = OrderType.CARD_ISSUE) val result = OrderResolver.selectActive(orders = orders, type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC)
assertThat(result?.id).isEqualTo("2") assertThat(result?.id).isEqualTo("2")
} }
@ -24,11 +24,11 @@ internal class OrderResolverTest {
@Test @Test
fun `selectActive picks the latest by updatedAt`() { fun `selectActive picks the latest by updatedAt`() {
val orders = listOf( val orders = listOf(
order(id = "old", type = OrderType.CARD_ISSUE, status = OrderStatus.NEW, updatedAt = "2026-01-01"), order(id = "old", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.NEW, updatedAt = "2026-01-01"),
order(id = "new", type = OrderType.CARD_ISSUE, status = OrderStatus.PROCESSING, updatedAt = "2026-06-05"), order(id = "new", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING, updatedAt = "2026-06-05"),
) )
val result = OrderResolver.selectActive(orders = orders, type = OrderType.CARD_ISSUE) val result = OrderResolver.selectActive(orders = orders, type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC)
assertThat(result?.id).isEqualTo("new") assertThat(result?.id).isEqualTo("new")
} }
@ -36,11 +36,11 @@ internal class OrderResolverTest {
@Test @Test
fun `selectActive returns null when no active order of the type exists`() { fun `selectActive returns null when no active order of the type exists`() {
val orders = listOf( val orders = listOf(
order(id = "1", type = OrderType.CARD_ISSUE, status = OrderStatus.COMPLETED, updatedAt = "2026-01-01"), order(id = "1", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.COMPLETED, updatedAt = "2026-01-01"),
order(id = "2", type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING, updatedAt = "2026-01-02"), order(id = "2", type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING, updatedAt = "2026-01-02"),
) )
val result = OrderResolver.selectActive(orders = orders, type = OrderType.CARD_ISSUE) val result = OrderResolver.selectActive(orders = orders, type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC)
assertThat(result).isNull() assertThat(result).isNull()
} }
@ -91,11 +91,11 @@ internal class OrderResolverTest {
@Test @Test
fun `selectLatest includes terminal orders`() { fun `selectLatest includes terminal orders`() {
val orders = listOf( val orders = listOf(
order(id = "1", type = OrderType.CARD_ISSUE, status = OrderStatus.COMPLETED, updatedAt = "2026-01-05"), order(id = "1", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.COMPLETED, updatedAt = "2026-01-05"),
order(id = "2", type = OrderType.CARD_ISSUE, status = OrderStatus.NEW, updatedAt = "2026-01-01"), order(id = "2", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.NEW, updatedAt = "2026-01-01"),
) )
val result = OrderResolver.selectLatest(orders = orders, type = OrderType.CARD_ISSUE) val result = OrderResolver.selectLatest(orders = orders, type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC)
assertThat(result?.id).isEqualTo("1") assertThat(result?.id).isEqualTo("1")
} }

View file

@ -59,6 +59,8 @@ internal class TangemPayDetailsStateFactory(
fun getLoadedState(status: PaymentAccountStatusValue.Loaded): TangemPayDetailsUM { fun getLoadedState(status: PaymentAccountStatusValue.Loaded): TangemPayDetailsUM {
val hasUnfrozenCard = status.cards.any { it.frozenState == TangemPayCardFrozenState.Unfrozen } val hasUnfrozenCard = status.cards.any { it.frozenState == TangemPayCardFrozenState.Unfrozen }
val hasIssuingCard = status.cards.any { it.state == TangemPayCardState.Issuing }
val isAddCardEnabled = status.error == null && !hasIssuingCard
return TangemPayDetailsUM( return TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig( topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = onBack, onBackClick = onBack,
@ -88,7 +90,7 @@ internal class TangemPayDetailsStateFactory(
} }
.toImmutableList(), .toImmutableList(),
onAddCardClick = intents::onAddCardClick, onAddCardClick = intents::onAddCardClick,
isAddCardEnabled = status.error == null, isAddCardEnabled = isAddCardEnabled,
), ),
), ),
isBalanceHidden = false, isBalanceHidden = false,

View file

@ -141,6 +141,7 @@ internal fun TangemPayDetailsScreenV2(
expressTransactionsBottomSheetState?.content(null) expressTransactionsBottomSheetState?.content(null)
} }
@Suppress("LongMethod")
private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) { private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) {
item("balanceBlock") { item("balanceBlock") {
BalanceBlock( BalanceBlock(
@ -174,6 +175,15 @@ private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) {
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
title = resourceReference(R.string.tangempay_issuing_new_digital_card_title), title = resourceReference(R.string.tangempay_issuing_new_digital_card_title),
subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description), subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
contentColor = TangemTheme.colors3.bg.opaque.secondary,
leadingContent = {
Icon(
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_clock_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
)
},
) )
} }
} }

View file

@ -1,9 +1,11 @@
package com.tangem.features.tangempay.ui package com.tangem.features.tangempay.ui
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -11,7 +13,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -37,7 +39,6 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_card_plus_32 import com.tangem.core.ui.res.generated.icons.ic_card_plus_32
import com.tangem.core.ui.res.generated.icons.ic_error_28
import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayIssueAdditionalCardUM import com.tangem.features.tangempay.entity.TangemPayIssueAdditionalCardUM
@ -72,47 +73,55 @@ internal fun TangemPayIssueAdditionalCardContent(state: TangemPayIssueAdditional
@Composable @Composable
private fun Content(state: TangemPayIssueAdditionalCardUM) { private fun Content(state: TangemPayIssueAdditionalCardUM) {
val appearance = state.contentAppearance() Column(modifier = Modifier.fillMaxWidth()) {
Header()
Column( FeeBlock(state = state)
modifier = Modifier if (state.isBalanceInsufficient) {
.fillMaxWidth() InsufficientFundsNotification(state = state)
.padding(16.dp), }
horizontalAlignment = Alignment.CenterHorizontally, IssueButton(state = state)
) {
SpacerH(16.dp)
StatusIcon(appearance = appearance)
SpacerH(32.dp)
CenteredMessageText(
textRes = appearance.titleRes,
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
)
CenteredMessageText(
textRes = appearance.subtitleRes,
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
)
SpacerH(32.dp)
FeeBlock(modifier = Modifier.padding(top = 16.dp), state = state)
SpacerH(8.dp)
BottomButtonsBlock(state = state, appearance = appearance)
} }
} }
@Composable @Composable
private fun StatusIcon(appearance: IssueAdditionalCardContentAppearance) { private fun Header() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SpacerH(32.dp)
StatusIcon()
SpacerH(32.dp)
CenteredMessageText(
textRes = R.string.tangempay_issue_additional_card_title,
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
)
SpacerH(8.dp)
CenteredMessageText(
textRes = R.string.tangempay_issue_additional_card_description,
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
)
SpacerH(32.dp)
}
}
@Composable
private fun StatusIcon() {
Box( Box(
modifier = Modifier modifier = Modifier
.size(80.dp) .size(80.dp)
.clip(CircleShape) .clip(CircleShape)
.background(appearance.iconBackgroundColor), .background(TangemTheme.colors3.bg.status.infoSubtle),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = appearance.icon, imageVector = Icons.ic_card_plus_32,
contentDescription = null, contentDescription = null,
tint = appearance.iconColor, tint = TangemTheme.colors3.icon.status.info,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
) )
} }
@ -152,68 +161,64 @@ private fun FeeBlock(state: TangemPayIssueAdditionalCardUM, modifier: Modifier =
} }
@Composable @Composable
private fun BottomButtonsBlock( private fun InsufficientFundsNotification(state: TangemPayIssueAdditionalCardUM, modifier: Modifier = Modifier) {
state: TangemPayIssueAdditionalCardUM,
appearance: IssueAdditionalCardContentAppearance,
modifier: Modifier = Modifier,
) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 16.dp), .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp)
verticalArrangement = Arrangement.spacedBy(8.dp), .clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors3.bg.status.warningSubtle)
.padding(horizontal = 14.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Image(
modifier = Modifier.size(20.dp),
painter = painterResource(R.drawable.img_usdc_16),
contentDescription = null,
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResourceSafe(R.string.tangempay_reissue_card_insufficient_funds_title),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.primary,
)
Text(
text = stringResourceSafe(R.string.tangempay_reissue_card_insufficient_funds_subtitle),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
)
}
}
TangemButton( TangemButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
variant = TangemButton.Variant.Secondary, variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X12, size = TangemButton.Size.X8,
onClick = state.onDismiss, onClick = state.onAddFundsClick,
text = resourceReference(R.string.common_cancel), text = resourceReference(R.string.tangempay_card_details_add_funds),
)
TangemButton(
modifier = Modifier.fillMaxWidth(),
size = TangemButton.Size.X12,
onClick = appearance.primaryAction(state),
isEnabled = !state.isLoading,
isLoading = state.isLoading,
text = resourceReference(appearance.primaryButtonTextRes),
) )
} }
} }
private data class IssueAdditionalCardContentAppearance(
val titleRes: Int,
val subtitleRes: Int,
val icon: ImageVector,
val iconColor: Color,
val iconBackgroundColor: Color,
val primaryButtonTextRes: Int,
val primaryAction: (TangemPayIssueAdditionalCardUM) -> () -> Unit,
)
@Composable @Composable
private fun TangemPayIssueAdditionalCardUM.contentAppearance(): IssueAdditionalCardContentAppearance { private fun IssueButton(state: TangemPayIssueAdditionalCardUM, modifier: Modifier = Modifier) {
return if (isBalanceInsufficient) { TangemButton(
IssueAdditionalCardContentAppearance( modifier = modifier
titleRes = R.string.tangempay_reissue_card_insufficient_funds_title, .fillMaxWidth()
subtitleRes = R.string.tangempay_reissue_card_insufficient_funds_subtitle, .padding(16.dp),
icon = Icons.ic_error_28, size = TangemButton.Size.X12,
iconColor = TangemTheme.colors3.icon.status.warning, onClick = state.onIssueClick,
iconBackgroundColor = TangemTheme.colors3.bg.status.warningSubtle, isEnabled = !state.isLoading && !state.isBalanceInsufficient,
primaryButtonTextRes = R.string.tangempay_card_details_add_funds, isLoading = state.isLoading,
primaryAction = { it.onAddFundsClick }, text = resourceReference(R.string.tangempay_issue_card),
) )
} else {
IssueAdditionalCardContentAppearance(
titleRes = R.string.tangempay_issue_additional_card_title,
subtitleRes = R.string.tangempay_issue_additional_card_description,
icon = Icons.ic_card_plus_32,
iconColor = TangemTheme.colors3.icon.status.info,
iconBackgroundColor = TangemTheme.colors3.bg.status.infoSubtle,
primaryButtonTextRes = R.string.tangempay_issue_card,
primaryAction = { it.onIssueClick },
)
}
} }
@Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360)
@ -223,29 +228,7 @@ private fun TangemPayIssueAdditionalCardContentPreview(
@PreviewParameter(IssueAdditionalCardPreviewProvider::class) state: TangemPayIssueAdditionalCardUM, @PreviewParameter(IssueAdditionalCardPreviewProvider::class) state: TangemPayIssueAdditionalCardUM,
) { ) {
TangemThemePreviewRedesign { TangemThemePreviewRedesign {
IssueAdditionalCardSheetPreview(state = state) TangemPayIssueAdditionalCardContent(state = state)
}
}
@Composable
private fun IssueAdditionalCardSheetPreview(state: TangemPayIssueAdditionalCardUM) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
TangemTopBar(
type = TangemTopBarType.BottomSheet,
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
onClick = state.onDismiss,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
Content(state)
} }
} }