Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-30 06:25:07 -07:00
parent 3fefb7bf52
commit ff958f6bba
23 changed files with 631 additions and 24 deletions

View file

@ -131,6 +131,10 @@
"name": "AND_15741_VISA_PAY_REMOVE_ACCOUNT",
"version": "undefined"
},
{
"name": "AND_16041_VISA_TIERS_PLUS_PLAN",
"version": "undefined"
},
{
"name": "TWI_83_ADDRESS_BOOK_ENABLED",
"version": "undefined"

View file

@ -21,6 +21,32 @@ data class CustomerMeResponse(
@Json(name = "balance") val balance: BalanceResponse?,
@Json(name = "product_instances") val productInstances: List<ProductInstance>,
@Json(name = "cards") val cards: List<Card>,
@Json(name = "customer_tariff_plan") val customerTariffPlan: CustomerTariffPlan? = null,
)
@JsonClass(generateAdapter = true)
data class CustomerTariffPlan(
@Json(name = "status") val status: String?,
@Json(name = "next_billing_at") val nextBillingAt: String?,
@Json(name = "pending_transition_at") val pendingTransitionAt: String?,
@Json(name = "tariff_plan") val tariffPlan: TariffPlan?,
@Json(name = "pending_tariff_plan") val pendingTariffPlan: TariffPlan?,
)
@JsonClass(generateAdapter = true)
data class TariffPlan(
@Json(name = "id") val id: String?,
@Json(name = "type") val type: String?,
@Json(name = "name") val name: String?,
@Json(name = "description_items") val descriptionItems: List<DescriptionItem>?,
)
@JsonClass(generateAdapter = true)
data class DescriptionItem(
@Json(name = "type") val type: String?,
@Json(name = "order") val order: Int?,
@Json(name = "title") val title: String?,
@Json(name = "body") val body: String?,
)
@JsonClass(generateAdapter = true)

View file

@ -1951,6 +1951,10 @@
<string name="tangempay_pay_support">Pay Support</string>
<string name="tangempay_payment_account">Payment account</string>
<string name="tangempay_payment_account_sync_needed">Session expired</string>
<string name="tangempay_current_plan_title">Current plan</string>
<string name="tangempay_current_plan_section_card">Card related</string>
<string name="tangempay_current_plan_section_plan">Plan related</string>
<string name="tangempay_current_plan_change">Change plan</string>
<string name="tangempay_pin_validation_error_message">Invalid PIN: avoid sequences or repeats</string>
<string name="tangempay_reissue_card_confirm">Replace card</string>
<string name="tangempay_reissue_card_description">This generates a new set of card details. Your old details will stop working. You can\'t undo this.</string>

View file

@ -120,6 +120,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
},
error = null,
virtualAccount = VirtualAccountOnramp.None,
tariffPlan = null,
)
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.CACHE,

View file

@ -395,6 +395,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
),
error = null,
virtualAccount = virtualAccount,
tariffPlan = tariffPlan,
)
}

View file

@ -6,6 +6,8 @@ import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.api.pay.models.response.FiatBalance
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
@ -18,6 +20,7 @@ import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDat
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import org.joda.time.DateTime
internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, CustomerInfo> {
override fun convert(value: CustomerMeResponse.Result): CustomerInfo {
@ -45,9 +48,37 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
fiatBalance = fiatBalance?.toDomain(),
cryptoBalance = cryptoBalance?.toDomain(),
availableForWithdrawal = value.balance?.availableForWithdrawal?.amount.orZero(),
tariffPlan = value.customerTariffPlan?.toDomain(),
)
}
private fun CustomerMeResponse.CustomerTariffPlan.toDomain(): TangemPayCustomerTariffPlan? {
val plan = tariffPlan?.toDomain() ?: return null
return TangemPayCustomerTariffPlan(
status = TangemPayCustomerTariffPlan.Status.fromString(status),
plan = plan,
nextBillingAt = nextBillingAt.toDateTimeOrNull(),
pendingPlan = pendingTariffPlan?.toDomain(),
pendingTransitionAt = pendingTransitionAt.toDateTimeOrNull(),
)
}
private fun CustomerMeResponse.TariffPlan.toDomain(): TangemPayTariffPlan? {
val name = name ?: return null
return TangemPayTariffPlan(
type = TangemPayTariffPlan.Type.fromString(type),
name = name,
descriptionItems = descriptionItems.orEmpty().map { it.toDomain() },
)
}
private fun CustomerMeResponse.DescriptionItem.toDomain() = TangemPayTariffPlan.DescriptionItem(
section = TangemPayTariffPlan.Section.fromString(type),
order = order ?: 0,
title = title.orEmpty(),
body = body.orEmpty(),
)
private fun CustomerMeResponse.ProductInstance.toDomain(): ProductInstance {
val status = status.toDomain()
val cardFrozenState = when (status) {
@ -116,4 +147,6 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
CustomerMeResponse.ProductInstance.SpecificationDataType.ACCOUNT -> SpecificationDataType.ACCOUNT
CustomerMeResponse.ProductInstance.SpecificationDataType.CARD -> SpecificationDataType.CARD
}
private fun String?.toDateTimeOrNull(): DateTime? = this?.let { runCatching { DateTime.parse(it) }.getOrNull() }
}

View file

@ -151,6 +151,8 @@ sealed class PaymentAccountStatusValue {
* (see [copySealed]), or `null` when the status is up to date. Not persisted.
* @property virtualAccount Virtual Account (Visa on-ramp) availability VA MVP0 (TWI-1638).
* Transient: not persisted in the local cache.
* @property tariffPlan Current tariff plan with subscription data (Tiers).
* Transient: not persisted in the local cache.
*/
@Serializable
data class Loaded(
@ -163,6 +165,7 @@ sealed class PaymentAccountStatusValue {
val fiatRate: SerializedBigDecimal?,
val error: Error?,
val virtualAccount: VirtualAccountOnramp,
val tariffPlan: TangemPayCustomerTariffPlan?,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,

View file

@ -0,0 +1,50 @@
package com.tangem.domain.models.account
import com.tangem.domain.models.serialization.SerializedDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.Locale
/**
* Customer's current tariff plan.
*
* @property status Lifecycle status of the subscription.
* @property plan The currently active plan ([TangemPayTariffPlan]).
* @property nextBillingAt When the next plan fee is charged; `null` for free plans.
* @property pendingPlan Plan the customer will be moved to (scheduled downgrade), or `null`.
* @property pendingTransitionAt When [pendingPlan] is applied, or `null`.
*/
@Serializable
data class TangemPayCustomerTariffPlan(
@SerialName("status") val status: Status,
@SerialName("plan") val plan: TangemPayTariffPlan,
@SerialName("next_billing_at") val nextBillingAt: SerializedDateTime?,
@SerialName("pending_plan") val pendingPlan: TangemPayTariffPlan?,
@SerialName("pending_transition_at") val pendingTransitionAt: SerializedDateTime?,
) {
@Serializable
enum class Status {
@SerialName("ACTIVE")
ACTIVE,
@SerialName("TRANSITIONING")
TRANSITIONING,
@SerialName("CANCELED")
CANCELED,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"ACTIVE" -> ACTIVE
"TRANSITIONING" -> TRANSITIONING
"CANCELED" -> CANCELED
else -> UNKNOWN
}
}
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.domain.models.account
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.Locale
@Serializable
data class TangemPayTariffPlan(
@SerialName("type") val type: Type,
@SerialName("name") val name: String,
@SerialName("description_items") val descriptionItems: List<DescriptionItem>,
) {
@Serializable
data class DescriptionItem(
@SerialName("section") val section: Section,
@SerialName("order") val order: Int,
@SerialName("title") val title: String,
@SerialName("body") val body: String,
)
@Serializable
enum class Type {
@SerialName("BASIC")
BASIC,
@SerialName("PLUS")
PLUS,
@SerialName("PLUS_FF")
PLUS_FF,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"BASIC" -> BASIC
"PLUS" -> PLUS
"PLUS_FF" -> PLUS_FF
else -> UNKNOWN
}
}
}
@Serializable
enum class Section {
@SerialName("CARD_RELATED")
CARD_RELATED,
@SerialName("PLAN_RELATED")
PLAN_RELATED,
@SerialName("UNKNOWN")
UNKNOWN,
;
companion object {
fun fromString(value: String?) = when (value?.uppercase(Locale.US)) {
"CARD_RELATED" -> CARD_RELATED
"PLAN_RELATED" -> PLAN_RELATED
else -> UNKNOWN
}
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.pay.model
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
@ -30,6 +31,7 @@ data class CustomerInfo(
val fiatBalance: PaymentAccountStatusValue.FiatBalance?,
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?,
val availableForWithdrawal: BigDecimal,
val tariffPlan: TangemPayCustomerTariffPlan?,
) {
/** Transitional single-card accessor — returns the first product instance, or null if none. */

View file

@ -5,4 +5,5 @@ interface TangemPayFeatureToggles {
val isCloseCardEnabled: Boolean
val isRemoveAccountEnabled: Boolean
val isMultipleCardsEnabled: Boolean
val isTiersPlusPlanEnabled: Boolean
}

View file

@ -18,4 +18,7 @@ internal class DefaultTangemPayFeatureToggles(
override val isMultipleCardsEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15235_VISA_MULTIPLE_CARDS)
override val isTiersPlusPlanEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16041_VISA_TIERS_PLUS_PLAN)
}

View file

@ -17,6 +17,7 @@ import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.tiers.current.TangemPayCurrentPlanComponent
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.tokenreceive.TokenReceiveComponent
@ -83,6 +84,12 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru
userWalletId = params.initialStatus.userWalletId,
),
)
is TangemPayAccountDetailsInnerRoute.CurrentPlan -> TangemPayCurrentPlanComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayCurrentPlanComponent.Params(
tariffPlan = config.tariffPlan,
),
)
}
private fun onChildBack() {

View file

@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.features.tangempay.closure.TangemPayCloseCardModel
import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupModel
import com.tangem.features.tangempay.model.*
import com.tangem.features.tangempay.tiers.current.TangemPayCurrentPlanModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -79,4 +80,9 @@ internal interface TangemPayModelModule {
@IntoMap
@ClassKey(TangemPayCardLimitSetupModel::class)
fun bindTangemPayCardLimitSetupModel(model: TangemPayCardLimitSetupModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayCurrentPlanModel::class)
fun bindTangemPayCurrentPlanModel(model: TangemPayCurrentPlanModel): Model
}

View file

@ -6,11 +6,13 @@ import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.themedColor
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_document_20
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
@ -32,6 +34,7 @@ internal class TangemPayDetailsStateFactory(
private val isRedesignEnabled: Boolean,
private val isRemoveAccountEnabled: Boolean,
private val isMultipleCardsEnabled: Boolean,
private val isTiersPlusPlanEnabled: Boolean,
) {
fun getLoadingState(): TangemPayDetailsUM {
return TangemPayDetailsUM(
@ -39,7 +42,7 @@ internal class TangemPayDetailsStateFactory(
onBackClick = onBack,
onOpenMenu = onOpenMenu,
items = getTopBarMenuItems(),
itemsV2 = getTopBarMenuItemsV2(),
itemsV2 = getTopBarMenuItemsV2(tariffPlan = null),
),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
@ -72,7 +75,7 @@ internal class TangemPayDetailsStateFactory(
onBackClick = onBack,
onOpenMenu = onOpenMenu,
items = getTopBarMenuItems(),
itemsV2 = getTopBarMenuItemsV2(),
itemsV2 = getTopBarMenuItemsV2(tariffPlan = status.tariffPlan),
),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
@ -239,29 +242,44 @@ internal class TangemPayDetailsStateFactory(
}.toImmutableList()
}
private fun getTopBarMenuItemsV2(): ImmutableList<TangemPayDropDownItemUM> {
return persistentListOf(
TangemPayDropDownItemUM(
title = resourceReference(R.string.tangem_pay_terms_limits),
onClick = intents::onClickTermsAndLimits,
icon = TangemIconUM.Icon(
imageVector = Icons.ic_document_20,
tintReference = {
TangemTheme.colors3.icon.primary
},
private fun getTopBarMenuItemsV2(
tariffPlan: TangemPayCustomerTariffPlan?,
): ImmutableList<TangemPayDropDownItemUM> {
return buildList {
if (isTiersPlusPlanEnabled && tariffPlan != null) {
add(
TangemPayDropDownItemUM(
title = resourceReference(R.string.tangempay_current_plan_title),
onClick = { intents.onClickCurrentPlan(tariffPlan) },
icon = TangemIconUM.Icon(
iconRes = CoreUiR.drawable.ic_information_24,
tintReference = { TangemTheme.colors3.icon.primary },
),
subtitle = stringReference(tariffPlan.plan.name),
),
)
}
add(
TangemPayDropDownItemUM(
title = resourceReference(R.string.tangem_pay_terms_limits),
onClick = intents::onClickTermsAndLimits,
icon = TangemIconUM.Icon(
imageVector = Icons.ic_document_20,
tintReference = { TangemTheme.colors3.icon.primary },
),
),
),
TangemPayDropDownItemUM(
title = resourceReference(R.string.tangempay_pay_support),
onClick = intents::onContactSupportClicked,
icon = TangemIconUM.Icon(
iconRes = R.drawable.ic_mail_20,
tintReference = {
TangemTheme.colors3.icon.primary
},
)
add(
TangemPayDropDownItemUM(
title = resourceReference(R.string.tangempay_pay_support),
onClick = intents::onContactSupportClicked,
icon = TangemIconUM.Icon(
iconRes = R.drawable.ic_mail_20,
tintReference = { TangemTheme.colors3.icon.primary },
),
),
),
)
)
}.toImmutableList()
}
fun getActionButtonsConfig(

View file

@ -25,6 +25,7 @@ import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
@ -62,7 +63,7 @@ import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
@Stable
@ModelScoped
internal class TangemPayDetailsModel @Inject constructor(
@ -109,6 +110,7 @@ internal class TangemPayDetailsModel @Inject constructor(
isRedesignEnabled = isRedesignEnabled(),
isRemoveAccountEnabled = tangemPayFeatureToggles.isRemoveAccountEnabled,
isMultipleCardsEnabled = isMultipleCardsEnabled,
isTiersPlusPlanEnabled = tangemPayFeatureToggles.isTiersPlusPlanEnabled,
)
val uiState: StateFlow<TangemPayDetailsUM>
@ -380,6 +382,10 @@ internal class TangemPayDetailsModel @Inject constructor(
urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK)
}
override fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan) {
router.push(TangemPayAccountDetailsInnerRoute.CurrentPlan(tariffPlan))
}
override fun onCardClick(cardId: String) {
analytics.send(TangemPayAnalyticsEvents.CardIconClicked())
router.push(TangemPayAccountDetailsInnerRoute.CardDetails(cardId = cardId))

View file

@ -1,6 +1,7 @@
package com.tangem.features.tangempay.navigation
import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.pay.TangemPayCard
import kotlinx.serialization.Serializable
@ -14,4 +15,9 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route {
@Serializable
data class AddToWallet(val card: TangemPayCard) : TangemPayAccountDetailsInnerRoute()
@Serializable
data class CurrentPlan(
val tariffPlan: TangemPayCustomerTariffPlan,
) : TangemPayAccountDetailsInnerRoute()
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.tangempay.tiers.current
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.ComposableContentComponent
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
internal class TangemPayCurrentPlanComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: TangemPayCurrentPlanModel = getOrCreateModel(params = params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
TangemPayCurrentPlanScreen(state = state, modifier = modifier)
}
data class Params(val tariffPlan: TangemPayCustomerTariffPlan)
}

View file

@ -0,0 +1,66 @@
package com.tangem.features.tangempay.tiers.current
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.core.decompose.navigation.Router
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.features.tangempay.details.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayCurrentPlanModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
) : Model() {
private val params = paramsContainer.require<TangemPayCurrentPlanComponent.Params>()
val state: StateFlow<TangemPayCurrentPlanUM>
field = MutableStateFlow(createState(params.tariffPlan.plan))
private fun createState(plan: TangemPayTariffPlan): TangemPayCurrentPlanUM = TangemPayCurrentPlanUM(
planName = stringReference(plan.name),
notification = null,
sections = buildSections(plan),
onBackClick = router::pop,
onChangePlanClick = {},
)
private fun buildSections(plan: TangemPayTariffPlan) = persistentListOf(
sectionOf(plan, TangemPayTariffPlan.Section.CARD_RELATED, R.string.tangempay_current_plan_section_card),
sectionOf(plan, TangemPayTariffPlan.Section.PLAN_RELATED, R.string.tangempay_current_plan_section_plan),
)
.filter { it.items.isNotEmpty() }
.toImmutableList()
private fun sectionOf(
plan: TangemPayTariffPlan,
section: TangemPayTariffPlan.Section,
headerStrRes: Int,
): TangemPayCurrentPlanUM.Section {
return TangemPayCurrentPlanUM.Section(
header = resourceReference(headerStrRes),
items = plan.descriptionItems
.filter { it.section == section }
.sortedBy { it.order }
.map { item ->
TangemPayCurrentPlanUM.InfoItem(
label = stringReference(item.title),
value = stringReference(item.body),
)
}
.toImmutableList(),
)
}
}

View file

@ -0,0 +1,237 @@
package com.tangem.features.tangempay.tiers.current
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.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
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.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.tangempay.details.impl.R
import kotlinx.collections.immutable.persistentListOf
import com.tangem.core.ui.R as CoreUiR
@Composable
internal fun TangemPayCurrentPlanScreen(state: TangemPayCurrentPlanUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors3.bg.primary),
) {
Column(modifier = Modifier.fillMaxSize()) {
CurrentPlanTopBar(state = state)
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(top = 12.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
state.notification?.let { PlanNotification(notification = it) }
state.sections.fastForEach { section -> PlanSection(section = section) }
}
ChangePlanFooter(state = state)
}
}
}
@Composable
private fun CurrentPlanTopBar(state: TangemPayCurrentPlanUM) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.tangempay_current_plan_title),
subtitle = state.planName,
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_arrow_back_28),
onClick = state.onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
}
@Composable
private fun PlanNotification(notification: TangemPayCurrentPlanUM.Notification, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors3.bg.status.infoSubtle)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
modifier = Modifier.size(20.dp),
painter = painterResource(id = CoreUiR.drawable.ic_information_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.status.info,
)
Text(
text = notification.text.resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.primary,
)
}
notification.button?.let { button ->
TangemButton(
modifier = Modifier.fillMaxWidth(),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X11,
text = button.text,
onClick = button.onClick,
)
}
}
}
@Composable
private fun PlanSection(section: TangemPayCurrentPlanUM.Section, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
Text(
modifier = Modifier.padding(
horizontal = 20.dp,
vertical = 8.dp,
),
text = section.header.resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
)
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(TangemTheme.colors3.bg.secondary),
) {
section.items.fastForEachIndexed { index, item ->
PlanInfoRow(item = item)
if (index != section.items.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 1.dp,
color = TangemTheme.colors3.border.secondary,
)
}
}
}
}
}
@Composable
private fun PlanInfoRow(item: TangemPayCurrentPlanUM.InfoItem, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = item.label.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = item.value.resolveReference(),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
)
}
}
@Composable
private fun ChangePlanFooter(state: TangemPayCurrentPlanUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.navigationBarsPadding(),
) {
TangemButton(
modifier = Modifier.fillMaxWidth(),
variant = TangemButton.Variant.Primary,
size = TangemButton.Size.X12,
text = resourceReference(R.string.tangempay_current_plan_change),
onClick = state.onChangePlanClick,
)
}
}
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO)
@Composable
private fun TangemPayCurrentPlanScreenPreview() {
TangemThemePreviewRedesign {
TangemPayCurrentPlanScreen(state = previewState())
}
}
private fun previewState() = TangemPayCurrentPlanUM(
planName = stringReference("Plus"),
notification = TangemPayCurrentPlanUM.Notification(
text = stringReference(
"Your Plus plan is active till Mar 23, then we will move you to Basic. $29.99 won't be charged.",
),
button = TangemPayCurrentPlanUM.Notification.Button(
text = stringReference("Stay on Plus"),
onClick = {},
),
),
sections = persistentListOf(
TangemPayCurrentPlanUM.Section(
header = stringReference("Card related"),
items = persistentListOf(
TangemPayCurrentPlanUM.InfoItem(stringReference("Visa Programme"), stringReference("Signature")),
TangemPayCurrentPlanUM.InfoItem(
label = stringReference("Max daily spending limit"),
value = stringReference("$50.000"),
),
TangemPayCurrentPlanUM.InfoItem(stringReference("FX fee"), stringReference("1%")),
),
),
TangemPayCurrentPlanUM.Section(
header = stringReference("Plan related"),
items = persistentListOf(
TangemPayCurrentPlanUM.InfoItem(stringReference("Plan fee"), stringReference("\$29.99/month")),
TangemPayCurrentPlanUM.InfoItem(stringReference("Max cards issued"), stringReference("5")),
TangemPayCurrentPlanUM.InfoItem(
label = stringReference("Additional benefits"),
value = stringReference("Benefit 1, Benefit 2, Benefit 3"),
),
),
),
),
onBackClick = {},
onChangePlanClick = {},
)

View file

@ -0,0 +1,38 @@
package com.tangem.features.tangempay.tiers.current
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class TangemPayCurrentPlanUM(
val planName: TextReference,
val notification: Notification?,
val sections: ImmutableList<Section>,
val onBackClick: () -> Unit,
val onChangePlanClick: () -> Unit,
) {
@Immutable
data class Notification(
val text: TextReference,
val button: Button? = null,
) {
@Immutable
data class Button(
val text: TextReference,
val onClick: () -> Unit,
)
}
@Immutable
data class Section(
val header: TextReference,
val items: ImmutableList<InfoItem>,
)
@Immutable
data class InfoItem(
val label: TextReference,
val value: TextReference,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.tangempay.utils
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
internal interface TangemPayDetailIntents {
fun onContactSupportClicked()
@ -9,6 +10,7 @@ internal interface TangemPayDetailIntents {
fun onClickAddFunds()
fun onClickWithdraw()
fun onClickTermsAndLimits()
fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan)
fun onCardClick(cardId: String)
fun onAddCardClick()
fun onRemoveAccount()

View file

@ -32,6 +32,7 @@ internal class TangemPayDetailsStateFactoryTest {
isRedesignEnabled = true,
isRemoveAccountEnabled = true,
isMultipleCardsEnabled = true,
isTiersPlusPlanEnabled = true,
)
@BeforeEach