Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-17 14:44:06 +05:00
parent 5618f44c8b
commit 084bad17ab
18 changed files with 452 additions and 192 deletions

View file

@ -4,9 +4,11 @@ import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.DefaultTangemPayCloseCardStore
import com.tangem.datasource.local.visa.DefaultTangemPayIssueCardStore
import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayCloseCardStore
import com.tangem.datasource.local.visa.TangemPayIssueCardStore
import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import dagger.Module
import dagger.Provides
@ -42,4 +44,12 @@ internal object TangemPayStoresModule {
prefs = prefs,
)
}
@Provides
@Singleton
fun provideTangemPayIssueCardStore(prefs: AppPreferencesStore): TangemPayIssueCardStore {
return DefaultTangemPayIssueCardStore(
prefs = prefs,
)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getObjectListSync
import com.tangem.datasource.local.preferences.utils.storeObjectList
import com.tangem.domain.models.wallet.UserWalletId
internal class DefaultTangemPayIssueCardStore(
private val prefs: AppPreferencesStore,
) : TangemPayIssueCardStore {
override suspend fun addIssueOrderId(userWalletId: UserWalletId, orderId: String) {
val current = prefs.getObjectListSync<String>(getKey(userWalletId))
if (orderId !in current) {
prefs.storeObjectList(key = getKey(userWalletId), value = current + orderId)
}
}
override suspend fun getIssueOrderIds(userWalletId: UserWalletId): List<String> {
return prefs.getObjectListSync(getKey(userWalletId))
}
override suspend fun removeIssueOrderId(userWalletId: UserWalletId, orderId: String) {
val current = prefs.getObjectListSync<String>(getKey(userWalletId))
if (orderId in current) {
prefs.storeObjectList(key = getKey(userWalletId), value = current - orderId)
}
}
private fun getKey(userWalletId: UserWalletId) =
stringPreferencesKey("tangem_pay_issue_card_orders_${userWalletId.stringValue}")
}

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.models.wallet.UserWalletId
/**
* Local store for additional-card issuance order ids.
*
* The backend `customer/me` response does not surface a card while it is still being issued, so the
* order id of an in-flight additional-card issuance is persisted here (per wallet) to render an
* "issuing" placeholder card until the order reaches a terminal state and the real card appears.
*/
interface TangemPayIssueCardStore {
suspend fun addIssueOrderId(userWalletId: UserWalletId, orderId: String)
suspend fun getIssueOrderIds(userWalletId: UserWalletId): List<String>
suspend fun removeIssueOrderId(userWalletId: UserWalletId, orderId: String)
}

View file

@ -75,6 +75,10 @@ internal interface TangemPayDataModule {
@Singleton
fun bindCloseCardRepository(repository: DefaultCloseCardRepository): TangemPayCloseCardRepository
@Binds
@Singleton
fun bindIssueCardRepository(repository: DefaultIssueCardRepository): TangemPayIssueCardRepository
@Binds
@Singleton
fun bindTangemPayCryptoCurrencyFactory(factory: DefaultTangemPayCurrencyFactory): TangemPayCurrencyFactory
@ -269,8 +273,17 @@ internal interface TangemPayDataModule {
fun provideIssueAdditionalCardUseCase(
customerOffersRepository: CustomerOffersRepository,
customerOrderRepository: CustomerOrderRepository,
issueCardRepository: TangemPayIssueCardRepository,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
appCoroutineScope: AppCoroutineScope,
): IssueAdditionalCardUseCase {
return IssueAdditionalCardUseCase(customerOffersRepository, customerOrderRepository)
return IssueAdditionalCardUseCase(
customerOffersRepository = customerOffersRepository,
customerOrderRepository = customerOrderRepository,
issueCardRepository = issueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = appCoroutineScope,
)
}
}
}

View file

@ -53,6 +53,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val singleQuoteSupplier: SingleQuoteStatusSupplier,
private val closeCardRepository: TangemPayCloseCardRepository,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val issueCardRepository: TangemPayIssueCardRepository,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -360,13 +361,17 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
if (tangemPayCards.isEmpty()) return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
// Additional-card issuance: the backend omits the new card until it is provisioned, so surface a
// placeholder for every locally tracked in-flight issuance order alongside the real cards.
val issuingCards = buildIssuingCards(userWalletId)
return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
customerId = customerId,
depositAddress = cryptoBalance.depositAddress,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = fiatRate,
cards = tangemPayCards,
cards = tangemPayCards + issuingCards,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
@ -399,6 +404,37 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
}
}
/**
* Builds the issuing placeholder cards from locally tracked additional-card orders. Each order is
* re-checked against the backend; terminal orders are dropped (and forgotten) because the real card
* is now part of [CustomerInfo], while in-flight orders surface as an issuing placeholder card.
*/
private suspend fun buildIssuingCards(userWalletId: UserWalletId): List<TangemPayCard> {
val orderIds = issueCardRepository.getIssueOrderIds(userWalletId)
return orderIds.mapNotNull { orderId ->
val order = cardDetailsRepository.getOrderInfo(userWalletId, orderId).getOrNull()
if (order != null && order.orderStatus.isTerminal) {
issueCardRepository.removeIssueOrderId(userWalletId, orderId)
null
} else {
issuingPlaceholderCard(orderId)
}
}
}
/** Placeholder card for an additional card that is still being issued (no backend card yet). */
private fun issuingPlaceholderCard(orderId: String): TangemPayCard = TangemPayCard(
id = orderId,
productInstanceId = orderId,
cardStatus = TangemPayCard.Status.INACTIVE,
hasPinCode = false,
displayName = null,
limit = null,
frozenState = TangemPayCardFrozenState.Unfrozen,
lastDigits = "",
state = TangemPayCardState.Issuing,
)
private suspend fun VisaApiError.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
return when (this) {
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced

View file

@ -0,0 +1,25 @@
package com.tangem.data.pay.repository
import com.tangem.datasource.local.visa.TangemPayIssueCardStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject
internal class DefaultIssueCardRepository @Inject constructor(
private val tangemPayIssueCardStore: TangemPayIssueCardStore,
) : TangemPayIssueCardRepository {
override suspend fun storeIssueOrderId(userWalletId: UserWalletId, orderId: String) {
runSuspendCatching { tangemPayIssueCardStore.addIssueOrderId(userWalletId, orderId) }
}
override suspend fun getIssueOrderIds(userWalletId: UserWalletId): List<String> {
return runSuspendCatching { tangemPayIssueCardStore.getIssueOrderIds(userWalletId) }
.getOrDefault(emptyList())
}
override suspend fun removeIssueOrderId(userWalletId: UserWalletId, orderId: String) {
runSuspendCatching { tangemPayIssueCardStore.removeIssueOrderId(userWalletId, orderId) }
}
}

View file

@ -20,18 +20,24 @@ enum class TangemPayCardState {
/** A close order is in progress; the card is being closed. */
@SerialName("Closing")
Closing,
/** An issue order is in progress; the (additional) card is being issued and not yet provisioned. */
@SerialName("Issuing")
Issuing,
;
override fun toString() = when (this) {
Active -> "Active"
Reissuing -> "Reissuing"
Closing -> "Closing"
Issuing -> "Issuing"
}
companion object {
fun fromString(value: String) = when (value.lowercase(Locale.US)) {
"reissuing" -> Reissuing
"closing" -> Closing
"issuing" -> Issuing
else -> Active
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.pay.repository
import com.tangem.domain.models.wallet.UserWalletId
/**
* Tracks in-flight additional-card issuance orders locally.
*
* The backend does not return a card while it is still being issued, so the issuance order id is
* stored locally to surface an "issuing" placeholder card until the order reaches a terminal state.
*/
interface TangemPayIssueCardRepository {
suspend fun storeIssueOrderId(userWalletId: UserWalletId, orderId: String)
suspend fun getIssueOrderIds(userWalletId: UserWalletId): List<String>
suspend fun removeIssueOrderId(userWalletId: UserWalletId, orderId: String)
}

View file

@ -6,12 +6,15 @@ import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Offer
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.CustomerOffersRepository
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
import com.tangem.domain.pay.util.OrderResolver
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.launch
import java.util.UUID
/**
@ -33,8 +36,11 @@ import java.util.UUID
class IssueAdditionalCardUseCase(
private val customerOffersRepository: CustomerOffersRepository,
private val customerOrderRepository: CustomerOrderRepository,
private val issueCardRepository: TangemPayIssueCardRepository,
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
private val appCoroutineScope: AppCoroutineScope,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, Result> = either {
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, Unit> = either {
val offer = catch(
block = {
customerOffersRepository.getOffers(userWalletId)
@ -61,20 +67,18 @@ class IssueAdditionalCardUseCase(
idempotencyKey = UUID.randomUUID().toString(),
).bind()
Result(order = order, offer = offer)
issueCardRepository.storeIssueOrderId(userWalletId = userWalletId, orderId = order.id)
appCoroutineScope.launch {
startTangemPayOrderPollingUseCase(
order = TangemPayOrderInfo(orderId = order.id, orderStatus = order.status),
userWalletId = userWalletId,
)
}
}
private fun Raise<VisaApiError>.handleError(throwable: Throwable): Nothing {
TangemLogger.e("Error in IssueAdditionalCardUseCase", throwable)
raise(VisaApiError.Unspecified)
}
/**
* Outcome of a successful run.
*
* @property offer the offer that authorised issuance, carried back so the caller can show pricing
* without an extra round trip.
*/
data class Result(val order: Order, val offer: Offer)
}

View file

@ -10,7 +10,9 @@ import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.repository.CustomerOffersRepository
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
@ -23,7 +25,15 @@ internal class IssueAdditionalCardUseCaseTest {
private val offersRepository: CustomerOffersRepository = mockk()
private val orderRepository: CustomerOrderRepository = mockk()
private val useCase = IssueAdditionalCardUseCase(offersRepository, orderRepository)
private val issueCardRepository: TangemPayIssueCardRepository = mockk(relaxed = true)
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true)
private val useCase = IssueAdditionalCardUseCase(
customerOffersRepository = offersRepository,
customerOrderRepository = orderRepository,
issueCardRepository = issueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = TestAppCoroutineScope(),
)
private val userWalletId = UserWalletId("1234567890ABCDEF")
private val spec = "SP_000004"
@ -62,10 +72,9 @@ internal class IssueAdditionalCardUseCaseTest {
val result = useCase(userWalletId)
val resultValue = result.getOrNull()
assertThat(resultValue?.order).isEqualTo(existing)
assertThat(resultValue?.offer).isEqualTo(offer)
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { orderRepository.createOrder(any(), any(), any(), any()) }
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(userWalletId, existing.id) }
}
@Test
@ -118,7 +127,8 @@ internal class IssueAdditionalCardUseCaseTest {
val result = useCase(userWalletId)
assertThat(result.getOrNull()?.order).isEqualTo(newOrder)
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(userWalletId, newOrder.id) }
}
private fun order(id: String, type: OrderType, status: OrderStatus): Order = Order(

View file

@ -57,7 +57,7 @@ internal class TangemPayDetailsStateFactory(
}
fun getLoadedState(status: PaymentAccountStatusValue.Loaded): TangemPayDetailsUM {
val card = status.cards.firstOrNull()
val hasUnfrozenCard = status.cards.any { it.frozenState == TangemPayCardFrozenState.Unfrozen }
val errorNotificationConfig = when (status.error) {
null -> null
PaymentAccountStatusValue.Error.NotSynced -> createRenewSessionNotificationConfig()
@ -75,10 +75,7 @@ internal class TangemPayDetailsStateFactory(
onRefresh = intents::onRefreshSwipe,
),
balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(
actionButtons = getActionButtonsConfig(
isEnabled = errorNotificationConfig == null &&
(card == null || card.frozenState == TangemPayCardFrozenState.Unfrozen),
),
actionButtons = getActionButtonsConfig(isEnabled = errorNotificationConfig == null && hasUnfrozenCard),
cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState(
cards = status.cards
.let { if (isMultipleCardsEnabled) it else it.take(1) }
@ -86,9 +83,11 @@ internal class TangemPayDetailsStateFactory(
TangemPayDetailsBalanceBlockState.Card(
lastDigits = cardItem.lastDigits,
onClick = { intents.onCardClick(cardItem.id) },
isReissuing = cardItem.state != TangemPayCardState.Active,
isReissuingOrClosing = cardItem.state == TangemPayCardState.Reissuing ||
cardItem.state == TangemPayCardState.Closing,
isEnabled = errorNotificationConfig == null,
isFrozen = cardItem.isFrozen,
isIssuing = cardItem.state == TangemPayCardState.Issuing,
)
}
.toImmutableList(),

View file

@ -94,7 +94,8 @@ internal sealed class TangemPayDetailsBalanceBlockState {
data class Card(
val lastDigits: String,
val onClick: () -> Unit,
val isReissuing: Boolean,
val isReissuingOrClosing: Boolean,
val isIssuing: Boolean,
val isFrozen: Boolean,
val isEnabled: Boolean,
)

View file

@ -207,6 +207,12 @@ private fun LazyListScope.cardState(state: TangemPayCardPageUM) {
subtitle = resourceReference(R.string.tangempay_card_page_closing_banner_description),
)
}
TangemPayCardState.Issuing -> cardPageItem(key = "Issuing") {
TangemPayReplacingCardBlock(
title = resourceReference(R.string.tangempay_issuing_new_digital_card_title),
subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
)
}
}
}

View file

@ -132,7 +132,7 @@ internal fun TangemPayDetailsScreen(
},
)
if (state.balanceBlockState.cardsBlockState?.cards?.fastAny { it.isReissuing } == true) {
if (state.balanceBlockState.cardsBlockState?.cards?.fastAny { it.isReissuingOrClosing } == true) {
item(
key = "REISSUE_MESSAGE",
content = {
@ -328,7 +328,7 @@ private fun TangemPayCardItem(card: TangemPayDetailsBalanceBlockState.Card, modi
.alpha(if (card.isEnabled) 1f else DISABLED_ALPHA)
.fillMaxSize(),
painter = painterResource(
if (card.isReissuing) {
if (card.isReissuingOrClosing) {
R.drawable.img_visa_card_inactive_48_32
} else {
R.drawable.img_visa_card_48_32
@ -336,7 +336,7 @@ private fun TangemPayCardItem(card: TangemPayDetailsBalanceBlockState.Card, modi
),
contentDescription = null,
)
if (!card.isReissuing) {
if (!card.isReissuingOrClosing) {
Text(
modifier = Modifier
.align(Alignment.BottomStart)
@ -485,16 +485,18 @@ internal class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<T
TangemPayDetailsBalanceBlockState.Card(
lastDigits = "1234",
onClick = {},
isReissuing = false,
isReissuingOrClosing = false,
isEnabled = false,
isFrozen = false,
isIssuing = false,
),
TangemPayDetailsBalanceBlockState.Card(
lastDigits = "3456",
onClick = {},
isReissuing = false,
isReissuingOrClosing = false,
isEnabled = true,
isFrozen = false,
isIssuing = false,
),
),
onAddCardClick = {},
@ -523,9 +525,10 @@ internal class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<T
TangemPayDetailsBalanceBlockState.Card(
lastDigits = "1234",
onClick = {},
isReissuing = true,
isReissuingOrClosing = true,
isFrozen = false,
isEnabled = true,
isIssuing = false,
),
),
onAddCardClick = {},

View file

@ -158,7 +158,7 @@ private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) {
}
}
when {
state.balanceBlockState.cardsBlockState?.cards?.fastAny { it.isReissuing } == true -> {
state.balanceBlockState.cardsBlockState?.cards?.fastAny { it.isReissuingOrClosing } == true -> {
item("reissuingBannerBlock") {
TangemMessage(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
@ -167,6 +167,15 @@ private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) {
)
}
}
state.balanceBlockState.cardsBlockState?.cards?.fastAny { it.isIssuing } == true -> {
item("issuingBannerBlock") {
TangemMessage(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
title = resourceReference(R.string.tangempay_issuing_new_digital_card_title),
subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
)
}
}
else -> {
if (state.errorNotificationConfig != null) {
item("errorSessionBannerBlock") {
@ -342,7 +351,7 @@ private fun CardsBlock(
) {
items(items = cardsBlockState.cards) { item ->
TangemPayCardView(
isReissuing = item.isReissuing,
isIssueInProgress = item.isIssuing || item.isReissuingOrClosing,
lastDigits = item.lastDigits,
onClick = item.onClick,
isEnabled = item.isEnabled,

View file

@ -1,211 +1,271 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
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.PreviewParameterProvider
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButtonIconEnd
import com.tangem.core.ui.components.SpacerH
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.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowText
import com.tangem.core.ui.ds2.row.TangemRowTextRole
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
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_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.entity.TangemPayIssueAdditionalCardUM
@Composable
internal fun TangemPayIssueAdditionalCardContent(state: TangemPayIssueAdditionalCardUM) {
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = state.onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
onBack = state.onDismiss,
containerColor = TangemTheme.colors.background.secondary,
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors3.bg.secondary,
title = {
TangemModalBottomSheetTitle(
endIconRes = R.drawable.ic_close_24,
onEndClick = state.onDismiss,
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 = {
Content(state)
},
)
}
@Composable
private fun Content(state: TangemPayIssueAdditionalCardUM) {
val appearance = state.contentAppearance()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
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
private fun StatusIcon(appearance: IssueAdditionalCardContentAppearance) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(appearance.iconBackgroundColor),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = appearance.icon,
contentDescription = null,
tint = appearance.iconColor,
modifier = Modifier.size(28.dp),
)
}
}
@Composable
private fun CenteredMessageText(textRes: Int, style: TextStyle, color: Color) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CardIcon()
Text(
modifier = Modifier.padding(top = 16.dp),
text = stringResourceSafe(R.string.tangempay_issue_additional_card_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
text = stringResourceSafe(textRes),
style = style,
color = color,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier.padding(top = 4.dp),
text = stringResourceSafe(R.string.tangempay_issue_additional_card_description),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
FeeRow(modifier = Modifier.padding(top = 16.dp), state = state)
if (state.isBalanceInsufficient) {
InsufficientFundsBlock(modifier = Modifier.padding(top = 8.dp), onAddFundsClick = state.onAddFundsClick)
}
PrimaryButton(
modifier = Modifier
.padding(vertical = 16.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.tangempay_issue_card),
showProgress = state.isLoading,
enabled = !state.isLoading && !state.isBalanceInsufficient,
onClick = state.onIssueClick,
)
}
}
}
@Composable
private fun CardIcon() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = CircleShape,
private fun FeeBlock(state: TangemPayIssueAdditionalCardUM, modifier: Modifier = Modifier) {
TangemRow(
modifier = modifier,
contentLead = TangemRowContentLead.Start,
titleSlot = {
TangemRowText(
text = resourceReference(R.string.tangempay_issue_additional_card_fee_label),
role = TangemRowTextRole.Title,
)
.size(48.dp),
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = ImageVector.vectorResource(id = R.drawable.ic_credit_card_add_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
}
@Composable
private fun FeeRow(state: TangemPayIssueAdditionalCardUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.action)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = stringResourceSafe(R.string.tangempay_issue_additional_card_fee_label),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
Text(
},
valueSlot = {
TangemRowText(
text = state.feeText,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
role = TangemRowTextRole.Value,
)
},
)
}
}
@Composable
private fun InsufficientFundsBlock(onAddFundsClick: () -> Unit, modifier: Modifier = Modifier) {
private fun BottomButtonsBlock(
state: TangemPayIssueAdditionalCardUM,
appearance: IssueAdditionalCardContentAppearance,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.action)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.Top) {
Image(
modifier = Modifier.size(20.dp),
painter = painterResource(id = R.drawable.img_usdc_16),
contentDescription = null,
TangemButton(
modifier = Modifier.fillMaxWidth(),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X12,
onClick = state.onDismiss,
text = resourceReference(R.string.common_cancel),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = stringResourceSafe(R.string.tangempay_reissue_card_insufficient_funds_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = stringResourceSafe(
R.string.tangempay_reissue_card_insufficient_funds_subtitle,
),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
SecondaryButtonIconEnd(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
text = stringResourceSafe(R.string.common_add_funds),
iconResId = R.drawable.ic_plus_24,
onClick = onAddFundsClick,
TangemButton(
modifier = Modifier.fillMaxWidth(),
size = TangemButton.Size.X12,
onClick = appearance.primaryAction(state),
isEnabled = !state.isLoading,
isLoading = state.isLoading,
text = resourceReference(appearance.primaryButtonTextRes),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
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
private fun TangemPayIssueAdditionalCardUM.contentAppearance(): IssueAdditionalCardContentAppearance {
return if (isBalanceInsufficient) {
IssueAdditionalCardContentAppearance(
titleRes = R.string.tangempay_reissue_card_insufficient_funds_title,
subtitleRes = R.string.tangempay_reissue_card_insufficient_funds_subtitle,
icon = Icons.ic_error_28,
iconColor = TangemTheme.colors3.icon.status.warning,
iconBackgroundColor = TangemTheme.colors3.bg.status.warningSubtle,
primaryButtonTextRes = R.string.tangempay_card_details_add_funds,
primaryAction = { it.onAddFundsClick },
)
} 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, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPayIssueAdditionalCardContentPreview(
@PreviewParameter(IssueAdditionalCardPreviewProvider::class) state: TangemPayIssueAdditionalCardUM,
) {
TangemThemePreview {
TangemPayIssueAdditionalCardContent(state = state)
TangemThemePreviewRedesign {
IssueAdditionalCardSheetPreview(state = state)
}
}
private class IssueAdditionalCardPreviewProvider : PreviewParameterProvider<TangemPayIssueAdditionalCardUM> {
override val values: Sequence<TangemPayIssueAdditionalCardUM>
get() = sequenceOf(
TangemPayIssueAdditionalCardUM(
isBalanceInsufficient = false,
feeText = "10 $",
isLoading = false,
onIssueClick = {},
onAddFundsClick = {},
onDismiss = {},
),
TangemPayIssueAdditionalCardUM(
isBalanceInsufficient = true,
feeText = "10 $",
isLoading = false,
onIssueClick = {},
onAddFundsClick = {},
onDismiss = {},
),
TangemPayIssueAdditionalCardUM(
isBalanceInsufficient = false,
feeText = "10 $",
isLoading = true,
onIssueClick = {},
onAddFundsClick = {},
onDismiss = {},
@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)
}
}
private class IssueAdditionalCardPreviewProvider :
CollectionPreviewParameterProvider<TangemPayIssueAdditionalCardUM>(
collection = listOf(
stubState(),
stubState(isBalanceInsufficient = true),
stubState(isLoading = true),
),
)
}
private fun stubState(
isBalanceInsufficient: Boolean = false,
isLoading: Boolean = false,
): TangemPayIssueAdditionalCardUM = TangemPayIssueAdditionalCardUM(
isBalanceInsufficient = isBalanceInsufficient,
feeText = "$4.25",
isLoading = isLoading,
onIssueClick = {},
onAddFundsClick = {},
onDismiss = {},
)

View file

@ -41,7 +41,7 @@ private const val REISSUING_CARD_BG = 0xFF1E1E1E
@Composable
internal fun TangemPayCardView(
isReissuing: Boolean,
isIssueInProgress: Boolean,
isEnabled: Boolean,
lastDigits: String,
onClick: () -> Unit,
@ -55,7 +55,7 @@ internal fun TangemPayCardView(
width = TangemTheme.dimens2.x14,
)
.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_CARD_BUTTON),
isReissuing = isReissuing,
isIssueInProgress = isIssueInProgress,
isEnabled = isEnabled,
onClick = onClick,
) {
@ -71,7 +71,7 @@ internal fun TangemPayCardView(
modifier = Modifier.size(TangemTheme.dimens2.x3),
imageVector = when {
isFrozen -> Icons.ic_snowflake_16
isReissuing -> Icons.ic_clock_12
isIssueInProgress -> Icons.ic_clock_12
else -> Icons.ic_cloud_12_filled
},
tint = TangemTheme.colors3.icon.staticDark,
@ -122,14 +122,14 @@ internal fun TangemPayAddCardView(onClick: () -> Unit, modifier: Modifier = Modi
@Suppress("MagicNumber")
@Composable
private fun CardBackground(
isReissuing: Boolean,
isIssueInProgress: Boolean,
isEnabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
val bgColor = remember(isReissuing) {
if (isReissuing) {
val bgColor = remember(isIssueInProgress) {
if (isIssueInProgress) {
Color(REISSUING_CARD_BG)
} else {
Color(DEFAULT_CARD_BG)
@ -151,9 +151,9 @@ private fun CardBackground(
brush = Brush.radialGradient(
colors = listOf(
Color(
if (isReissuing) 0xFFB0B4BC else 0xFF38587F,
if (isIssueInProgress) 0xFFB0B4BC else 0xFF38587F,
).copy(
if (isReissuing) .1f else .41f,
if (isIssueInProgress) .1f else .41f,
),
Color.Transparent,
),
@ -183,7 +183,7 @@ private fun CardBackgroundPreview() {
height = TangemTheme.dimens2.x10,
width = TangemTheme.dimens2.x14,
),
isReissuing = false,
isIssueInProgress = false,
onClick = {},
content = {},
isEnabled = true,
@ -194,14 +194,14 @@ private fun CardBackgroundPreview() {
height = TangemTheme.dimens2.x10,
width = TangemTheme.dimens2.x14,
),
isReissuing = true,
isIssueInProgress = true,
onClick = {},
content = {},
isEnabled = true,
)
SpacerH(TangemTheme.dimens2.x4)
TangemPayCardView(
isReissuing = false,
isIssueInProgress = false,
onClick = {},
lastDigits = "1234",
isEnabled = true,
@ -209,7 +209,7 @@ private fun CardBackgroundPreview() {
)
SpacerH(TangemTheme.dimens2.x4)
TangemPayCardView(
isReissuing = true,
isIssueInProgress = true,
onClick = {},
lastDigits = "",
isEnabled = true,
@ -218,7 +218,13 @@ private fun CardBackgroundPreview() {
SpacerH(TangemTheme.dimens2.x4)
TangemPayAddCardView(onClick = {})
SpacerH(TangemTheme.dimens2.x4)
TangemPayCardView(isReissuing = false, onClick = {}, lastDigits = "1234", isEnabled = true, isFrozen = true)
TangemPayCardView(
isIssueInProgress = false,
onClick = {},
lastDigits = "1234",
isEnabled = true,
isFrozen = true,
)
}
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.message.BottomSheetMessage
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_error_28
import com.tangem.core.ui.res.generated.icons.ic_snowflake_20
import com.tangem.core.ui.res.generated.icons.ic_sun_20
import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType
@ -152,8 +153,9 @@ internal object TangemPayMessagesFactory {
fun createMaximumCardsIssued(maxCards: Int): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {
icon(R.drawable.img_attention_20) {
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Attention
vector(Icons.ic_error_28) {
type = MessageBottomSheetUM.Vector.Type.Attention
backgroundType = MessageBottomSheetUM.Vector.BackgroundType.Attention
}
title = resourceReference(R.string.tangempay_maximum_cards_issued_title)
body = resourceReference(