Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-20 05:56:16 -07:00
parent 5705579468
commit 91942930d6
11 changed files with 314 additions and 25 deletions

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
@JsonClass(generateAdapter = true)
data class CustomerMeResponse(
@ -30,6 +31,8 @@ data class CustomerMeResponse(
@Json(name = "updated_at") val updatedAt: String,
@Json(name = "payment_account_id") val paymentAccountId: String,
@Json(name = "display_name") val displayName: String?,
@Json(name = "actual_card_limit") val actualCardLimit: CardLimit?,
@Json(name = "admin_card_limit") val adminCardLimit: CardLimit?,
) {
@JsonClass(generateAdapter = false)
enum class Status {
@ -71,6 +74,12 @@ data class CustomerMeResponse(
}
}
@JsonClass(generateAdapter = true)
data class CardLimit(
@Json(name = "amount") val amount: BigDecimal,
@Json(name = "period_type") val periodType: String,
)
@JsonClass(generateAdapter = true)
data class PaymentAccount(
@Json(name = "id") val id: String,

View file

@ -1643,6 +1643,11 @@
<string name="tangempay_card_details_withdraw_in_progress_description">You can\'t initiate swap or new withdrawal till the current one is finished</string>
<string name="tangempay_card_details_withdraw_in_progress_title">Withdrawal in progress</string>
<string name="tangempay_card_page_settings_title">Card settings</string>
<string name="tangempay_card_page_daily_limit_title">Daily limit</string>
<string name="tangempay_card_page_daily_limit_current_limit">Current limit</string>
<string name="tangempay_card_page_daily_limit_change">Change</string>
<string name="tangempay_card_page_daily_limit_error_title">Daily limit unavailable</string>
<string name="tangempay_card_page_daily_limit_error_description">We couldn\'t load your daily limit. Please try again.</string>
<string name="tangempay_change_pin_code">Change PIN-code</string>
<string name="tangempay_come_back_if_forget_pin">Come back to the app if you forget it.</string>
<string name="tangempay_digital_card">Digital card</string>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="M9.991,1.668C14.55,1.668 18.333,5.443 18.333,10.001C18.333,14.552 14.559,18.334 10,18.335C5.45,18.335 1.666,14.552 1.666,10.001C1.666,5.443 5.441,1.668 9.991,1.668ZM14.658,5.324C14.443,5.109 14.1,5.091 13.863,5.282L9.415,8.874C8.859,9.323 8.814,10.156 9.32,10.661C9.825,11.167 10.658,11.122 11.107,10.566L14.699,6.118C14.89,5.881 14.873,5.539 14.658,5.324ZM4.352,9.051C3.827,9.051 3.401,9.476 3.401,10.001C3.401,10.526 3.827,10.952 4.352,10.952C4.877,10.952 5.302,10.526 5.302,10.001C5.302,9.476 4.877,9.051 4.352,9.051ZM15.648,9.051C15.123,9.051 14.698,9.476 14.697,10.001C14.697,10.526 15.123,10.952 15.648,10.952C16.173,10.952 16.598,10.526 16.598,10.001C16.598,9.476 16.173,9.051 15.648,9.051ZM6.678,5.334C6.307,4.963 5.705,4.963 5.334,5.334C4.963,5.706 4.963,6.307 5.334,6.679C5.705,7.05 6.307,7.05 6.678,6.679C7.049,6.307 7.049,5.706 6.678,5.334ZM10,3.403C9.541,3.403 9.157,3.729 9.068,4.162C9.056,4.225 9.049,4.289 9.049,4.355C9.049,4.88 9.475,5.306 10,5.306C10.525,5.306 10.951,4.88 10.951,4.355C10.951,4.289 10.944,4.225 10.931,4.162C10.843,3.729 10.46,3.403 10,3.403Z" />
</vector>

View file

@ -25,6 +25,8 @@ import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.model.TangemPayCardLimit
import com.tangem.domain.pay.model.TangemPayCardLimitPeriod
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.error.VisaApiError
@ -207,6 +209,8 @@ internal class DefaultOnboardingRepository @Inject constructor(
cardId = instance.cardId,
frozenState = cardFrozenState,
displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null,
actualCardLimit = instance.actualCardLimit?.parseCardLimit(),
adminCardLimit = instance.adminCardLimit?.parseCardLimit(),
)
}
return CustomerInfo(
@ -219,6 +223,13 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
}
private fun CustomerMeResponse.CardLimit.parseCardLimit(): TangemPayCardLimit {
return TangemPayCardLimit(
amount = amount,
period = TangemPayCardLimitPeriod.fromString(periodType),
)
}
private fun sendKycAnalytics(kycStatus: KycStatus) {
val event = when (kycStatus) {
KycStatus.APPROVED -> TangemPayAnalyticsEvents.KycPassedAndOrderCreated()

View file

@ -51,6 +51,8 @@ data class CustomerInfo(
val cardId: String,
val frozenState: TangemPayCardFrozenState,
val displayName: CardDisplayName?,
val actualCardLimit: TangemPayCardLimit?,
val adminCardLimit: TangemPayCardLimit?,
)
data class CardInfo(

View file

@ -0,0 +1,32 @@
package com.tangem.domain.pay.model
import java.math.BigDecimal
import java.util.Locale
data class TangemPayCardLimit(
val amount: BigDecimal,
val period: TangemPayCardLimitPeriod,
)
enum class TangemPayCardLimitPeriod {
DAY,
WEEK,
MONTH,
YEAR,
ALL_TIME,
AUTHORIZATION,
UNKNOWN,
;
companion object {
fun fromString(value: String) = when (value.uppercase(Locale.US)) {
"DAY" -> DAY
"WEEK" -> WEEK
"MONTH" -> MONTH
"YEAR" -> YEAR
"ALL_TIME" -> ALL_TIME
"AUTHORIZATION" -> AUTHORIZATION
else -> UNKNOWN
}
}
}

View file

@ -9,6 +9,7 @@ import kotlinx.collections.immutable.persistentListOf
internal data class TangemPayCardPageUM(
val settings: ImmutableList<TangemPayCardPageSetting>,
val onBackClick: () -> Unit,
val dailyLimitState: TangemPayDailyLimitBlockState,
val addToWalletBlockState: AddToWalletBlockState? = null,
val isReissueInProgress: Boolean = false,
) {
@ -21,11 +22,13 @@ internal data class TangemPayCardPageUM(
TangemPayCardPageSetting(TextReference.Str("Reissue Card")) {},
),
isReissueInProgress: Boolean = false,
dailyLimitState: TangemPayDailyLimitBlockState = TangemPayDailyLimitBlockState.Content.stub(),
) = TangemPayCardPageUM(
addToWalletBlockState = addToWalletBlockState,
settings = settings,
onBackClick = {},
isReissueInProgress = isReissueInProgress,
dailyLimitState = dailyLimitState,
)
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.tangempay.entity
import androidx.compose.runtime.Immutable
@Immutable
internal sealed interface TangemPayDailyLimitBlockState {
data object Loading : TangemPayDailyLimitBlockState
data object Error : TangemPayDailyLimitBlockState
data class Content(
val limit: String,
val onChangeClick: () -> Unit,
) : TangemPayDailyLimitBlockState {
companion object {
fun stub() = Content(
limit = "$5,000",
onChangeClick = {},
)
}
}
}

View file

@ -14,11 +14,16 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayCardLimitPeriod
import com.tangem.domain.pay.model.TangemPayReissueOrderInfo
import com.tangem.domain.pay.model.TangemPayTopUpData
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
@ -33,6 +38,7 @@ import com.tangem.features.tangempay.entity.TangemPayCardPageSetting
import com.tangem.features.tangempay.entity.TangemPayCardPageUM
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState
import com.tangem.features.tangempay.utils.TangemPayMessagesFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
@ -55,6 +61,7 @@ internal class TangemPayCardPageModel @Inject constructor(
private val router: Router,
private val analytics: AnalyticsEventHandler,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val onboardingRepository: OnboardingRepository,
private val uiMessageSender: UiMessageSender,
private val reissueCardRepository: TangemPayReissueCardRepository,
) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener {
@ -70,6 +77,7 @@ internal class TangemPayCardPageModel @Inject constructor(
field = MutableStateFlow(
TangemPayCardPageUM(
onBackClick = router::pop,
dailyLimitState = TangemPayDailyLimitBlockState.Loading,
settings = persistentListOf(
TangemPayCardPageSetting(
title = TextReference.Res(R.string.tangempay_card_details_change_pin),
@ -92,6 +100,7 @@ internal class TangemPayCardPageModel @Inject constructor(
init {
// TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed
fetchAddToWalletBanner()
fetchCardLimit()
subscribeToCardFrozenState()
}
@ -256,6 +265,39 @@ internal class TangemPayCardPageModel @Inject constructor(
}.saveIn(addToWalletBannerJobHolder)
}
private fun fetchCardLimit() {
modelScope.launch {
onboardingRepository.getCustomerInfo(params.userWalletId)
.onRight { info ->
val productInstance = info.productInstance
val cardInfo = info.cardInfo
val actualCardLimit = productInstance?.actualCardLimit
val dailyLimitState = if (
productInstance != null &&
cardInfo != null &&
actualCardLimit?.period == TangemPayCardLimitPeriod.DAY
) {
val limit = actualCardLimit.amount.format {
val symbol = getJavaCurrencyByCode(cardInfo.currencyCode).symbol
fiat(cardInfo.currencyCode, symbol)
}
TangemPayDailyLimitBlockState.Content(
limit = limit,
onChangeClick = {}, // TODO v_rodionov: #[REDACTED_TASK_KEY]
)
} else {
TangemPayDailyLimitBlockState.Error
}
uiState.update { it.copy(dailyLimitState = dailyLimitState) }
}
.onLeft {
uiState.update { state ->
state.copy(dailyLimitState = TangemPayDailyLimitBlockState.Error)
}
}
}
}
private fun onClickAddToWallet() {
router.push(TangemPayDetailsInnerRoute.AddToWallet)
}

View file

@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
@ -47,6 +49,7 @@ import com.tangem.features.tangempay.entity.DisplayNameState
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.entity.TangemPayCardPageSetting
import com.tangem.features.tangempay.entity.TangemPayCardPageUM
import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState
import kotlinx.collections.immutable.ImmutableList
private const val CONTENT_FADE_DURATION_MS = 300
@ -88,33 +91,23 @@ internal fun TangemPayCardPageScreen(
)
}
if (state.addToWalletBlockState != null) {
item(key = "GooglePay") {
val visibleState = remember { MutableTransitionState(false).apply { targetState = true } }
AnimatedVisibility(
visibleState = visibleState,
enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)),
exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)),
) {
TangemPayAddToWalletBlock(
state = state.addToWalletBlockState,
)
}
cardPageItem(key = "GooglePay") {
TangemPayAddToWalletBlock(state = state.addToWalletBlockState)
}
}
item(key = "Settings") {
val visibleState = remember { MutableTransitionState(false).apply { targetState = true } }
AnimatedVisibility(
visibleState = visibleState,
enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)),
exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)),
) {
if (state.isReissueInProgress) {
TangemPayReplacingCardBlock()
} else {
TangemPayCardPageSettingsBlock(
settings = state.settings,
)
}
cardPageItem(key = "Limit") {
TangemPayDailyLimitBlock(state = state.dailyLimitState)
}
if (state.dailyLimitState == TangemPayDailyLimitBlockState.Error) {
cardPageItem(key = "LimitError") {
TangemPayDailyLimitErrorBlock()
}
}
cardPageItem(key = "Settings") {
if (state.isReissueInProgress) {
TangemPayReplacingCardBlock()
} else {
TangemPayCardPageSettingsBlock(settings = state.settings)
}
}
}
@ -187,6 +180,26 @@ private fun TangemPayCardPageSettingRow(
}
}
private fun LazyListScope.cardPageItem(
key: Any? = null,
contentType: Any? = null,
content: @Composable LazyItemScope.() -> Unit,
) {
item(
key = key,
contentType = contentType,
) {
val visibleState = remember { MutableTransitionState(false).apply { targetState = true } }
AnimatedVisibility(
visibleState = visibleState,
enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)),
exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)),
) {
content()
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -0,0 +1,140 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerW12
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
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.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState
@Composable
internal fun TangemPayDailyLimitBlock(state: TangemPayDailyLimitBlockState, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.roundedCornersMedium,
)
.padding(TangemTheme.dimens.spacing12),
) {
Text(
text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
SpacerH12()
CurrentLimitBlock(state)
}
}
@Composable
private fun CurrentLimitBlock(state: TangemPayDailyLimitBlockState) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(36.dp)
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(R.drawable.ic_limit_20),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
SpacerW12()
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_current_limit),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
when (state) {
TangemPayDailyLimitBlockState.Error,
is TangemPayDailyLimitBlockState.Content,
-> Text(
text = if (state is TangemPayDailyLimitBlockState.Content) state.limit else "",
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
TangemPayDailyLimitBlockState.Loading -> TextShimmer(
style = TangemTheme.typography.subtitle1,
text = "$50,000",
)
}
}
SpacerW8()
if (state is TangemPayDailyLimitBlockState.Content) {
SecondaryButton(
text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_change),
onClick = state.onChangeClick,
size = TangemButtonSize.Small,
)
}
}
}
@Composable
internal fun TangemPayDailyLimitErrorBlock(modifier: Modifier = Modifier) {
Notification(
config = NotificationConfig(
title = resourceReference(R.string.tangempay_card_page_daily_limit_error_title),
subtitle = resourceReference(R.string.tangempay_card_page_daily_limit_error_description),
iconResId = R.drawable.img_attention_20,
),
containerColor = TangemTheme.colors.background.action,
modifier = modifier.fillMaxWidth(),
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun preview() = TangemThemePreview {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TangemPayDailyLimitBlock(state = TangemPayDailyLimitBlockState.Content.stub())
TangemPayDailyLimitBlock(state = TangemPayDailyLimitBlockState.Error)
TangemPayDailyLimitBlock(state = TangemPayDailyLimitBlockState.Loading)
TangemPayDailyLimitErrorBlock()
}
}