Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 01:36:42 -07:00
parent 65d2e84c6e
commit 9e986a6cb3
21 changed files with 180 additions and 77 deletions

View file

@ -2020,6 +2020,7 @@
<string name="tangempay_onboarding_title">Get your Tangem Pay Card in minutes</string>
<string name="tangempay_pay_support">Pay Support</string>
<string name="tangempay_main_select_plan">Select plan</string>
<string name="tangempay_status_inactive">Inactive</string>
<string name="tangempay_payment_account">Payment account</string>
<string name="tangempay_payment_account_sync_needed">Session expired</string>
<string name="tangempay_pin_validation_error_message">Invalid PIN: avoid sequences or repeats</string>

View file

@ -76,6 +76,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
// Transient statuses are not persisted
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.AwaitingPlanSelection,
is PaymentAccountStatusValue.Inactive,
is PaymentAccountStatusValue.Error.ExposedDevice,
is PaymentAccountStatusValue.Error.Unavailable,
is PaymentAccountStatusValue.Error.NotSynced,

View file

@ -108,7 +108,28 @@ internal class PaymentAccountStatusValueDMConverterTest {
// GIVEN
val domain = PaymentAccountStatusValue.AwaitingPlanSelection(
source = StatusSource.ACTUAL,
tariffPlan = null,
cryptoCurrency = cryptoCurrency,
tariffPlan = mockk(),
)
// WHEN
val result = converter.convert(domain)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN domain Inactive WHEN convert THEN returns null (transient, not persisted)`() {
// GIVEN
val domain = PaymentAccountStatusValue.Inactive(
source = StatusSource.ACTUAL,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal("100"),
currency = "USD",
),
cryptoCurrency = cryptoCurrency,
tariffPlan = mockk(),
)
// WHEN

View file

@ -109,6 +109,7 @@ class IsAccountsModeEnabledUseCase(
is PaymentAccountStatusValue.Error,
is PaymentAccountStatusValue.IssuingCard,
is PaymentAccountStatusValue.AwaitingPlanSelection,
is PaymentAccountStatusValue.Inactive,
is PaymentAccountStatusValue.Loaded,
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.UnderReview,

View file

@ -28,6 +28,7 @@ sealed class PaymentAccountStatusValue {
is Error,
is IssuingCard,
is AwaitingPlanSelection,
is Inactive,
is Empty,
is NotCreated,
is UnderReview,
@ -56,6 +57,7 @@ sealed class PaymentAccountStatusValue {
is Deactivated -> copy(source = source, error = error ?: this.error)
is Loading,
is AwaitingPlanSelection,
is Inactive,
is Empty,
is NotCreated,
is Error,
@ -107,13 +109,30 @@ sealed class PaymentAccountStatusValue {
* Represents a state where KYC is approved but no tariff plan has been selected yet
*
* @property source The source of the status information.
* @property tariffPlan Current tariff plan (fallback Basic) if the backend already returns one
* Transient: not persisted in the local cache.
* @property cryptoCurrency The crypto currency held by the account.
* @property tariffPlan Current tariff plan
*/
@Serializable
data class AwaitingPlanSelection(
override val source: StatusSource,
val tariffPlan: TangemPayCustomerTariffPlan?,
val cryptoCurrency: CryptoCurrency.Token,
val tariffPlan: TangemPayCustomerTariffPlan,
) : PaymentAccountStatusValue()
/**
* Represents a state where a tariff plan has been selected and the card is being issued
*
* @property source The source of the status information.
* @property fiatBalance The fiat balance of state.
* @property cryptoCurrency The crypto currency held by the account.
* @property tariffPlan Current tariff plan
*/
@Serializable
data class Inactive(
override val source: StatusSource,
val fiatBalance: FiatBalance,
val cryptoCurrency: CryptoCurrency.Token,
val tariffPlan: TangemPayTariffPlanState,
) : PaymentAccountStatusValue()
/**

View file

@ -183,6 +183,7 @@ internal class ChooseTokenListItemConverter(
is PaymentAccountStatusValue.Error,
is PaymentAccountStatusValue.IssuingCard,
is PaymentAccountStatusValue.AwaitingPlanSelection,
is PaymentAccountStatusValue.Inactive,
PaymentAccountStatusValue.NotCreated,
is PaymentAccountStatusValue.UnderReview,
PaymentAccountStatusValue.Loading,

View file

@ -20,6 +20,7 @@ import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.models.pay.isFrozen
import com.tangem.domain.models.pay.thumbnailUrl
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.model.transformers.DetailsBalanceTransformer
import com.tangem.features.tangempay.utils.TangemPayDetailIntents
import com.tangem.features.tangempay.utils.hasWithdrawableAmount
import com.tangem.features.tangempay.utils.isFresh
@ -28,7 +29,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import com.tangem.core.ui.R as CoreUiR
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class TangemPayDetailsStateFactory(
private val onBack: () -> Unit,
private val onOpenMenu: () -> Unit,
@ -159,6 +160,51 @@ internal class TangemPayDetailsStateFactory(
)
}
fun getInactiveState(status: PaymentAccountStatusValue.Inactive): TangemPayDetailsUM {
val notification = notificationFactory.createAwaitingDepositConfig(status.tariffPlan)
return TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = onBack,
onOpenMenu = onOpenMenu,
items = getTopBarMenuItems(),
itemsV2 = getTopBarMenuItemsV2(tariffPlan = null),
),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
onRefresh = intents::onRefreshSwipe,
),
balanceBlockState = TangemPayDetailsBalanceBlockState.Content(
actionButtons = getActionButtonsConfig(
isAddFundsEnabled = true,
isWithdrawEnabled = false,
),
cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState(
cards = persistentListOf(
TangemPayDetailsBalanceBlockState.Card(
lastDigits = "",
imageUrl = null,
onClick = {},
isEnabled = false,
isFrozen = false,
state = TangemPayCardUiState.InProgress,
),
),
onAddCardClick = intents::onAddCardClick,
isAddCardEnabled = false,
),
fiatBalance = DetailsBalanceTransformer.getFiatBalanceText(status.fiatBalance),
isInactive = true,
isNegative = false,
isBalanceFlickering = false,
),
isBalanceHidden = false,
addToWalletBlockState = null,
errorNotificationConfig = notification,
accountDeactivatedNotificationConfig = null,
cashbackBlockState = null,
)
}
private fun getTopBarMenuItems(): ImmutableList<TangemDropdownMenuItem> {
return persistentListOf(
TangemDropdownMenuItem(

View file

@ -110,6 +110,7 @@ internal sealed class TangemPayDetailsBalanceBlockState {
val fiatBalance: TextReference,
val isBalanceFlickering: Boolean,
val isNegative: Boolean,
val isInactive: Boolean,
val isMuted: Boolean = false,
) : TangemPayDetailsBalanceBlockState()

View file

@ -56,6 +56,7 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.features.tangempay.model.transformers.*
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.tiers.feeCurrency
import com.tangem.features.tangempay.utils.*
import com.tangem.features.tokendetails.ExpressTransactionsEvent
import com.tangem.features.tokendetails.ExpressTransactionsEventListener
@ -171,6 +172,9 @@ internal class TangemPayDetailsModel @Inject constructor(
)
uiState.update { balanceTransformer.transform(stateFactory.getLoadedState(state)) }
}
is PaymentAccountStatusValue.Inactive -> uiState.update {
stateFactory.getInactiveState(state)
}
else -> uiState.update { stateFactory.getLoadingState() }
}
}
@ -484,11 +488,6 @@ internal class TangemPayDetailsModel @Inject constructor(
)
return
}
val activeCardsCount = currentStatus.value.ifLoadedOrNull { loaded -> loaded.cards.count() } ?: 0
if (activeCardsCount >= ALLOWED_MAX_CARDS_COUNT) {
uiMessageSender.send(TangemPayMessagesFactory.createMaximumCardsIssued(maxCards = ALLOWED_MAX_CARDS_COUNT))
return
}
modelScope.launch {
val offer = getCustomerOffers.additionalCardOffer(userWalletId).getOrNull()
if (offer == null) {
@ -569,8 +568,4 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun showBottomSheetError(type: TangemPayDetailsErrorType) {
uiMessageSender.send(message = TangemPayMessagesFactory.createErrorMessage(errorType = type))
}
private companion object {
const val ALLOWED_MAX_CARDS_COUNT = 3
}
}

View file

@ -12,6 +12,7 @@ import java.util.Currency
internal class DetailsBalanceTransformer(
private val fiatBalance: PaymentAccountStatusValue.FiatBalance,
private val isInactive: Boolean = false,
private val isMuted: Boolean = false,
) : Transformer<TangemPayDetailsUM> {
@ -23,11 +24,13 @@ internal class DetailsBalanceTransformer(
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
isMuted = isMuted,
isNegative = fiatBalance.availableBalance.signum() < 0,
isInactive = isInactive,
)
return prevState.copy(balanceBlockState = balance)
}
private fun getFiatBalanceText(fiatBalance: PaymentAccountStatusValue.FiatBalance): TextReference {
companion object {
fun getFiatBalanceText(fiatBalance: PaymentAccountStatusValue.FiatBalance): TextReference {
val currency = Currency.getInstance(fiatBalance.currency)
return fiatBalance.availableBalance.formatStyled {
fiat(
@ -38,3 +41,4 @@ internal class DetailsBalanceTransformer(
}
}
}
}

View file

@ -9,6 +9,9 @@ import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlan
import org.joda.time.format.DateTimeFormatter
val TangemPayTariffPlan.feeCurrency: String?
get() = fees.firstOrNull()?.currency
fun TangemPayTariffPlan.formatRecurringFeeOrNull(): String? {
val fee = fees.find { it.type == TangemPayTariffPlan.Fee.Type.RECURRING } ?: return null
val currency = getJavaCurrencyByCode(fee.currency)

View file

@ -510,6 +510,7 @@ internal class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<T
isAddCardEnabled = true,
),
isNegative = false,
isInactive = false,
),
isBalanceHidden = false,
addToWalletBlockState = AddToWalletBlockState(

View file

@ -40,6 +40,7 @@ import com.tangem.core.ui.components.topFade
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.topbar.TangemTopBar
@ -359,21 +360,50 @@ private fun BalanceBlock(
fadeOut(animationSpec = tween(durationMillis = 90))
},
) { animatedState ->
when (animatedState) {
BalanceValue(animatedState, isBalanceHidden)
}
Text(
modifier = Modifier.padding(vertical = TangemTheme.dimens2.x1),
text = stringResourceSafe(R.string.token_details_balance_total),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
)
when (state) {
is TangemPayDetailsBalanceBlockState.Loading -> Unit
is TangemPayDetailsBalanceBlockState.Content -> {
TangemBadge(
modifier = Modifier.padding(vertical = TangemTheme.dimens2.x1),
text = resourceReference(R.string.tangempay_status_inactive),
variant = TangemBadge.Variant.Outline,
status = TangemBadge.Status.Warning,
size = TangemBadge.Size.X6,
iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_information_24),
)
}
is TangemPayDetailsBalanceBlockState.Error -> Unit
}
}
}
@Composable
private fun BalanceValue(state: TangemPayDetailsBalanceBlockState, isBalanceHidden: Boolean) {
when (state) {
is TangemPayDetailsBalanceBlockState.Loading -> TangemShimmer(
style = TangemTheme.typography3.heading.medium,
)
is TangemPayDetailsBalanceBlockState.Content -> {
val balanceColor = when {
animatedState.isMuted -> TangemTheme.colors3.text.secondary
animatedState.isNegative -> TangemTheme.colors3.text.status.error
state.isMuted -> TangemTheme.colors3.text.secondary
state.isNegative -> TangemTheme.colors3.text.status.error
else -> TangemTheme.colors3.text.primary
}
Text(
modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE),
text = animatedState.fiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
text = state.fiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
style = TangemTheme.typography3.display.medium.applyBladeBrush(
isEnabled = animatedState.isBalanceFlickering,
isEnabled = state.isBalanceFlickering,
textColor = balanceColor,
),
color = balanceColor,
@ -394,15 +424,6 @@ private fun BalanceBlock(
}
}
Text(
modifier = Modifier.padding(vertical = TangemTheme.dimens2.x1),
text = stringResourceSafe(R.string.token_details_balance_total),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
)
}
}
@Composable
private fun CardsBlock(
cardsBlockState: TangemPayDetailsBalanceBlockState.CardsBlockState,

View file

@ -12,6 +12,8 @@ internal val AccountStatus.Payment.cryptoCurrency: CryptoCurrency.Token
get() = when (val v = value) {
is PaymentAccountStatusValue.Loaded -> v.cryptoCurrency
is PaymentAccountStatusValue.Deactivated -> v.cryptoCurrency
is PaymentAccountStatusValue.Inactive -> v.cryptoCurrency
is PaymentAccountStatusValue.AwaitingPlanSelection -> v.cryptoCurrency
else -> error("TangemPayDetails opened with unsupported status: $v")
}

View file

@ -5,12 +5,10 @@ import com.tangem.core.ui.components.bottomsheets.message.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
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
@ -151,26 +149,6 @@ internal object TangemPayMessagesFactory {
}
}
fun createMaximumCardsIssued(maxCards: Int): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {
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(
id = R.string.tangempay_maximum_cards_issued_description,
formatArgs = wrappedList(maxCards),
)
}
primaryButton {
text = resourceReference(R.string.common_got_it)
onClick { closeBs() }
}
}
}
// TODO v_rodionov: #[REDACTED_TASK_KEY] fix hardcoded strings
fun createStayOnPlanMessage(
planName: String,

View file

@ -153,6 +153,7 @@ internal class CashbackBlockTransformerTest {
fiatBalance = TextReference.EMPTY,
isBalanceFlickering = false,
isNegative = false,
isInactive = false,
),
addToWalletBlockState = null,
isBalanceHidden = false,

View file

@ -61,6 +61,7 @@ internal class TangemPayActionButtonsTransformerTest {
fiatBalance = TextReference.EMPTY,
isBalanceFlickering = false,
isNegative = false,
isInactive = false,
),
addToWalletBlockState = null,
isBalanceHidden = false,

View file

@ -27,6 +27,7 @@ internal class WalletTangemPayAnalyticsEventSender @Inject constructor(
PaymentAccountStatusValue.NotCreated,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.AwaitingPlanSelection,
is PaymentAccountStatusValue.Inactive,
is PaymentAccountStatusValue.Deactivated,
-> return
}

View file

@ -237,6 +237,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
is PaymentAccountStatusValue.Error.ExposedDevice,
is PaymentAccountStatusValue.IssuingCard,
is PaymentAccountStatusValue.AwaitingPlanSelection,
is PaymentAccountStatusValue.Inactive,
is PaymentAccountStatusValue.Loaded,
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.UnderReview,

View file

@ -285,6 +285,7 @@ internal class GetWalletNotificationsFactory @Inject constructor(
is PaymentAccountStatusValue.Error.ExposedDevice,
is PaymentAccountStatusValue.IssuingCard,
is PaymentAccountStatusValue.AwaitingPlanSelection,
is PaymentAccountStatusValue.Inactive,
is PaymentAccountStatusValue.Loaded,
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.UnderReview,

View file

@ -38,6 +38,9 @@ internal class TangemPayMainBlockConverter(
is PaymentAccountStatusValue.AwaitingPlanSelection -> TangemPayMainUM.SelectPlan(
onClick = { tangemPayClickIntents.onSelectPlanClicked(value) },
)
is PaymentAccountStatusValue.Inactive -> TangemPayMainUM.IssuingCard(
onClick = { tangemPayClickIntents.openDetails(value) },
)
is PaymentAccountStatusValue.UnderReview -> TangemPayMainUM.UnderReview(
subtitle = when (statusValue.kycStatus) {
KycStatus.REJECTED -> TextReference.Res(R.string.tangempay_kyc_has_failed)