Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-01 18:37:58 +04:00
parent 952dbd4732
commit 7e375ac36b
20 changed files with 233 additions and 27 deletions

View file

@ -593,7 +593,10 @@ internal class ChildFactory @Inject constructor(
is AppRoute.TangemPayDetails -> {
createComponentChild(
context = context,
params = TangemPayDetailsComponent.Params(customerWalletAddress = route.customerWalletAddress),
params = TangemPayDetailsComponent.Params(
customerWalletAddress = route.customerWalletAddress,
cardNumberEnd = route.cardNumberEnd,
),
componentFactory = tangemPayDetailsComponentFactory,
)
}

View file

@ -441,7 +441,10 @@ sealed class AppRoute(val path: String) : Route {
) : AppRoute(path = "/archived_account/${userWalletId.stringValue}")
@Serializable
data class TangemPayDetails(val customerWalletAddress: String) : AppRoute(path = "/tangem_pay_details")
data class TangemPayDetails(
val customerWalletAddress: String,
val cardNumberEnd: String,
) : AppRoute(path = "/tangem_pay_details")
@Serializable
data class TangemPayOnboarding(

View file

@ -137,4 +137,13 @@ interface TangemPayApi {
@Header("Authorization") authHeader: String,
@Body body: OrderRequest,
): ApiResponse<OrderResponse>
@GET("v1/customer/balance")
suspend fun getCardBalance(@Header("Authorization") authHeader: String): ApiResponse<CardBalanceResponse>
@POST("v1/customer/card/details")
suspend fun revealCardDetails(
@Header("Authorization") authHeader: String,
@Body body: CardDetailsRequest,
): ApiResponse<CardDetailsResponse>
}

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CardDetailsRequest(
@Json(name = "session_id") val sessionId: String,
)

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
data class CardBalanceResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "currency") val currency: String,
@Json(name = "available_balance") val availableBalance: BigDecimal,
@Json(name = "credit_limit") val creditLimit: BigDecimal,
@Json(name = "pending_charges") val pendingCharges: BigDecimal,
@Json(name = "posted_charges") val postedCharges: BigDecimal,
@Json(name = "balance_due") val balanceDue: BigDecimal,
)
}

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
data class CardDetailsResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "token") val token: String,
@Json(name = "expiration_month") val expirationMonth: String,
@Json(name = "expiration_year") val expirationYear: String,
@Json(name = "emboss_name") val embossName: String,
@Json(name = "card_type") val cardType: String,
@Json(name = "card_status") val cardStatus: String,
@Json(name = "card_number_end") val cardNumberEnd: String,
@Json(name = "pan") val pan: String,
@Json(name = "cvv") val cvv: String,
)
}

View file

@ -1,8 +1,10 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.repository.DefaultCardDetailsRepository
import com.tangem.data.pay.repository.DefaultKycRepository
import com.tangem.data.pay.repository.DefaultTangemPayTxHistoryRepository
import com.tangem.data.pay.repository.DefaultOnboardingRepository
import com.tangem.domain.pay.repository.CardDetailsRepository
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.TangemPayIssueOrderUseCase
@ -31,6 +33,10 @@ internal interface TangemPayDataModule {
@Singleton
fun bindTangemPayTxHistoryRepository(repository: DefaultTangemPayTxHistoryRepository): TangemPayTxHistoryRepository
@Binds
@Singleton
fun bindCardDetailsRepository(repository: DefaultCardDetailsRepository): CardDetailsRepository
companion object {
@Provides
@Singleton

View file

@ -0,0 +1,33 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.pay.repository.CardDetailsRepository
import javax.inject.Inject
private const val TAG = "TangemPay: CardDetailsRepository"
internal class DefaultCardDetailsRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
) : CardDetailsRepository {
override suspend fun getCardBalance(): Either<UniversalError, TangemPayCardBalance> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
tangemPayApi.getCardBalance(authHeader)
}.result ?: error("Cannot get card balance")
TangemPayCardBalance(
balance = result.availableBalance,
currencyCode = result.currency,
)
}
}
override suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails> {
TODO("[REDACTED_TASK_KEY] add reveal card details")
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.pay.model
import java.math.BigDecimal
data class TangemPayCardBalance(
val balance: BigDecimal,
val currencyCode: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.domain.pay.model
/**
* Returned dynamically to show on the UI. Never saved anywhere
*/
data class TangemPayCardDetails(
val pan: String,
val cvv: String,
val expirationYear: String,
val expirationMonth: String,
)

View file

@ -0,0 +1,13 @@
package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
interface CardDetailsRepository {
suspend fun getCardBalance(): Either<UniversalError, TangemPayCardBalance>
suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails>
}

View file

@ -4,6 +4,6 @@ import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface TangemPayDetailsComponent : ComposableContentComponent {
data class Params(val customerWalletAddress: String)
data class Params(val customerWalletAddress: String, val cardNumberEnd: String)
interface Factory : ComponentFactory<Params, TangemPayDetailsComponent>
}

View file

@ -16,6 +16,7 @@ dependencies {
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.error)
/** Features api */
implementation(projects.features.tangempay.details.api)

View file

@ -3,20 +3,24 @@ package com.tangem.features.tangempay.model
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.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState
import com.tangem.domain.pay.repository.CardDetailsRepository
import com.tangem.features.tangempay.components.TangemPayDetailsComponent
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.features.tangempay.model.transformers.TangemPayDetailsRefreshTransformer
import com.tangem.features.tangempay.transformer.CardDetailsStateTransformer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@ -25,21 +29,35 @@ import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayDetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val cardDetailsRepository: CardDetailsRepository,
) : Model() {
private val params: TangemPayDetailsComponent.Params = paramsContainer.require()
val uiState: StateFlow<TangemPayDetailsUM>
field = MutableStateFlow(getInitialState())
private val refreshStateJobHolder = JobHolder()
private val fetchBalanceJobHolder = JobHolder()
init {
fetchBalance()
}
private fun fetchBalance(): Job {
return modelScope.launch {
val result = cardDetailsRepository.getCardBalance()
uiState.update(CardDetailsStateTransformer(result))
}.saveIn(fetchBalanceJobHolder)
}
@Suppress("MagicNumber", "UnusedPrivateMember")
private fun onRefreshSwipe(refreshState: ShowRefreshState) {
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = true))
modelScope.launch {
// simulate update logic
delay(2000)
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value))
fetchBalance().join()
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false))
}.saveIn(refreshStateJobHolder)
}
@ -49,13 +67,19 @@ internal class TangemPayDetailsModel @Inject constructor(
topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = router::pop, items = null),
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = ::onRefreshSwipe),
balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(actionButtons = persistentListOf()),
cardDetailsUM = TangemPayCardDetailsUM(
number = "•••• •••• •••• 1245",
expiry = "••/••",
cvv = "•••",
onReveal = {},
),
cardDetailsUM = getHiddenCardDetails(),
isBalanceHidden = false,
)
}
private fun getHiddenCardDetails() = TangemPayCardDetailsUM(
number = "•••• •••• •••• ${params.cardNumberEnd}",
expiry = "••/••",
cvv = "•••",
onReveal = ::revealCardDetails,
)
private fun revealCardDetails() {
// TODO "[REDACTED_TASK_KEY] add reveal card details"
}
}

View file

@ -0,0 +1,42 @@
package com.tangem.features.tangempay.transformer
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import java.util.Currency
internal class CardDetailsStateTransformer(
private val balance: Either<UniversalError, TangemPayCardBalance>,
) : Transformer<TangemPayDetailsUM> {
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
val balance = when (balance) {
is Either.Left<UniversalError> -> {
TangemPayDetailsBalanceBlockState.Error(persistentListOf())
}
is Either.Right<TangemPayCardBalance> -> {
TangemPayDetailsBalanceBlockState.Content(
isBalanceFlickering = false,
fiatBalance = getBalanceText(balance.value),
// TODO [REDACTED_TASK_KEY]: Add crypto balance when the BFF is ready
cryptoBalance = "",
actionButtons = persistentListOf(),
)
}
}
return prevState.copy(balanceBlockState = balance)
}
private fun getBalanceText(balance: TangemPayCardBalance): String {
val currency = Currency.getInstance(balance.currencyCode)
return balance.balance.format {
fiat(fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol)
}
}
}

View file

@ -133,11 +133,12 @@ internal fun TangemPayDetailsBalanceBlock(
state = state,
isBalanceHidden = isBalanceHidden,
)
CryptoBalance(
modifier = Modifier.padding(start = 12.dp, top = 4.dp),
state = state,
isBalanceHidden = isBalanceHidden,
)
// TODO [REDACTED_TASK_KEY]: Uncomment after adding crypto balance when the BFF is ready
// CryptoBalance(
// modifier = Modifier.padding(start = 12.dp, top = 4.dp),
// state = state,
// isBalanceHidden = isBalanceHidden,
// )
if (state.actionButtons.isNotEmpty()) {
HorizontalActionChips(
modifier = Modifier.padding(top = 12.dp),
@ -179,6 +180,7 @@ private fun FiatBalance(
}
}
@Suppress("UnusedPrivateMember")
@Composable
private fun CryptoBalance(
state: TangemPayDetailsBalanceBlockState,

View file

@ -388,8 +388,8 @@ internal class WalletModel @Inject constructor(
}
}
private fun tangemPayCardOnClick(customerWalletAddress: String) {
innerWalletRouter.openTangemPayDetails(customerWalletAddress)
private fun tangemPayCardOnClick(customerWalletAddress: String, cardNumberEnd: String) {
innerWalletRouter.openTangemPayDetails(customerWalletAddress, cardNumberEnd)
}
private fun needToRefreshTimer() {

View file

@ -114,8 +114,8 @@ internal class DefaultWalletRouter @Inject constructor(
router.push(AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.ContinueOnboarding))
}
override fun openTangemPayDetails(custoemrWalletAddress: String) {
router.push(AppRoute.TangemPayDetails(custoemrWalletAddress))
override fun openTangemPayDetails(customerWalletAddress: String, cardNumberEnd: String) {
router.push(AppRoute.TangemPayDetails(customerWalletAddress, cardNumberEnd))
}
override fun openYieldSupplyBottomSheet(

View file

@ -63,7 +63,7 @@ internal interface InnerWalletRouter {
fun openTangemPayOnboarding()
fun openTangemPayDetails(custoemrWalletAddress: String)
fun openTangemPayDetails(customerWalletAddress: String, cardNumberEnd: String)
/** Open BS abput yield supply active and all money deposited in AAVE */
fun openYieldSupplyBottomSheet(

View file

@ -17,7 +17,7 @@ internal class TangemPayStateTransformer(
private val value: MainScreenCustomerInfo? = null,
private val onIssueOrderClick: () -> Unit = {},
private val onContinueKycClick: () -> Unit = {},
private val cardOnClick: (String) -> Unit = {},
private val cardOnClick: (customerWalletAddress: String, cardNumberEnd: String) -> Unit = { _, _ -> },
private val issueProgressState: Boolean = false,
private val issueState: Boolean = false,
) : Transformer<TangemPayState> {
@ -33,8 +33,8 @@ internal class TangemPayStateTransformer(
return when {
value == null -> TangemPayState.Empty
!value.info.isKycApproved() -> createKycInProgressState(onContinueKycClick)
value.orderStatus == NOT_ISSUED || value.orderStatus == CANCELED -> createIssueState()
cardInfo != null -> getCardInfoState(cardInfo)
value.orderStatus == NOT_ISSUED || value.orderStatus == CANCELED -> createIssueState()
else -> createIssueProgressState()
}
}
@ -64,7 +64,7 @@ internal class TangemPayStateTransformer(
private fun getCardInfoState(cardInfo: CardInfo): TangemPayState = TangemPayState.Card(
lastFourDigits = TextReference.Str("*${cardInfo.lastFourDigits}"),
balanceText = TextReference.Str(getBalanceText(cardInfo)),
onClick = { cardOnClick(cardInfo.customerWalletAddress) },
onClick = { cardOnClick(cardInfo.customerWalletAddress, cardInfo.lastFourDigits) },
)
private fun getBalanceText(cardInfo: CardInfo): String {