Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-23 16:59:15 +04:00
parent 8aa7bf9b75
commit 4032360f13
34 changed files with 679 additions and 101 deletions

View file

@ -14,7 +14,6 @@ import javax.inject.Singleton
import kotlin.text.encodeToByteArray
private const val DEFAULT_KEY = "tangem_pay_default_key"
private const val DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY = "tangem_pay_default_customer_wallet_address_key"
@Singleton
internal class DefaultTangemPayStorage @Inject constructor(
@ -54,21 +53,6 @@ internal class DefaultTangemPayStorage @Inject constructor(
?.let(tokensAdapter::fromJson)
}
/**
* Store the only customer wallet address, since for the f&f user can issue only one card tied to one address
*/
override suspend fun storeCustomerWalletAddress(customerWalletAddress: String) =
withContext(dispatcherProvider.io) {
secureStorage.store(
customerWalletAddress.encodeToByteArray(throwOnInvalidSequence = true),
DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY,
)
}
override suspend fun getCustomerWalletAddress(): String? = withContext(dispatcherProvider.io) {
secureStorage.get(DEFAULT_CUSTOMER_WALLET_ADDRESS_KEY)?.decodeToString(throwOnInvalidSequence = true)
}
override suspend fun clear(customerWalletAddress: String) = withContext(dispatcherProvider.io) {
secureStorage.delete(createKey(customerWalletAddress))
}

View file

@ -16,6 +16,7 @@ import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.hotwallet.*
import com.tangem.features.kyc.KycComponent
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
@ -35,6 +36,7 @@ import com.tangem.features.swap.SwapComponent
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import com.tangem.features.tangempay.components.TangemPayDetailsComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.*
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.wallet.WalletEntryComponent
import com.tangem.features.walletconnect.components.WalletConnectEntryComponent
@ -106,6 +108,7 @@ internal class ChildFactory @Inject constructor(
private val sendEntryPointComponentFactory: SendEntryPointComponent.Factory,
private val tangemPayDetailsComponentFactory: TangemPayDetailsComponent.Factory,
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
private val kycComponentFactory: KycComponent.Factory,
private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) {
@ -597,10 +600,20 @@ internal class ChildFactory @Inject constructor(
is AppRoute.TangemPayOnboarding -> {
createComponentChild(
context = context,
params = TangemPayOnboardingComponent.Params(route.deeplink),
params = when (val mode = route.mode) {
is AppRoute.TangemPayOnboarding.Mode.ContinueOnboarding -> ContinueOnboarding
is AppRoute.TangemPayOnboarding.Mode.Deeplink -> Deeplink(deeplink = mode.deeplink)
},
componentFactory = tangemPayOnboardingComponentFactory,
)
}
is AppRoute.Kyc -> {
createComponentChild(
context = context,
params = KycComponent.Params,
componentFactory = kycComponentFactory,
)
}
is AppRoute.YieldSupplyPromo -> {
createComponentChild(
context = context,

View file

@ -376,8 +376,23 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class TangemPayOnboarding(
val deeplink: String,
) : AppRoute(path = "/tangem_pay_onboarding/$deeplink")
val mode: Mode,
) : AppRoute(path = "/tangem_pay_onboarding/$mode") {
@Serializable
sealed class Mode {
@Serializable
data class Deeplink(
val deeplink: String,
) : Mode()
@Serializable
object ContinueOnboarding : Mode()
}
}
@Serializable
data object Kyc : AppRoute(path = "/kyc")
@Serializable
data class YieldSupplyPromo(

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(
@ -16,6 +17,8 @@ data class CustomerMeResponse(
@Json(name = "product_instance") val productInstance: ProductInstance?,
@Json(name = "payment_account") val paymentAccount: PaymentAccount?,
@Json(name = "kyc") val kyc: Kyc?,
@Json(name = "card") val card: Card?,
@Json(name = "balance") val balance: Balance?,
)
@JsonClass(generateAdapter = true)
@ -45,4 +48,25 @@ data class CustomerMeResponse(
@Json(name = "review_answer") val reviewAnswer: String,
@Json(name = "created_at") val createdAt: String,
)
@JsonClass(generateAdapter = true)
data class Card(
@Json(name = "token") val token: String,
@Json(name = "expiration_month") val expirationMonth: Int,
@Json(name = "expiration_year") val expirationYear: Int,
@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,
)
@JsonClass(generateAdapter = true)
data class Balance(
@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

@ -8,9 +8,5 @@ interface TangemPayStorage {
suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens?
suspend fun storeCustomerWalletAddress(customerWalletAddress: String)
suspend fun getCustomerWalletAddress(): String?
suspend fun clear(customerWalletAddress: String)
}

View file

@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="36"
android:viewportHeight="36">
<path
android:pathData="M18,18m-18,0a18,18 0,1 1,36 0a18,18 0,1 1,-36 0"
android:strokeAlpha="0.1"
android:fillColor="#0099FF"
android:fillAlpha="0.1"/>
<path
android:pathData="M17.758,21.123C18.172,21.12 18.51,21.453 18.514,21.867C18.517,22.281 18.184,22.619 17.771,22.623C16.646,22.633 15.542,23.074 14.779,23.887C14.496,24.189 14.021,24.203 13.719,23.92C13.417,23.636 13.402,23.161 13.686,22.859C14.756,21.719 16.265,21.136 17.758,21.123ZM18.982,17.373C18.982,16.671 18.425,16.123 17.764,16.123C17.102,16.123 16.546,16.672 16.546,17.373C16.546,18.075 17.102,18.623 17.764,18.623C18.425,18.623 18.982,18.075 18.982,17.373ZM20.482,17.373C20.482,18.881 19.276,20.123 17.764,20.123C16.251,20.123 15.046,18.881 15.046,17.373C15.046,15.866 16.251,14.623 17.764,14.623C19.276,14.623 20.482,15.866 20.482,17.373Z"
android:fillColor="#0099FF"/>
<path
android:pathData="M8.482,20.742V16.006C8.482,14.465 8.481,13.23 8.618,12.262C8.759,11.268 9.06,10.447 9.742,9.801C10.346,9.229 11.088,8.946 11.974,8.799C12.841,8.655 13.915,8.63 15.229,8.625C15.644,8.624 15.981,8.958 15.982,9.372C15.984,9.786 15.649,10.123 15.235,10.125C13.905,10.13 12.95,10.157 12.22,10.278C11.508,10.397 11.086,10.594 10.773,10.89C10.431,11.215 10.219,11.661 10.104,12.473C9.985,13.311 9.982,14.421 9.982,16.006V20.742C9.982,22.328 9.985,23.437 10.104,24.275C10.219,25.087 10.431,25.534 10.773,25.858C11.122,26.189 11.61,26.397 12.486,26.509C13.381,26.623 14.562,26.624 16.232,26.624H18.232C18.647,26.624 18.982,26.96 18.982,27.374C18.982,27.788 18.647,28.124 18.232,28.124H16.232C14.603,28.124 13.308,28.125 12.297,27.996C11.268,27.865 10.418,27.588 9.742,26.947C9.06,26.301 8.759,25.48 8.618,24.486C8.481,23.519 8.482,22.283 8.482,20.742ZM25.482,16.874V16.006C25.482,14.421 25.48,13.311 25.361,12.473C25.246,11.661 25.034,11.215 24.691,10.89C24.379,10.594 23.956,10.397 23.245,10.278C22.515,10.157 21.56,10.13 20.229,10.125C19.815,10.123 19.481,9.786 19.482,9.372C19.484,8.958 19.821,8.624 20.235,8.625C21.549,8.63 22.624,8.655 23.491,8.799C24.377,8.946 25.119,9.229 25.723,9.801C26.405,10.447 26.706,11.268 26.847,12.262C26.984,13.23 26.982,14.465 26.982,16.006V16.874C26.982,17.288 26.647,17.624 26.232,17.624C25.818,17.624 25.483,17.288 25.482,16.874Z"
android:fillColor="#0099FF"/>
<path
android:pathData="M17.733,6.623C18.164,6.623 18.503,6.618 18.81,6.689C19.473,6.841 20.057,7.255 20.383,7.855C20.536,8.136 20.606,8.466 20.691,8.836L20.774,9.195C20.856,9.547 20.931,9.869 20.964,10.138C20.998,10.417 21,10.74 20.854,11.062C20.704,11.391 20.455,11.659 20.153,11.843C19.867,12.016 19.558,12.073 19.27,12.099C18.986,12.124 18.634,12.123 18.231,12.123H17.235C16.831,12.123 16.48,12.124 16.195,12.099C15.907,12.073 15.598,12.016 15.313,11.843C15.01,11.659 14.761,11.391 14.611,11.062C14.465,10.74 14.467,10.417 14.501,10.138C14.534,9.869 14.609,9.547 14.691,9.195L14.774,8.836C14.859,8.466 14.929,8.136 15.082,7.855C15.408,7.255 15.992,6.841 16.655,6.689C16.962,6.618 17.301,6.623 17.733,6.623ZM17.733,8.123C17.223,8.123 17.092,8.128 16.992,8.15C16.71,8.215 16.503,8.382 16.399,8.571C16.368,8.629 16.342,8.714 16.236,9.174L16.153,9.534C16.064,9.918 16.011,10.148 15.99,10.318C15.981,10.391 15.982,10.432 15.983,10.45C16.002,10.486 16.035,10.527 16.091,10.562C16.092,10.562 16.095,10.563 16.098,10.564C16.104,10.567 16.115,10.571 16.133,10.575C16.17,10.585 16.23,10.596 16.326,10.605C16.53,10.622 16.804,10.623 17.235,10.623H18.231C18.661,10.623 18.935,10.622 19.139,10.605C19.235,10.596 19.295,10.585 19.332,10.575C19.35,10.571 19.361,10.567 19.367,10.564C19.37,10.563 19.372,10.562 19.373,10.562C19.429,10.528 19.462,10.486 19.481,10.45C19.482,10.432 19.484,10.392 19.475,10.318C19.454,10.148 19.401,9.918 19.313,9.534L19.23,9.174C19.123,8.714 19.097,8.629 19.066,8.571C18.962,8.382 18.755,8.215 18.473,8.15C18.373,8.128 18.242,8.123 17.733,8.123Z"
android:fillColor="#0099FF"/>
<path
android:pathData="M27.483,23.373C27.483,21.578 26.028,20.123 24.233,20.123C22.438,20.123 20.983,21.578 20.983,23.373C20.983,25.168 22.438,26.623 24.233,26.623C26.028,26.623 27.483,25.168 27.483,23.373ZM22.572,24.493H22.574L22.575,24.494C22.202,24.316 22.045,23.868 22.223,23.494C22.401,23.121 22.848,22.963 23.222,23.141L23.224,23.142L23.227,23.143C23.228,23.143 23.23,23.145 23.232,23.146C23.235,23.147 23.24,23.149 23.244,23.151C23.253,23.156 23.265,23.162 23.278,23.169C23.303,23.183 23.337,23.202 23.376,23.226C23.451,23.272 23.549,23.338 23.658,23.426C23.669,23.409 23.68,23.393 23.691,23.376C24.021,22.875 24.552,22.178 25.21,21.824C25.575,21.628 26.03,21.764 26.227,22.129C26.423,22.494 26.286,22.948 25.921,23.145C25.618,23.308 25.26,23.722 24.942,24.203C24.795,24.427 24.673,24.637 24.589,24.791C24.547,24.868 24.515,24.93 24.493,24.972C24.483,24.993 24.475,25.009 24.47,25.019C24.467,25.024 24.466,25.027 24.465,25.029C24.345,25.279 24.097,25.444 23.82,25.456C23.544,25.468 23.282,25.326 23.142,25.087C23.005,24.855 22.847,24.698 22.729,24.603C22.67,24.555 22.622,24.523 22.593,24.505L22.572,24.493ZM28.983,23.373C28.983,25.997 26.856,28.123 24.233,28.123C21.609,28.123 19.483,25.997 19.483,23.373C19.483,20.75 21.609,18.623 24.233,18.623C26.856,18.623 28.983,20.75 28.983,23.373Z"
android:fillColor="#0099FF"/>
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="36"
android:viewportHeight="36">
<path
android:pathData="M18,18m-18,0a18,18 0,1 1,36 0a18,18 0,1 1,-36 0"
android:strokeAlpha="0.1"
android:fillColor="#0099FF"
android:fillAlpha="0.1"/>
<path
android:pathData="M9,10L27,10A3,3 0,0 1,30 13L30,23A3,3 0,0 1,27 26L9,26A3,3 0,0 1,6 23L6,13A3,3 0,0 1,9 10z"
android:fillColor="#0099FF"/>
<path
android:pathData="M13.051,20.582H11.502L10.34,16.5C10.285,16.312 10.167,16.146 9.995,16.068C9.565,15.871 9.092,15.715 8.575,15.636V15.479H11.071C11.415,15.479 11.674,15.715 11.717,15.989L12.32,18.933L13.868,15.479H15.375L13.051,20.582ZM16.235,20.58H14.771L15.976,15.477H17.44L16.235,20.58ZM19.335,16.891C19.378,16.616 19.636,16.459 19.938,16.459C20.412,16.419 20.927,16.498 21.358,16.694L21.617,15.595C21.186,15.438 20.712,15.359 20.282,15.359C18.862,15.359 17.829,16.066 17.829,17.047C17.829,17.794 18.561,18.185 19.077,18.422C19.636,18.657 19.852,18.814 19.809,19.049C19.809,19.403 19.378,19.56 18.948,19.56C18.432,19.56 17.915,19.442 17.442,19.246L17.184,20.345C17.7,20.541 18.259,20.62 18.776,20.62C20.368,20.659 21.358,19.952 21.358,18.892C21.358,17.558 19.335,17.479 19.335,16.891ZM26.48,20.58L25.318,15.477H24.07C23.811,15.477 23.553,15.634 23.467,15.869L21.315,20.58H22.822L23.122,19.834H24.973L25.146,20.58H26.48ZM24.283,16.852L24.713,18.775H23.508L24.283,16.852Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,50 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="36"
android:viewportHeight="36">
<group>
<clip-path
android:pathData="M18,18m-18,0a18,18 0,1 1,36 0a18,18 0,1 1,-36 0"/>
</group>
<group>
<clip-path
android:pathData="M18,18m-18,0a18,18 0,1 1,36 0a18,18 0,1 1,-36 0"/>
<path
android:pathData="M0,31.5l0,-31.5l36,-0l0,31.5z"
android:fillColor="#F5F7F8"/>
<path
android:pathData="M18,9l0,-4.5l18,-0l0,4.5z"
android:fillColor="#DD2033"/>
<path
android:pathData="M18,18l0,-4.5l18,-0l0,4.5z"
android:fillColor="#DD2033"/>
<path
android:pathData="M0,27l0,-4.5l36,-0l0,4.5z"
android:fillColor="#DD2033"/>
<path
android:pathData="M0,36l0,-4.5l36,-0l0,4.5z"
android:fillColor="#DD2033"/>
<path
android:pathData="M0,18l0,-18l18,-0l0,18z"
android:fillColor="#004692"/>
<path
android:pathData="M14.2,11L13.306,13.023L11.2,13.292L12.753,14.811L12.346,17L14.2,15.75L16.054,17L15.647,14.811L17.2,13.292L15.094,13.023L14.2,11Z"
android:fillColor="#F5F7F8"/>
<path
android:pathData="M14.2,-1L13.306,1.023L11.2,1.292L12.753,2.811L12.346,5L14.2,3.75L16.054,5L15.647,2.811L17.2,1.292L15.094,1.023L14.2,-1Z"
android:fillColor="#F5F7F8"/>
<path
android:pathData="M10.2,5L9.306,7.023L7.2,7.292L8.753,8.811L8.346,11L10.2,9.75L12.054,11L11.647,8.811L13.2,7.292L11.094,7.023L10.2,5Z"
android:fillColor="#F5F7F8"/>
<path
android:pathData="M5.7,11L4.806,13.023L2.7,13.292L4.253,14.811L3.846,17L5.7,15.75L7.554,17L7.147,14.811L8.7,13.292L6.594,13.023L5.7,11Z"
android:fillColor="#F5F7F8"/>
<path
android:pathData="M5.7,-1L4.806,1.023L2.7,1.292L4.253,2.811L3.846,5L5.7,3.75L7.554,5L7.147,2.811L8.7,1.292L6.594,1.023L5.7,-1Z"
android:fillColor="#F5F7F8"/>
<path
android:pathData="M1.7,5L0.806,7.023L-1.3,7.292L0.253,8.811L-0.154,11L1.7,9.75L3.554,11L3.147,8.811L4.7,7.292L2.594,7.023L1.7,5Z"
android:fillColor="#F5F7F8"/>
</group>
</vector>

View file

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="16dp"
android:viewportWidth="26"
android:viewportHeight="16">
<path
android:pathData="M3,0L23,0A3,3 0,0 1,26 3L26,13A3,3 0,0 1,23 16L3,16A3,3 0,0 1,0 13L0,3A3,3 0,0 1,3 0z"
android:strokeAlpha="0.5"
android:fillColor="#474E71"
android:fillAlpha="0.4"/>
<path
android:pathData="M7.521,11.139H5.788L4.488,6.193C4.427,5.966 4.296,5.765 4.103,5.67C3.622,5.431 3.093,5.242 2.515,5.146V4.956H5.306C5.692,4.956 5.981,5.242 6.029,5.574L6.703,9.142L8.435,4.956H10.12L7.521,11.139ZM11.082,11.139H9.445L10.793,4.956H12.429L11.082,11.139ZM14.549,6.668C14.597,6.335 14.886,6.145 15.223,6.145C15.753,6.097 16.33,6.192 16.811,6.43L17.1,5.099C16.619,4.908 16.089,4.813 15.608,4.813C14.02,4.813 12.864,5.669 12.864,6.858C12.864,7.762 13.683,8.237 14.261,8.523C14.886,8.808 15.127,8.998 15.078,9.284C15.078,9.712 14.597,9.902 14.116,9.902C13.538,9.902 12.96,9.759 12.431,9.521L12.142,10.853C12.72,11.091 13.345,11.186 13.924,11.186C15.705,11.233 16.811,10.378 16.811,9.093C16.811,7.476 14.549,7.381 14.549,6.668ZM22.54,11.139L21.241,4.956H19.845C19.556,4.956 19.267,5.146 19.171,5.431L16.764,11.139H18.449L18.785,10.236H20.855L21.048,11.139H22.54ZM20.083,6.621L20.564,8.951H19.216L20.083,6.621Z"
android:strokeAlpha="0.5"
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:fillAlpha="0.5"/>
</vector>

View file

@ -5,9 +5,11 @@ import com.tangem.data.pay.repository.DefaultTangemPayTxHistoryRepository
import com.tangem.data.pay.repository.DefaultOnboardingRepository
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@ -27,4 +29,14 @@ internal interface TangemPayDataModule {
@Binds
@Singleton
fun bindTangemPayTxHistoryRepository(repository: DefaultTangemPayTxHistoryRepository): TangemPayTxHistoryRepository
companion object {
@Provides
@Singleton
fun provideTangemPayMainScreenCustomerInfoUseCase(
repository: OnboardingRepository,
): TangemPayMainScreenCustomerInfoUseCase {
return TangemPayMainScreenCustomerInfoUseCase(repository = repository)
}
}
}

View file

@ -5,8 +5,10 @@ import arrow.core.raise.either
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.ProductInstance
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.repository.OnboardingRepository
import javax.inject.Inject
@ -26,11 +28,31 @@ internal class DefaultOnboardingRepository @Inject constructor(
override suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo> = either {
return requestHelper.request { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.map {
CustomerInfo(
productInstance = it.result?.productInstance?.let { ProductInstance(id = it.id, status = it.status) },
kycStatus = it.result?.kyc?.status,
}.map { getCustomerInfo(it.result) }
}
override suspend fun getMainScreenCustomerInfo(): Either<UniversalError, CustomerInfo> = either {
return requestHelper.requestWithPersistedToken { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.map { getCustomerInfo(it.result) }
}
private fun getCustomerInfo(response: CustomerMeResponse.Result?): CustomerInfo {
val card = response?.card
val balance = response?.balance
val cardInfo = if (card != null && balance != null) {
CardInfo(
lastFourDigits = card.cardNumberEnd,
balance = balance.availableBalance,
currencyCode = balance.currency,
)
} else {
null
}
return CustomerInfo(
productInstance = response?.productInstance?.let { ProductInstance(id = it.id, status = it.status) },
kycStatus = response?.kyc?.status,
cardInfo = cardInfo,
)
}
}

View file

@ -10,18 +10,20 @@ import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import javax.inject.Inject
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@ -35,9 +37,9 @@ internal class TangemPayRequestPerformer @Inject constructor(
@NetworkMoshi moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
private val tangemPayStorage: TangemPayStorage,
private val userWalletsRepository: UserWalletsListRepository,
private val getCurrencyUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val authDataSource: TangemPayAuthDataSource,
private val getWalletsUseCase: GetWalletsUseCase,
) {
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
@ -49,22 +51,39 @@ internal class TangemPayRequestPerformer @Inject constructor(
suspend fun <T : Any> request(requestBlock: suspend (header: String) -> ApiResponse<T>): Either<UniversalError, T> =
either {
withContext(dispatchers.io) {
performRequest(requestBlock = requestBlock, refreshTokens = ::refreshAuthTokens).bind()
performRequest(
requestBlock = requestBlock,
getTokens = ::getAccessTokens,
refreshTokens = ::refreshAuthTokens,
).bind()
}
}
suspend fun <T : Any> requestWithPersistedToken(
requestBlock: suspend (header: String) -> ApiResponse<T>,
): Either<UniversalError, T> = either {
withContext(dispatchers.io) {
performRequest(
requestBlock = requestBlock,
getTokens = ::getAccessTokensIfSaved,
refreshTokens = ::refreshAuthTokens,
).bind()
}
}
private suspend fun <T : Any> performRequest(
requestBlock: suspend (header: String) -> ApiResponse<T>,
getTokens: (suspend () -> Either<UniversalError, VisaAuthTokens>),
refreshTokens: (suspend () -> Either<UniversalError, VisaAuthTokens>)? = null,
): Either<UniversalError, T> = either {
runCatching {
requestBlock("Bearer ${getAccessTokens().bind().accessToken}").getOrThrow()
requestBlock("Bearer ${getTokens().bind().accessToken}").getOrThrow()
}.getOrElse { error ->
when (error) {
is ApiResponseError.HttpException -> {
if (refreshTokens != null && error.code == ApiResponseError.HttpException.Code.UNAUTHORIZED) {
refreshOrJoin(refreshTokens).bind()
performRequest(requestBlock, refreshTokens = null).bind()
performRequest(requestBlock, refreshTokens = null, getTokens = getTokens).bind()
} else {
raise(mapHttpError(error))
}
@ -103,9 +122,7 @@ internal class TangemPayRequestPerformer @Inject constructor(
}
private suspend fun getCustomerWalletAddress(): Either<UniversalError, String> = either {
customerWalletAddress
?: tangemPayStorage.getCustomerWalletAddress()
?: fetchAuthInputData().bind().address
customerWalletAddress ?: fetchAuthInputData().bind().address
}
private suspend fun getAccessTokens(): Either<UniversalError, VisaAuthTokens> = either {
@ -113,6 +130,11 @@ internal class TangemPayRequestPerformer @Inject constructor(
tangemPayStorage.getAuthTokens(address) ?: fetchTokens().bind()
}
private suspend fun getAccessTokensIfSaved(): Either<UniversalError, VisaAuthTokens> = either {
tangemPayStorage.getAuthTokens(getCustomerWalletAddress().bind())
?: raise(VisaApiError.UnknownWithoutCode)
}
private fun mapHttpError(throwable: ApiResponseError.HttpException): UniversalError {
val errorBody = throwable.errorBody ?: return VisaApiError.UnknownWithoutCode
return runCatching {
@ -125,14 +147,16 @@ internal class TangemPayRequestPerformer @Inject constructor(
}
private suspend fun fetchAuthInputData(): Either<UniversalError, AuthInputData> = either {
val wallet = userWalletsRepository.userWalletsSync().find { it is UserWallet.Cold } as? UserWallet.Cold
val userWallets = getWalletsUseCase()
.filter { it.isNotEmpty() }
.first()
val wallet = userWallets.find { it is UserWallet.Cold } as? UserWallet.Cold
?: raise(VisaApiError.UnknownWithoutCode)
val address = getCurrencyUseCase.invokeMultiWalletSync(wallet.walletId, CryptoCurrency.ID.fromValue(POL_VALUE))
.getOrNull()?.value?.networkAddress?.defaultAddress?.value ?: raise(VisaApiError.UnknownWithoutCode)
customerWalletAddress = address
tangemPayStorage.storeCustomerWalletAddress(address)
AuthInputData(address, wallet.cardId)
}

View file

@ -1,19 +1,28 @@
package com.tangem.domain.pay.model
import java.math.BigDecimal
private const val APPROVED_KYC_STATUS = "APPROVED"
private const val ACTIVE_PI_STATUS = "active"
data class CustomerInfo(
val productInstance: ProductInstance?,
val kycStatus: String?,
val cardInfo: CardInfo?,
) {
data class ProductInstance(
val id: String,
val status: String,
)
data class CardInfo(
val lastFourDigits: String,
val balance: BigDecimal,
val currencyCode: String,
)
fun isKycApproved() = kycStatus == APPROVED_KYC_STATUS
fun isProductInstanceActive() = productInstance?.status == ACTIVE_PI_STATUS
}
data class ProductInstance(
val id: String,
val status: String,
)
}

View file

@ -9,4 +9,9 @@ interface OnboardingRepository {
suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean>
suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo>
/**
* Returns only if the user already authorised at least once
*/
suspend fun getMainScreenCustomerInfo(): Either<UniversalError, CustomerInfo>
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.pay.usecase
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.repository.OnboardingRepository
/**
* Returns tangem pay customer info for the main screen banner
* Works only if the user already authorised at least once (won't emit anything otherwise)
*/
class TangemPayMainScreenCustomerInfoUseCase(
private val repository: OnboardingRepository,
) {
suspend operator fun invoke(): CustomerInfo? = repository.getMainScreenCustomerInfo().getOrNull()
}

View file

@ -1,12 +1,11 @@
package com.tangem.features.kyc
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface KycComponent {
interface KycComponent : ComposableContentComponent {
fun launch()
data object Params
interface Factory {
fun create(appComponentContext: AppComponentContext): KycComponent
}
interface Factory : ComponentFactory<Params, KycComponent>
}

View file

@ -1,45 +1,62 @@
package com.tangem.features.kyc
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.sumsub.sns.core.SNSMobileSDK
import com.sumsub.sns.core.data.listener.TokenExpirationHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.domain.pay.KycStartInfo
import com.tangem.features.kyc.theme.TangemSNSIconHandler
import com.tangem.features.kyc.theme.TangemSNSTheme
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import java.util.Locale
class DefaultKycComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: KycComponent.Params,
) : KycComponent, AppComponentContext by appComponentContext {
private val model: DefaultKycModel = getOrCreateModel()
private val model: DefaultKycModel = getOrCreateModel(params)
override fun launch() {
init {
componentScope.launch {
model.uiState.collect {
it?.let { startInfo ->
val tokenExpirationHandler = object : TokenExpirationHandler {
override fun onTokenExpired() = ""
}
val snsSdk = SNSMobileSDK.Builder(activity)
.withAccessToken(accessToken = startInfo.token, onTokenExpiration = tokenExpirationHandler)
.withTheme(TangemSNSTheme.theme(activity))
.withIconHandler(TangemSNSIconHandler())
.withLocale(Locale(startInfo.locale))
.build()
snsSdk.launch()
}
model.uiState.drop(1).collectLatest { startInfo ->
startInfo?.let { launchSdk(startInfo) }
router.pop()
}
}
model.getKycToken()
}
private fun launchSdk(startInfo: KycStartInfo) {
val tokenExpirationHandler = object : TokenExpirationHandler {
/**
* We don't refresh this token for f&f release. Assume it lives long enough to finish KYC
* [REDACTED_TODO_COMMENT]
*/
override fun onTokenExpired() = ""
}
val snsSdk = SNSMobileSDK.Builder(activity)
.withAccessToken(accessToken = startInfo.token, onTokenExpiration = tokenExpirationHandler)
.withTheme(TangemSNSTheme.theme(activity))
.withIconHandler(TangemSNSIconHandler())
.withLocale(Locale(startInfo.locale))
.build()
snsSdk.launch()
}
@Composable
override fun Content(modifier: Modifier) {
KycLoadingScreen(router::pop, modifier)
}
@AssistedFactory
interface Factory : KycComponent.Factory {
override fun create(appComponentContext: AppComponentContext): DefaultKycComponent
override fun create(context: AppComponentContext, params: KycComponent.Params): DefaultKycComponent
}
}

View file

@ -21,7 +21,7 @@ class DefaultKycModel @Inject constructor(
private val _uiState: MutableStateFlow<KycStartInfo?> = MutableStateFlow(null)
val uiState = _uiState.asStateFlow()
fun getKycToken() {
init {
modelScope.launch {
kycRepository.getKycStartInfo().getOrNull()?.let { _uiState.emit(it) }
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.kyc
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun KycLoadingScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
topBar = {
AppBarWithBackButton(
modifier = Modifier.statusBarsPadding(),
onBackClick = onBack,
iconRes = R.drawable.ic_back_24,
)
},
content = { paddingValues ->
Box(
modifier = modifier
.padding(paddingValues)
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier,
color = TangemTheme.colors.icon.primary1,
)
}
},
)
}

View file

@ -15,6 +15,8 @@ dependencies {
implementation(projects.features.kyc.api)
implementation(projects.core.decompose)
implementation(deps.compose.ui)
implementation(projects.core.ui)
/** DI */
implementation(deps.hilt.android)

View file

@ -1,23 +1,25 @@
package com.tangem.features.kyc
import androidx.compose.runtime.Composable
import com.tangem.core.decompose.context.AppComponentContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import androidx.compose.ui.Modifier
/**
* Mocking it for release/external builds to exclude SumSub dependency
* This will never be called if the FT [isTangemPayEnabled] is off
*/
internal class MockKycComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
) : KycComponent, AppComponentContext by appComponentContext {
override fun launch() {
/* no op */
}
@Composable
override fun Content(modifier: Modifier) { /* no op */ }
@AssistedFactory
interface Factory : KycComponent.Factory {
override fun create(appComponentContext: AppComponentContext): MockKycComponent
override fun create(context: AppComponentContext, params: KycComponent.Params): MockKycComponent
}
}

View file

@ -5,9 +5,13 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
interface TangemPayOnboardingComponent : ComposableContentComponent {
data class Params(
val deeplink: String,
)
sealed class Params {
data class Deeplink(
val deeplink: String,
) : Params()
object ContinueOnboarding : Params()
}
interface Factory : ComponentFactory<Params, TangemPayOnboardingComponent>
}

View file

@ -5,33 +5,29 @@ 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.context.child
import com.tangem.features.kyc.KycComponent
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.tangempay.model.TangemPayOnboardingModel
import com.tangem.features.tangempay.ui.TandemPayOnboardingScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.features.tangempay.ui.TandemPayOnboardingScreen
internal class DefaultTangemPayOnboardingComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: TangemPayOnboardingComponent.Params,
kycComponentFactory: KycComponent.Factory,
) : TangemPayOnboardingComponent, AppComponentContext by appComponentContext {
private val kycComponent = kycComponentFactory.create(child("kycComponent"))
private val model: TangemPayOnboardingModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.screenState.collectAsStateWithLifecycle()
TandemPayOnboardingScreen(modifier = modifier, state = state, onButtonClick = ::onButtonClick)
}
private fun onButtonClick() {
kycComponent.launch()
TandemPayOnboardingScreen(
modifier = modifier,
state = state,
onBackClick = model::back,
onOpenKycClick = model::openKyc,
)
}
@AssistedFactory

View file

@ -16,7 +16,8 @@ internal class DefaultOnboardVisaDeepLinkHandler @AssistedInject constructor(
init {
if (tangemPayFeatureToggles.isTangemPayEnabled) {
appRouter.push(AppRoute.TangemPayOnboarding(uri.toString()))
val mode = AppRoute.TangemPayOnboarding.Mode.Deeplink(uri.toString())
appRouter.push(AppRoute.TangemPayOnboarding(mode))
} else {
appRouter.push(AppRoute.Home())
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.tangempay.model
import androidx.compose.runtime.Stable
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -30,29 +31,44 @@ internal class TangemPayOnboardingModel @Inject constructor(
init {
modelScope.launch {
repository.validateDeeplink(params.deeplink)
.onRight { isValid -> if (isValid) checkCustomerInfo() }
.onLeft { exit() }
when (params) {
is TangemPayOnboardingComponent.Params.ContinueOnboarding -> {
checkCustomerInfo()
}
is TangemPayOnboardingComponent.Params.Deeplink -> {
repository.validateDeeplink(params.deeplink)
.onRight { isValid -> if (isValid) checkCustomerInfo() }
.onLeft { back() }
}
}
}
}
fun openKyc() {
router.replaceAll(AppRoute.Wallet, AppRoute.Kyc)
}
fun back() {
router.pop()
}
private suspend fun checkCustomerInfo() {
repository.getCustomerInfo()
.onRight { customerInfo ->
when {
!customerInfo.isKycApproved() -> {
screenState.value = screenState.value.copy(fullScreenLoading = false)
when (params) {
is TangemPayOnboardingComponent.Params.Deeplink ->
screenState.value = screenState.value.copy(fullScreenLoading = false)
else -> openKyc()
}
}
!customerInfo.isProductInstanceActive() -> {
// TODO [REDACTED_TASK_KEY]: create order and poll order status (API is not ready yet)
}
else -> exit()
else -> back()
}
}
.onLeft { exit() }
}
private fun exit() {
router.pop()
.onLeft { back() }
}
}

View file

@ -15,7 +15,8 @@ import com.tangem.core.ui.res.TangemThemePreview
@Composable
internal fun TandemPayOnboardingScreen(
state: TangemPayOnboardingScreenState,
onButtonClick: () -> Unit,
onOpenKycClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@ -23,7 +24,7 @@ internal fun TandemPayOnboardingScreen(
topBar = {
AppBarWithBackButton(
modifier = Modifier.statusBarsPadding(),
onBackClick = {},
onBackClick = onBackClick,
iconRes = R.drawable.ic_back_24,
)
},
@ -33,7 +34,7 @@ internal fun TandemPayOnboardingScreen(
.padding(paddingValues)
.fillMaxSize(),
state = state,
onButtonClick = onButtonClick,
onButtonClick = onOpenKycClick,
)
},
)
@ -44,6 +45,6 @@ internal fun TandemPayOnboardingScreen(
@Composable
private fun PreviewDarkTheme() {
TangemThemePreview {
TandemPayOnboardingScreen(state = TangemPayOnboardingScreenState(), {})
TandemPayOnboardingScreen(state = TangemPayOnboardingScreenState(), {}, {})
}
}

View file

@ -116,6 +116,7 @@ dependencies {
implementation(projects.features.kyc.api)
implementation(projects.features.tokenRecieve.api)
implementation(projects.features.yieldSupply.api)
implementation(projects.features.tangempay.details.api)
/** Common modules */
implementation(projects.common)

View file

@ -18,6 +18,7 @@ import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.nft.ObserveAndClearNFTCacheIfNeedUseCase
import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.settings.*
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.wallets.usecase.*
@ -33,12 +34,14 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent.DemonstrateWalletsScrollPreview.Direction
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.state.transformers.*
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.TangemPayStateConverter
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
@ -84,6 +87,9 @@ internal class WalletModel @Inject constructor(
private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val userWalletsListRepository: UserWalletsListRepository,
private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase,
private val tangemPayStateConverter: TangemPayStateConverter,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
) : Model() {
@ -112,6 +118,7 @@ internal class WalletModel @Inject constructor(
subscribeOnSelectedWalletFlow()
subscribeToScreenBackgroundState()
subscribeOnPushNotificationsPermission()
subscribeToTangemPayInfo()
enableNotificationsIfNeeded()
clickIntents.initialize(innerWalletRouter, modelScope)
@ -313,6 +320,27 @@ internal class WalletModel @Inject constructor(
.saveIn(clearNFTCacheJobHolder)
}
private fun subscribeToTangemPayInfo() {
/**
* Update state each time a user opens/returns to wallet screen
*/
screenLifecycleProvider.isBackgroundState.onEach { isBackground ->
if (!isBackground) { updateTangemPayInfo() }
}.launchIn(modelScope)
}
private fun updateTangemPayInfo() {
if (tangemPayFeatureToggles.isTangemPayEnabled) {
modelScope.launch {
tangemPayMainScreenCustomerInfoUseCase()?.let { info ->
stateHolder.update {
it.copy(tangemPayState = tangemPayStateConverter.convert(info))
}
}
}
}
}
private fun needToRefreshTimer() {
modelScope.launch {
delay(REFRESH_WALLET_BACKGROUND_TIMER_MILLIS)

View file

@ -0,0 +1,23 @@
package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class TangemPayState {
object Empty : TangemPayState()
data class Progress(
val title: TextReference,
val buttonText: TextReference,
@DrawableRes val iconRes: Int,
val onButtonClick: () -> Unit,
) : TangemPayState()
data class Card(
val lastFourDigits: TextReference,
val balanceText: TextReference,
) : TangemPayState()
}

View file

@ -14,4 +14,5 @@ internal data class WalletScreenState(
val isHidingMode: Boolean,
val showMarketsOnboarding: Boolean,
val onDismissMarketsOnboarding: () -> Unit,
val tangemPayState: TangemPayState = TangemPayState.Empty,
)

View file

@ -0,0 +1,52 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.R
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState
import com.tangem.utils.converter.Converter
import java.util.Currency
import javax.inject.Inject
internal class TangemPayStateConverter @Inject constructor(
private val router: Router,
) : Converter<CustomerInfo, TangemPayState> {
override fun convert(value: CustomerInfo): TangemPayState {
val route = AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.ContinueOnboarding)
val cardInfo = value.cardInfo
return when {
!value.isKycApproved() -> TangemPayState.Progress(
title = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_title),
buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button),
iconRes = R.drawable.ic_promo_kyc_36,
onButtonClick = { router.push(route) },
)
!value.isProductInstanceActive() -> TangemPayState.Progress(
title = TextReference.Res(R.string.tangempay_issue_card_notification_title),
buttonText = TextReference.Res(R.string.common_continue),
iconRes = R.drawable.ic_tangem_pay_promo_card_36,
onButtonClick = { router.push(route) },
)
cardInfo != null -> {
TangemPayState.Card(
lastFourDigits = TextReference.Str("*${cardInfo.lastFourDigits}"),
balanceText = TextReference.Str(getBalanceText(cardInfo)),
)
}
else -> TangemPayState.Empty
}
}
private fun getBalanceText(cardInfo: CardInfo): String {
val currency = Currency.getInstance(cardInfo.currencyCode)
return cardInfo.balance.format {
fiat(fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol)
}
}
}

View file

@ -79,6 +79,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeTokensButton
import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.BalancesAndLimitsBottomSheet
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.TangemPayMainScreenBlock
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.VisaTxDetailsBottomSheet
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.balancesAndLimitsBlock
import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator
@ -212,6 +213,11 @@ private fun WalletContent(
notifications(configs = selectedWallet.warnings, modifier = itemModifier)
item(
key = "TangemPayMainScreenBlock",
contentType = state.tangemPayState::class.java,
) { TangemPayMainScreenBlock(state.tangemPayState, itemModifier) }
(selectedWallet as? WalletState.SingleCurrency)?.let { walletState ->
walletState.marketPriceBlockState?.let { marketPriceBlockState ->
marketPriceBlock(state = marketPriceBlockState, modifier = itemModifier)

View file

@ -0,0 +1,82 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Surface
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.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState
@Composable
internal fun TangemPayCardMainBlock(state: TangemPayState.Card, modifier: Modifier = Modifier) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = TangemTheme.shapes.roundedCornersXMedium,
color = TangemTheme.colors.background.primary,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(R.drawable.img_tangem_pay_card_main_36),
contentDescription = null,
modifier = Modifier.size(36.dp).clip(CircleShape),
)
Spacer(Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResourceSafe(R.string.tangempay_kyc_card_ready_notification_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(R.drawable.img_visa_label_26_16),
contentDescription = null,
modifier = Modifier.size(26.dp, 16.dp),
)
Spacer(Modifier.width(4.dp))
Text(
text = state.lastFourDigits.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.TopEnd,
) {
Text(
text = state.balanceText.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.End,
)
}
}
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.visa
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.R
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState
import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.TangemPayCardMainBlock
@Composable
internal fun TangemPayMainScreenBlock(state: TangemPayState, modifier: Modifier = Modifier) {
AnimatedVisibility(state !is TangemPayState.Empty) {
when (state) {
is TangemPayState.Progress -> {
Notification(
modifier = modifier
.fillMaxWidth(),
config = NotificationConfig(
title = state.title,
iconSize = 36.dp,
subtitle = TextReference.EMPTY,
iconResId = state.iconRes,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = state.buttonText,
onClick = state.onButtonClick,
),
),
)
}
is TangemPayState.Card -> TangemPayCardMainBlock(state, modifier)
is TangemPayState.Empty -> Unit
}
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ResetCardScreenPreview() {
TangemThemePreview {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
TangemPayMainScreenBlock(
TangemPayState.Progress(
title = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_title),
buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button),
iconRes = R.drawable.ic_promo_kyc_36,
onButtonClick = {},
),
)
TangemPayMainScreenBlock(
TangemPayState.Progress(
title = TextReference.Res(R.string.tangempay_issue_card_notification_title),
buttonText = TextReference.Res(R.string.common_continue),
iconRes = R.drawable.ic_tangem_pay_promo_card_36,
onButtonClick = {},
),
)
TangemPayMainScreenBlock(
TangemPayState.Card(
lastFourDigits = TextReference.Str("*1234"),
balanceText = TextReference.Str("$ 0.00"),
),
)
}
}
}