Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-28 14:12:42 +05:00
parent ff55704d32
commit 9e2eb5710b
82 changed files with 1150 additions and 401 deletions

View file

@ -9,7 +9,7 @@ import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
@ -36,7 +36,7 @@ internal class DefaultTangemPayStorage @Inject constructor(
)
}
private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) }
private val tokensAdapter by lazy { moshi.adapter(TangemPayAuthTokens::class.java) }
override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
@ -50,7 +50,7 @@ internal class DefaultTangemPayStorage @Inject constructor(
}
}
override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens) =
override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens) =
withContext(dispatcherProvider.io) {
val json = tokensAdapter.toJson(tokens)
@ -60,7 +60,7 @@ internal class DefaultTangemPayStorage @Inject constructor(
)
}
override suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens? =
override suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens? =
withContext(dispatcherProvider.io) {
secureStorage.get(createKey(customerWalletAddress))
?.decodeToString(throwOnInvalidSequence = true)
@ -98,6 +98,14 @@ internal class DefaultTangemPayStorage @Inject constructor(
secureStorage.delete(createOrderIdKey(customerWalletAddress))
}
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId) {
appPreferencesStore.store(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId), true)
}
override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? {
return appPreferencesStore.getSyncOrNull(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId))
}
override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) =
withContext(dispatcherProvider.io) {
secureStorage.delete(createCustomerAddressKey(userWalletId))

View file

@ -17,6 +17,7 @@ import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VisaDataToSignByCustomerWallet
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
import com.tangem.domain.visa.model.sign
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.operations.derivation.DeriveWalletPublicKeyTask
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
@ -44,14 +45,17 @@ class TangemPayGenerateAddressAndSignChallengeTask @AssistedInject constructor(
val wallet = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?: return CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError)
val derivationResult = runDerivationTask(session, wallet)
val address = when (derivationResult) {
val address = when (val derivationResult = runDerivationTask(session, wallet)) {
is CompletionResult.Failure<*> -> return CompletionResult.Failure(derivationResult.error)
is CompletionResult.Success<ExtendedPublicKey> -> generateAddressFromExtendedKey(derivationResult.data)
}
val userWalletId = UserWalletIdBuilder.walletPublicKey(wallet.publicKey)
val challenge = withContext(dispatchersProvider.io) {
visaAuthRemoteDataSource.getCustomerWalletAuthChallenge(address)
visaAuthRemoteDataSource.getCustomerWalletAuthChallenge(
customerWalletAddress = address,
customerWalletId = userWalletId.stringValue,
)
}.getOrElse { return CompletionResult.Failure(it.tangemError) }
val dataToSign = VisaDataToSignByCustomerWallet(hashToSign = challenge.challenge)

View file

@ -668,7 +668,7 @@ internal class ChildFactory @Inject constructor(
is AppRoute.Kyc -> {
createComponentChild(
context = context,
params = KycComponent.Params,
params = KycComponent.Params(route.userWalletId),
componentFactory = kycComponentFactory,
)
}

View file

@ -438,7 +438,7 @@ sealed class AppRoute(val path: String) : Route {
}
@Serializable
data object Kyc : AppRoute(path = "/kyc")
data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc")
@Serializable
data class YieldSupplyPromo(

View file

@ -37,7 +37,7 @@
},
{
"name": "TANGEM_PAY_ENABLED",
"version": "undefined"
"version": "5.31.0"
},
{
"name": "NEW_TOKEN_RECEIVE_ENABLED",

View file

@ -26,6 +26,7 @@ sealed class ApiConfig {
StakeKit,
P2PEthPool,
TangemPay,
TangemPayAuth,
BlockAid,
YieldSupply,
MoonPay,
@ -39,6 +40,7 @@ sealed class ApiConfig {
is StakeKit -> ID.StakeKit
is P2PEthPool -> ID.P2PEthPool
is TangemPay -> ID.TangemPay
is TangemPayAuth -> ID.TangemPayAuth
is BlockAid -> ID.BlockAid
is YieldSupply -> ID.YieldSupply
is MoonPay -> ID.MoonPay

View file

@ -0,0 +1,53 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.version.AppVersionProvider
internal class TangemPayAuth(
private val appVersionProvider: AppVersionProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs = listOf(
createDevEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api.dev.us.paera.com/",
headers = createHeaders(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.us.paera.com/",
headers = createHeaders(),
)
private fun createHeaders() = mapOf(
"version" to ProviderSuspend { appVersionProvider.versionName },
"platform" to ProviderSuspend { "Android" },
)
}

View file

@ -26,11 +26,6 @@ interface TangemPayApi {
@Body request: GenerateNoneByCardWalletRequest,
): ApiResponse<GenerateNonceResponse>
@POST("v1/auth/challenge")
suspend fun generateNonceByCustomerWallet(
@Body request: GenerateNonceByCustomerWalletRequest,
): ApiResponse<GenerateNonceResponse>
@POST("v1/auth/token")
suspend fun getAccessTokenByCardId(@Body request: GetAccessTokenByCardIdRequest): ApiResponse<JWTResponse>
@ -124,6 +119,12 @@ interface TangemPayApi {
@GET("v1/customer/me")
suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse<CustomerMeResponse>
@GET("v1/customer/wallets/{customer_wallet_id}")
suspend fun checkCustomerWalletId(
@Header("X-API-KEY") authHeader: String,
@Path("customer_wallet_id") customerWalletId: String,
): ApiResponse<CheckCustomerWalletResponse>
@POST("v1/deeplink/validate")
suspend fun validateDeeplink(@Body body: DeeplinkValidityRequest): ApiResponse<DeeplinkValidityResponse>

View file

@ -0,0 +1,28 @@
package com.tangem.datasource.api.pay
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.pay.models.request.GenerateNonceByCustomerWalletRequest
import com.tangem.datasource.api.pay.models.request.GetTokenByCustomerWalletRequest
import com.tangem.datasource.api.pay.models.request.RefreshCustomerWalletAccessTokenRequest
import com.tangem.datasource.api.pay.models.response.TangemPayGenerateNonceResponse
import com.tangem.datasource.api.pay.models.response.TangemPayGetTokensResponse
import retrofit2.http.Body
import retrofit2.http.POST
interface TangemPayAuthApi {
@POST("auth/challenge")
suspend fun generateNonceByCustomerWallet(
@Body request: GenerateNonceByCustomerWalletRequest,
): ApiResponse<TangemPayGenerateNonceResponse>
@POST("auth/token")
suspend fun getTokenByCustomerWallet(
@Body request: GetTokenByCustomerWalletRequest,
): ApiResponse<TangemPayGetTokensResponse>
@POST("auth/token/refresh")
suspend fun refreshCustomerWalletAccessToken(
@Body request: RefreshCustomerWalletAccessTokenRequest,
): ApiResponse<TangemPayGetTokensResponse>
}

View file

@ -7,4 +7,5 @@ import com.squareup.moshi.JsonClass
data class GenerateNonceByCustomerWalletRequest(
@Json(name = "auth_type") val authType: String = "customer_wallet",
@Json(name = "customer_wallet_address") val customerWalletAddress: String,
@Json(name = "customer_wallet_id") val customerWalletId: String,
)

View file

@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class GetTokenByCustomerWalletRequest(
@Json(name = "auth_type") val authType: String = "customer_wallet",
@Json(name = "auth_type") val authType: String,
@Json(name = "session_id") val sessionId: String,
@Json(name = "signature") val signature: String,
@Json(name = "message_format") val messageFormat: String,

View file

@ -5,6 +5,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RefreshCustomerWalletAccessTokenRequest(
@Json(name = "auth_type") val authType: String = "customer_wallet",
@Json(name = "auth_type") val authType: String,
@Json(name = "refresh_token") val refreshToken: String,
)

View file

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

View file

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

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TangemPayGetTokensResponse(
@Json(name = "access_token") val accessToken: String,
@Json(name = "expires_at") val expiresAt: Long,
@Json(name = "refresh_token") val refreshToken: String,
@Json(name = "refresh_expires_at") val refreshExpiresAt: Long,
)

View file

@ -80,6 +80,12 @@ internal object ApiConfigsModule {
@IntoSet
fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPay(appVersionProvider)
@Provides
@IntoSet
fun provideTangemPayAuthConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemPayAuth(
appVersionProvider,
)
@Provides
@IntoSet
fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig {

View file

@ -17,6 +17,7 @@ import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
@ -135,6 +136,15 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideTangemPayAuthApi(retrofitApiBuilder: RetrofitApiBuilder): TangemPayAuthApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPayAuth,
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideBlockAidApi(retrofitApiBuilder: RetrofitApiBuilder): BlockAidApi {

View file

@ -24,4 +24,5 @@ data class EnvironmentConfig(
val tangemApiKeyStage: String? = null,
val yieldModuleApiKey: String? = null,
val yieldModuleApiKeyDev: String? = null,
val bffStaticToken: String? = null,
)

View file

@ -32,6 +32,7 @@ internal object EnvironmentConfigConverter : Converter<EnvironmentConfigModel, E
tangemApiKeyStage = value.tangemApiKeyStage,
yieldModuleApiKey = value.yieldModuleApiKey,
yieldModuleApiKeyDev = value.yieldModuleApiKeyDev,
bffStaticToken = value.bffStaticToken,
)
}
}

View file

@ -49,6 +49,7 @@ class EnvironmentConfigModel(
@Json(name = "yieldModuleApiKeyDev") val yieldModuleApiKeyDev: String?,
@Json(name = "blinkApiKey") val blinkApiKey: String?,
@Json(name = "tatumApiKey") val tatumApiKey: String?,
@Json(name = "bffStaticToken") val bffStaticToken: String?,
)
@JsonClass(generateAdapter = true)

View file

@ -13,6 +13,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.USED_CARDS_INFO_K
import com.tangem.datasource.local.preferences.PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.WAS_APPLICATION_STOPPED_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN
import com.tangem.domain.models.wallet.UserWalletId
/**
* All preferences keys that DataStore<Preferences> is stored.
@ -176,6 +177,9 @@ object PreferencesKeys {
fun getTangemPayAddToWalletKey(customerWalletAddress: String) =
booleanPreferencesKey("tangem_pay_add_to_wallet_done_key_$customerWalletAddress")
fun getTangemPayCheckCustomerByWalletId(userWalletId: UserWalletId) =
booleanPreferencesKey("tangem_pay_check_customer_by_wallet_id_$userWalletId")
// endregion
}

View file

@ -1,16 +1,16 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.model.TangemPayAuthTokens
interface TangemPayStorage {
suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String)
suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String?
suspend fun storeAuthTokens(customerWalletAddress: String, tokens: VisaAuthTokens)
suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens)
suspend fun getAuthTokens(customerWalletAddress: String): VisaAuthTokens?
suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens?
suspend fun storeOrderId(customerWalletAddress: String, orderId: String)
@ -21,6 +21,8 @@ interface TangemPayStorage {
suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean
suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean)
suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId)
suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean?
suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String)
}

View file

@ -74,6 +74,7 @@ class ApiConfigTest {
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk())
ApiConfig.ID.News -> News(authProvider = appAuthProvider)
ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = mockk())
}
}
}

View file

@ -115,6 +115,7 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider)
ApiConfig.ID.News -> News(authProvider = appAuthProvider)
ApiConfig.ID.TangemPayAuth -> TangemPayAuth(appVersionProvider = appVersionProvider)
}
}
}
@ -130,6 +131,7 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.MoonPay -> createMoonPayModel()
ApiConfig.ID.P2PEthPool -> createP2PModel()
ApiConfig.ID.News -> createNewsModel()
ApiConfig.ID.TangemPayAuth -> createTangemPayAuthModel()
}
}
@ -257,6 +259,20 @@ internal class ProdApiConfigsManagerTest {
)
}
private fun createTangemPayAuthModel(): TestModel {
return TestModel(
id = ApiConfig.ID.TangemPayAuth,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api.dev.us.paera.com/",
headers = mapOf(
"version" to ProviderSuspend { VERSION_NAME },
"platform" to ProviderSuspend { "Android" },
),
),
)
}
private fun createBlockAidSdkModel(): TestModel {
return TestModel(
id = ApiConfig.ID.BlockAid,

View file

@ -54,7 +54,7 @@
<string name="account_unsaved_dialog_message_create">Bist Du sicher, dass Du das neue Konto verwerfen willst?</string>
<string name="account_unsaved_dialog_message_edit">Bist Du sicher, dass Du die Bearbeitungen verwerfen willst?</string>
<string name="account_unsaved_dialog_title">Nicht gespeicherte Änderungen</string>
<string name="accounts_migration_alert_message">Einige benutzerdefinierte Token wurden von „%1$s\" Zu \"%2$sda ihre Herleitung zu diesem Konto gehört.</string>
<string name="accounts_migration_alert_message">Einige benutzerdefinierte Token wurden von “%1$s” Zu “%2$s” da ihre Herleitung zu diesem Konto gehört.</string>
<string name="accounts_migration_alert_title">Einige benutzerdefinierte Token wurden verschoben.</string>
<string name="action_buttons_buy_empty_search_message">Du kannst Deinen Token nicht finden? Gehe zum Bereich „Markt“ auf der Hauptseite und fügen diesen zum kaufen im Portfolio hinzu.</string>
<string name="action_buttons_sell_empty_search_message">Du kannst Deinen Token nicht finden? Gehe zum Bereich „Markt“ auf der Hauptseite und fügen diesen zum verkaufen im Portfolio hinzu.</string>

View file

@ -652,6 +652,8 @@
<string name="no_account_polkadot">La cuenta de destino no está activa. Envíe %s o más para activar la cuenta.</string>
<string name="no_account_send_to_create">Para crear una cuenta, envíe fondos a esta dirección</string>
<string name="no_trustline_xlm_asset">La cuenta de destino no tiene una Trustline (línea de confianza) para el activo que se envía.</string>
<string name="notification_black_friday_text">Obtén $10 en BTC con cada billetera \n ¡Date prisa!</string>
<string name="notification_black_friday_title">Black Friday: hasta 30% DESCUENTO</string>
<string name="notification_referral_promo_button">Únase ahora</string>
<string name="notification_referral_promo_text">Comparta su código y gane 5 USDT por venta. Su amigo obtiene un 10% de descuento.</string>
<string name="notification_referral_promo_title">¡Obtenga RECOMPENSAS por cada amigo!</string>

View file

@ -1386,7 +1386,7 @@
<string name="wc_alert_unknown_error_description">Code d\'erreur : %s. Si le problème persiste, n\'hésitez pas à contacter notre service d\'assistance.</string>
<string name="wc_alert_unknown_error_description_no_error_code">Si le problème persiste, nhésitez pas à contacter notre support.</string>
<string name="wc_alert_unknown_error_title">Nous avons rencontré une erreur inconnue.</string>
<string name="wc_alert_unsupported_dapps_description">Le portefeuille Tangem ne prend actuellement pas en charge %ss</string>
<string name="wc_alert_unsupported_dapps_description">Le portefeuille Tangem ne prend actuellement pas en charge %s</string>
<string name="wc_alert_unsupported_dapps_title">dApp non prise en charge</string>
<string name="wc_alert_unsupported_method_description">Code d\'erreur : 8 005. Si le problème persiste, n\'hésitez pas à contacter notre support.</string>
<string name="wc_alert_unsupported_method_title">Nous avons rencontré une erreur inconnue</string>

View file

@ -138,10 +138,17 @@
<string name="balance_hidden_title">残高は非表示</string>
<string name="beta_mode_warning_message">ブロックチェーン開発者によると、Kaspaトークンは現在ベータ版です。アップデートをお楽しみに</string>
<string name="beta_mode_warning_title">ベータモード</string>
<string name="biometric_disabled_warning_description">デバイスで生体認証がオフになっているため、ウォレットのロック解除に使用できません。\n生体認証を再度利用するには、デバイスの設定で有効にしてください。</string>
<string name="biometric_disabled_warning_title">生体認証が無効になっています</string>
<string name="biometric_lockout_permanent_warning_description">カードまたはリングをスキャンしてください</string>
<string name="biometric_lockout_permanent_warning_description_2">生体認証の試行回数が上限に達しました。端末タップまたはアクセスコードでウォレットを解除してください。</string>
<string name="biometric_lockout_permanent_warning_title">生体認証がロックされています</string>
<string name="biometric_lockout_warning_description">30秒後に再試行するか、カードまたはリングをスキャンしてください</string>
<string name="biometric_lockout_warning_description_2">生体認証ログインは一時的にロックされています。30秒後にもう一度お試しいただくか、端末タップまたはアクセスコードでウォレットを解除してください。</string>
<string name="biometric_lockout_warning_title">試行回数が多すぎます</string>
<string name="biometric_unavailable_warning">お使いの携帯電話で生体認証が無効になっているため、アプリにウォレットを保存できません。ウォレットを保存するには、携帯電話の設定で生体認証機能を有効にしてください。</string>
<string name="biometric_updated_warning_description">デバイスの生体認証が更新されました。再び生体認証ログインを有効にするため、ウォレットを選び、アクセスコードを入力してください。</string>
<string name="biometric_updated_warning_title">対応が必要です</string>
<string name="bitcoin_promo_activation_error">プロモーションコードの処理中にエラーが発生しました。しばらくしてからもう一度お試しください。</string>
<string name="bitcoin_promo_activation_error_title">アクティベーションエラー</string>
<string name="bitcoin_promo_activation_success">プロモーションコードが正常に有効化されました。報酬は14日以内にビットコインアカウントに入金されます。</string>
@ -818,6 +825,8 @@
<string name="no_account_polkadot">送信先アカウントが有効ではありません。%s 以上を送信してアカウントを有効にしてください。</string>
<string name="no_account_send_to_create">アカウントを作成するには、このアドレスに資金を送金してください</string>
<string name="no_trustline_xlm_asset">送信先アカウントには、送金されるアセットのトラストラインがありません。</string>
<string name="notification_black_friday_text">ウォレットごとに$10相当のBTCをプレゼント\nお早めに</string>
<string name="notification_black_friday_title">ブラックフライデー最大30% オフ</string>
<string name="notification_referral_promo_button">今すぐ参加</string>
<string name="notification_referral_promo_text">コードを共有すると、販売ごとに5 USDTを獲得できます。お友達は10%割引になります。</string>
<string name="notification_referral_promo_title">友達への紹介で報酬を獲得しよう!</string>
@ -1177,7 +1186,7 @@
<string name="staking_amount_requirement_error">ステーキング金額は %s 以上である必要があります</string>
<string name="staking_amount_tron_integer_error">ネットワークルールにより、ステーキング金額は%1$s TRX に切り上げられます。</string>
<string name="staking_amount_tron_integer_error_unstaking">ネットワークルールにより、ステーキング解除の量は%1$s TRX に切り上げられます。</string>
<string name="staking_apr_earn_badge">APR %1$s%</string>
<string name="staking_apr_earn_badge">APR %1$s%%</string>
<string name="staking_claim_unstaked">ステーキング解除分を請求する</string>
<string name="staking_details_account_fee">ステーキングアカウント手数料</string>
<string name="staking_details_account_fee_info">ステーキングアカウントとは、ステーキングされたSOLが保管される特別なアカウントです。トークンをバリデーターに委任し、取引の検証に参加して報酬を受け取る際に作成されます。ステーキングアカウントの作成には少額の手数料がかかりますが、ステーキング完了後に返金されます。</string>
@ -1869,7 +1878,7 @@
<string name="yield_module_fee_policy_sheet_fee_note">今後の追加入金ごとにおおよそのネットワーク手数料%1$s ( %2$s ) が差し引かれ、 %3$s ( %4$s ) の制限を超えることはありません。</string>
<string name="yield_module_fee_policy_sheet_max_fee_note">ネットワーク手数料が上限を超えた場合、手数料が下がるまで取引は成立しません。この制限は後で変更できます。</string>
<string name="yield_module_fee_policy_sheet_max_fee_title">最大手数料</string>
<string name="yield_module_fee_policy_sheet_min_amount_note">最小金額は現在のネットワーク手数料に基づいて計算され、入金額の4% 、つまり最小の%1$s ( %2$s ) を超えないようにします。</string>
<string name="yield_module_fee_policy_sheet_min_amount_note">最小金額は現在のネットワーク手数料に基づいて計算され、入金額の4%% 、つまり最小の%1$s ( %2$s ) を超えないようにします。</string>
<string name="yield_module_fee_policy_sheet_min_amount_title">最低入金額</string>
<string name="yield_module_fee_policy_sheet_title">入金手数料ポリシー</string>
<string name="yield_module_fee_policy_tangem_service_fee_title">Tangemは、生成された利息に対して15%サービス手数料も徴収します。</string>

View file

@ -831,6 +831,8 @@
<string name="no_account_polkadot">Аккаунт получателя не активирован. Отправьте %s или более для активации аккаунта.</string>
<string name="no_account_send_to_create">Для создания аккаунта отправьте средства на этот адрес</string>
<string name="no_trustline_xlm_asset">Аккаунт получателя не содержит трастлайна для отправляемого актива.</string>
<string name="notification_black_friday_text">Получите $10 в BTC за каждый кошелёк\nПоторопитесь!</string>
<string name="notification_black_friday_title">Чёрная пятница: скидки до 30%</string>
<string name="notification_referral_promo_button">Присоединиться</string>
<string name="notification_referral_promo_text">Поделись промокодом — заработай 5 USDT с каждой покупки. Твои друзья получат скидку 10% на карту Tangem!</string>
<string name="notification_referral_promo_title">Получай бонусы за каждого друга!</string>

View file

@ -842,8 +842,8 @@
<string name="no_account_polkadot">Destination account is not active. Send %s or more to activate the account.</string>
<string name="no_account_send_to_create">To create account send funds to this address</string>
<string name="no_trustline_xlm_asset">The destination account does not have a trustline for the asset being sent.</string>
<string name="notification_black_friday_text">Plus Rewards in BTC per Set.\nHurry up</string>
<string name="notification_black_friday_title">Black Friday: up to 25% OFF</string>
<string name="notification_black_friday_text">Get $10 in BTC with every wallet\nHurry!</string>
<string name="notification_black_friday_title">Black Friday: up to 30% OFF</string>
<string name="notification_referral_promo_button">Join Now</string>
<string name="notification_referral_promo_text">Share your code - earn 5 USDT per sale. Your friend gets 10% OFF.</string>
<string name="notification_referral_promo_title">Get REWARDS for every friend!</string>

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.components.inputrow
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
@ -15,6 +16,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
@ -35,7 +37,7 @@ import com.tangem.core.ui.res.TangemThemePreview
* @param modifier modifier
* @param caption caption text
* @param imageUrl icon to load
* @param iconRes icon resource
* @param iconResVector icon resource
* @param subtitleColor subtitle text color
* @param captionColor caption text color
* @param iconTint icon tint
@ -47,12 +49,13 @@ import com.tangem.core.ui.res.TangemThemePreview
*/
@Suppress("LongMethod")
@Composable
internal fun InputRowImageBase(
fun InputRowImageBase(
subtitle: TextReference,
modifier: Modifier = Modifier,
caption: TextReference? = null,
imageUrl: String? = null,
@DrawableRes iconRes: Int? = null,
@DrawableRes iconResVector: Int? = null,
@DrawableRes iconResWebp: Int? = null,
subtitleColor: Color = TangemTheme.colors.text.primary1,
captionColor: Color = TangemTheme.colors.text.tertiary,
iconTint: Color = TangemTheme.colors.icon.informative,
@ -76,7 +79,7 @@ internal fun InputRowImageBase(
.clip(TangemTheme.shapes.roundedCornersXLarge),
)
SpacerW12()
} else if (iconRes != null) {
} else if (iconResVector != null) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
@ -85,13 +88,20 @@ internal fun InputRowImageBase(
.background(iconTint.copy(alpha = 0.08f)),
) {
Icon(
painter = rememberVectorPainter(image = ImageVector.vectorResource(id = iconRes)),
painter = rememberVectorPainter(image = ImageVector.vectorResource(id = iconResVector)),
tint = iconTint,
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size18),
)
}
SpacerW12()
} else if (iconResWebp != null) {
Image(
painter = painterResource(iconResWebp),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size36),
)
SpacerW12()
}
Column {
Row {

View file

@ -83,7 +83,7 @@ fun InputRowImageInfo(
subtitle = subtitle,
caption = caption,
imageUrl = imageUrl,
iconRes = iconRes,
iconResVector = iconRes,
iconTint = iconTint,
subtitleColor = subtitleColor,
captionColor = captionColor,

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -23,6 +23,7 @@ dependencies {
implementation(projects.domain.visa)
implementation(projects.domain.card)
implementation(projects.domain.wallets)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency.models)
@ -31,6 +32,7 @@ dependencies {
implementation(projects.domain.networks)
implementation(projects.domain.walletManager)
implementation(projects.domain.quotes)
implementation(projects.domain.common)
/** Feature API - remove after removing [HotWalletFeatureToggles] */
implementation(projects.features.hotWallet.api)

View file

@ -5,8 +5,8 @@ import arrow.core.raise.either
import com.tangem.common.CompletionResult
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.sdk.api.TangemSdkManager
import javax.inject.Inject
@ -16,18 +16,14 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
) : TangemPayAuthDataSource {
override suspend fun produceInitialCredentials(cardId: String): Either<Throwable, TangemPayInitialCredentials> {
val initialCredentials = tangemSdkManager.tangemPayProduceInitialCredentials(cardId = cardId)
return when (initialCredentials) {
return when (val initialCredentials = tangemSdkManager.tangemPayProduceInitialCredentials(cardId = cardId)) {
is CompletionResult.Failure<*> -> Either.Left(initialCredentials.error)
is CompletionResult.Success<TangemPayInitialCredentials> -> Either.Right(initialCredentials.data)
}
}
override suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, VisaAuthTokens> = either {
visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens(
VisaAuthTokens.RefreshToken(refreshToken, authType = VisaAuthTokens.RefreshToken.Type.CardWallet),
)
override suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, TangemPayAuthTokens> = either {
visaAuthRemoteDataSource.refreshCustomerWalletAuthTokens(refreshToken = refreshToken)
.mapLeft { IllegalStateException("TangemPay token refresh failed. Error code: ${it.errorCode}") }
.bind()
}

View file

@ -1,26 +1,22 @@
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.models.wallet.UserWalletId
import com.tangem.domain.pay.KycStartInfo
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
private const val TAG = "TangemPay: KycRepository"
internal class DefaultKycRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
) : KycRepository {
override suspend fun getKycStartInfo(): Either<UniversalError, KycStartInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
tangemPayApi.getKycAccess(authHeader = authHeader)
}.result
KycStartInfo(token = result.token, locale = result.locale)
}
override suspend fun getKycStartInfo(userWalletId: UserWalletId): Either<VisaApiError, KycStartInfo> {
return requestHelper.performRequest(
userWalletId,
) { authHeader -> tangemPayApi.getKycAccess(authHeader = authHeader) }
.map { KycStartInfo(token = it.result.token, locale = it.result.locale) }
}
}

View file

@ -1,26 +1,29 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
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.MainScreenCustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
private const val VALID_STATUS = "valid"
@ -34,105 +37,82 @@ internal class DefaultOnboardingRepository @Inject constructor(
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayStorage: TangemPayStorage,
private val authDataSource: TangemPayAuthDataSource,
private val tangemPayWalletsManager: TangemPayWalletsManager,
private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : OnboardingRepository {
// Save data for a session
private var lastFetchedCustomerInfo: CustomerInfo? = null
private val lastFetchedCustomerInfoMap = ConcurrentHashMap<UserWalletId, CustomerInfo>()
override suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean> {
return requestHelper.runWithErrorLogs(TAG) {
val result = tangemPayApi.validateDeeplink(DeeplinkValidityRequest(link))
.getOrThrow()
.result
result?.status == VALID_STATUS
override suspend fun validateDeeplink(link: String): Either<VisaApiError, Boolean> {
return requestHelper.performWithStaticToken {
tangemPayApi.validateDeeplink(body = DeeplinkValidityRequest(link = link))
}.map { response ->
response.result?.status == VALID_STATUS
}
}
override suspend fun isTangemPayInitialDataProduced(): Boolean {
val walletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId
val customerWalletAddress = tangemPayStorage.getCustomerWalletAddress(walletId) ?: return false
tangemPayStorage.getAuthTokens(customerWalletAddress) ?: return false
override suspend fun isTangemPayInitialDataProduced(userWalletId: UserWalletId): Boolean {
return withContext(dispatcherProvider.io) {
val customerWalletAddress =
tangemPayStorage.getCustomerWalletAddress(userWalletId) ?: return@withContext false
tangemPayStorage.getAuthTokens(customerWalletAddress) ?: return@withContext false
return true
return@withContext true
}
}
override suspend fun produceInitialData() {
val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay()
val initialCredentials = authDataSource.produceInitialCredentials(cardId = wallet.cardId)
.fold(
ifLeft = { error -> error("Can not produce initial data: ${error.message}") },
ifRight = { it },
override suspend fun produceInitialData(userWalletId: UserWalletId) {
withContext(dispatcherProvider.io) {
val initialCredentials = authDataSource.produceInitialCredentials(cardId = getCardId(userWalletId))
.fold(
ifLeft = { error -> error("Can not produce initial data: ${error.message}") },
ifRight = { it },
)
// should storeCheckCustomerWalletResult because we already know this
tangemPayStorage.storeCheckCustomerWalletResult(userWalletId)
tangemPayStorage.storeCustomerWalletAddress(
userWalletId = userWalletId,
customerWalletAddress = initialCredentials.customerWalletAddress,
)
tangemPayStorage.storeAuthTokens(
customerWalletAddress = initialCredentials.customerWalletAddress,
tokens = initialCredentials.authTokens,
)
tangemPayStorage.storeCustomerWalletAddress(
userWalletId = wallet.walletId,
customerWalletAddress = initialCredentials.customerWalletAddress,
)
tangemPayStorage.storeAuthTokens(
customerWalletAddress = initialCredentials.customerWalletAddress,
tokens = initialCredentials.authTokens,
)
}
override suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.result
getCustomerInfo(result)
}
}
override suspend fun getMainScreenCustomerInfo(): Either<UniversalError, MainScreenCustomerInfo> {
return requestHelper.runWithErrorLogs(TAG) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress()
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> {
// TODO implement selector
return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getCustomerMe(authHeader) }
.map { response -> getCustomerInfo(userWalletId = userWalletId, response = response.result) }
}
when (val orderId = tangemPayStorage.getOrderId(customerWalletAddress)) {
// If order id wasn't saved -> start order creation and get customer info
null -> {
createOrder()
MainScreenCustomerInfo(
info = getCustomerInfoWithPersistedToken(),
orderStatus = OrderStatus.UNKNOWN,
)
}
// If order id was saved -> check its status
else -> {
val orderStatus = getOrderStatus(orderId)
if (orderStatus == OrderStatus.CANCELED) {
// If order was cancelled -> start order creation
createOrder()
}
val customerInfo = when (orderStatus) {
// Kyc is passed and user waits for order creation -> no need to get customer info
OrderStatus.NEW,
OrderStatus.PROCESSING,
-> CustomerInfo(productInstance = null, isKycApproved = true, cardInfo = null)
// Order was created/cancelled -> clear order id and get customer info
OrderStatus.UNKNOWN,
OrderStatus.COMPLETED,
OrderStatus.CANCELED,
-> getCustomerInfoWithPersistedToken().also {
tangemPayStorage.clearOrderId(customerWalletAddress)
}
}
MainScreenCustomerInfo(info = customerInfo, orderStatus = orderStatus)
}
}
override suspend fun clearOrderId(userWalletId: UserWalletId) {
withContext(dispatcherProvider.io) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
tangemPayStorage.clearOrderId(customerWalletAddress = customerWalletAddress)
}
}
override fun getSavedCustomerInfo(): CustomerInfo? {
return lastFetchedCustomerInfo
override suspend fun getOrderId(userWalletId: UserWalletId): String? {
return withContext(dispatcherProvider.io) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
tangemPayStorage.getOrderId(customerWalletAddress)
}
}
private suspend fun createOrder() = withContext(dispatcherProvider.io) {
override fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? {
return lastFetchedCustomerInfoMap[userWalletId]
}
override suspend fun createOrder(userWalletId: UserWalletId) = withContext(dispatcherProvider.io) {
launch {
requestHelper.runWithErrorLogs(TAG) {
val walletAddress = requestHelper.getCustomerWalletAddress()
val result = requestHelper.request { authHeader ->
val walletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
val result = requestHelper.request(userWalletId) { authHeader ->
tangemPayApi.createOrder(authHeader, body = OrderRequest(walletAddress))
}.result ?: error("Create order result is null")
@ -141,7 +121,23 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
}
private suspend fun getCustomerInfo(response: CustomerMeResponse.Result?): CustomerInfo {
private fun getCardId(userWalletId: UserWalletId): String {
val userWallet = if (hotWalletFeatureToggles.isHotWalletEnabled) {
userWalletsListRepository.userWallets.value?.firstOrNull { it.walletId == userWalletId }
} else {
userWalletsListManager.userWalletsSync.firstOrNull { it.walletId == userWalletId }
} ?: error("no userWallet found")
return if (userWallet is UserWallet.Cold) {
userWallet.cardId
} else {
TODO("[REDACTED_JIRA]")
}
}
private suspend fun getCustomerInfo(
userWalletId: UserWalletId,
response: CustomerMeResponse.Result?,
): CustomerInfo {
val card = response?.card
val balance = response?.balance
val paymentAccount = response?.paymentAccount
@ -157,46 +153,59 @@ internal class DefaultOnboardingRepository @Inject constructor(
null
}
val productInstance = response?.productInstance?.let { instance ->
cardFrozenStateStore.store(
key = instance.cardId,
value = when (instance.status) {
CustomerMeResponse.ProductInstance.Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
else -> TangemPayCardFrozenState.Frozen
},
)
ProductInstance(
id = instance.id,
cardId = instance.cardId,
status = when (instance.status) {
CustomerMeResponse.ProductInstance.Status.ACTIVE -> ProductInstance.Status.ACTIVE
else -> ProductInstance.Status.INACTIVE
},
)
val cardFrozenState = when (instance.status) {
CustomerMeResponse.ProductInstance.Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
else -> TangemPayCardFrozenState.Frozen
}
cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState)
ProductInstance(id = instance.id, cardId = instance.cardId, cardFrozenState = cardFrozenState)
}
return CustomerInfo(
productInstance = productInstance,
isKycApproved = response?.kyc?.status == APPROVED_KYC_STATUS,
cardInfo = cardInfo,
).also { lastFetchedCustomerInfo = it }
}
private suspend fun getOrderStatus(orderId: String): OrderStatus {
val result = requestHelper.request { authHeader ->
tangemPayApi.getOrder(authHeader, orderId)
}.result ?: error("Order result is null")
return when (result.status) {
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
).also {
lastFetchedCustomerInfoMap[userWalletId] = it
}
}
private suspend fun getCustomerInfoWithPersistedToken(): CustomerInfo {
val result = requestHelper.request { authHeader ->
tangemPayApi.getCustomerMe(authHeader)
}.result
return getCustomerInfo(result)
override suspend fun getOrderStatus(
userWalletId: UserWalletId,
orderId: String,
): Either<VisaApiError, OrderStatus> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId)
}.map { response ->
when (response.result?.status) {
null -> OrderStatus.UNKNOWN
OrderStatus.NEW.apiName -> OrderStatus.NEW
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
else -> OrderStatus.CANCELED
}
}
}
override suspend fun checkCustomerWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> {
val hasTangemPay = tangemPayStorage.checkCustomerWalletResult(userWalletId)
if (hasTangemPay == true) {
return Either.Right(true)
}
return requestHelper.performWithStaticToken { staticToken ->
tangemPayApi.checkCustomerWalletId(
authHeader = staticToken,
customerWalletId = userWalletId.stringValue,
)
}.map { response ->
val id = response.id
if (!id.isNullOrEmpty()) {
tangemPayStorage.storeCheckCustomerWalletResult(userWalletId = userWalletId)
true
} else {
false
}
}
}
}

View file

@ -14,6 +14,7 @@ import com.tangem.datasource.api.pay.models.request.SetPinRequest
import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
@ -35,9 +36,9 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
) : TangemPayCardDetailsRepository {
override suspend fun getCardBalance(): Either<UniversalError, TangemPayCardBalance> {
override suspend fun getCardBalance(userWalletId: UserWalletId): Either<UniversalError, TangemPayCardBalance> {
return requestHelper.runWithErrorLogs(TAG) {
val result = requestHelper.request { authHeader ->
val result = requestHelper.request(userWalletId) { authHeader ->
tangemPayApi.getCardBalance(authHeader)
}.result ?: error("Cannot get card balance")
TangemPayCardBalance(
@ -47,12 +48,12 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
}
}
override suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails> {
override suspend fun revealCardDetails(userWalletId: UserWalletId): Either<UniversalError, TangemPayCardDetails> {
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val result = requestHelper.request { authHeader ->
val result = requestHelper.request(userWalletId) { authHeader ->
tangemPayApi.revealCardDetails(
authHeader = authHeader,
body = CardDetailsRequest(sessionId = sessionId),
@ -81,14 +82,14 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
}
}
override suspend fun setPin(pin: String): Either<UniversalError, SetPinResult> {
override suspend fun setPin(userWalletId: UserWalletId, pin: String): Either<UniversalError, SetPinResult> {
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val encryptedData = rainCryptoUtil.encryptPin(pin = pin, secretKeyBytes = secretKeyBytes)
secretKeyBytes.fill(0)
val status = requestHelper.request { authHeader ->
val status = requestHelper.request(userWalletId) { authHeader ->
tangemPayApi.setPin(
authHeader = authHeader,
body = SetPinRequest(
@ -107,21 +108,24 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
}
}
override suspend fun isAddToWalletDone(): Either<UniversalError, Boolean> {
override suspend fun isAddToWalletDone(userWalletId: UserWalletId): Either<UniversalError, Boolean> {
return requestHelper.runWithErrorLogs(TAG) {
storage.getAddToWalletDone(requestHelper.getCustomerWalletAddress())
storage.getAddToWalletDone(requestHelper.getCustomerWalletAddress(userWalletId))
}
}
override suspend fun setAddToWalletAsDone(): Either<UniversalError, Unit> {
override suspend fun setAddToWalletAsDone(userWalletId: UserWalletId): Either<UniversalError, Unit> {
return requestHelper.runWithErrorLogs(TAG) {
storage.storeAddToWalletDone(requestHelper.getCustomerWalletAddress(), isDone = true)
storage.storeAddToWalletDone(requestHelper.getCustomerWalletAddress(userWalletId), isDone = true)
}
}
override suspend fun freezeCard(cardId: String): Either<UniversalError, TangemPayCardFrozenState> {
override suspend fun freezeCard(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardFrozenState> {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending)
return requestHelper.makeSafeRequest {
return requestHelper.makeSafeRequest(userWalletId) {
tangemPayApi.freezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId))
}.onLeft {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Unfrozen)
@ -141,9 +145,12 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
}
}
override suspend fun unfreezeCard(cardId: String): Either<UniversalError, TangemPayCardFrozenState> {
override suspend fun unfreezeCard(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardFrozenState> {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending)
return requestHelper.makeSafeRequest {
return requestHelper.makeSafeRequest(userWalletId) {
tangemPayApi.unfreezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId))
}.onLeft {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Frozen)

View file

@ -6,6 +6,7 @@ import com.tangem.data.visa.utils.TangemPayTxHistoryItemConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig
@ -34,6 +35,7 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
private val txHistoryItemConverter by lazy { TangemPayTxHistoryItemConverter(moshi) }
override fun getTxHistoryBatchFlow(
userWalletId: UserWalletId,
batchSize: Int,
context: TangemPayTxHistoryListBatchingContext,
): TangemPayTxHistoryListBatchFlow {
@ -41,18 +43,24 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: 0 },
batchFetcher = createFetcher(batchSize),
batchFetcher = createFetcher(userWalletId, batchSize),
).toBatchFlow()
}
private fun createFetcher(
userWalletId: UserWalletId,
batchSize: Int,
): BatchFetcher<TangemPayTxHistoryListConfig, List<TangemPayTxHistoryItem>> {
return CursorBatchFetcher(
prefetchDistance = batchSize,
batchSize = batchSize,
subFetcher = { request, _, _ ->
val items = loadItems(config = request.params, cursor = request.cursor, limit = request.limit)
val items = loadItems(
userWalletId = userWalletId,
config = request.params,
cursor = request.cursor,
limit = request.limit,
)
BatchFetchResult.Success(
data = items,
last = items.size < request.limit,
@ -64,6 +72,7 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
}
private suspend fun loadItems(
userWalletId: UserWalletId,
config: TangemPayTxHistoryListConfig,
cursor: String?,
limit: Int,
@ -71,7 +80,14 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
cacheRegistry.invokeOnExpire(
key = getCacheKey(customerWalletAddress = config.customerWalletAddress, cursor = cursor),
skipCache = config.shouldRefresh,
block = { fetch(customerWalletAddress = config.customerWalletAddress, cursor = cursor, pageSize = limit) },
block = {
fetch(
userWalletId = userWalletId,
customerWalletAddress = config.customerWalletAddress,
cursor = cursor,
pageSize = limit,
)
},
)
return txHistoryItemsStore.getSyncOrNull(
@ -84,9 +100,14 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
return "tangem_pay_tx_history_${customerWalletAddress}_${cursor ?: INITIAL_CURSOR}"
}
private suspend fun fetch(customerWalletAddress: String, cursor: String?, pageSize: Int) {
private suspend fun fetch(
userWalletId: UserWalletId,
customerWalletAddress: String,
cursor: String?,
pageSize: Int,
) {
requestPerformer.runWithErrorLogs(TAG) {
val result = requestPerformer.request { authHeader ->
val result = requestPerformer.request(userWalletId) { authHeader ->
visaApi.getTangemPayTxHistory(authHeader = authHeader, limit = pageSize, cursor = cursor)
}.result
val items = txHistoryItemConverter.convertList(result.transactions).filterNotNull()

View file

@ -1,19 +1,20 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.Either.Companion.catch
import arrow.core.left
import arrow.core.raise.catch
import arrow.core.right
import com.squareup.moshi.Moshi
import com.tangem.core.error.UniversalError
import com.squareup.wire.Instant
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.domain.visa.model.getAuthHeader
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.*
@ -25,20 +26,21 @@ import javax.inject.Inject
internal class TangemPayRequestPerformer @Inject constructor(
@NetworkMoshi moshi: Moshi,
private val environmentConfigStorage: EnvironmentConfigStorage,
private val dispatchers: CoroutineDispatcherProvider,
private val tangemPayStorage: TangemPayStorage,
private val authDataSource: TangemPayAuthDataSource,
private val tangemPayWalletsManager: TangemPayWalletsManager,
) {
private val customerWalletAddress = MutableStateFlow<String?>(null)
private val refreshTokensMutex = Mutex()
private var refreshTokensJob: Deferred<VisaAuthTokens>? = null
private var refreshTokensJob: Deferred<TangemPayAuthTokens>? = null
private val errorConverter = TangemPayErrorConverter(moshi)
suspend fun <T : Any> runWithErrorLogs(tag: String, requestBlock: suspend () -> T): Either<UniversalError, T> {
@Deprecated("Do not use this method")
suspend fun <T : Any> runWithErrorLogs(tag: String, requestBlock: suspend () -> T): Either<VisaApiError, T> {
return try {
val result = requestBlock()
Either.Right(result)
@ -56,54 +58,81 @@ internal class TangemPayRequestPerformer @Inject constructor(
}
suspend fun <T : Any> makeSafeRequest(
userWalletId: UserWalletId,
requestBlock: suspend (header: String) -> ApiResponse<T>,
): Either<VisaApiError, T> {
return catch { request(requestBlock) }
.mapLeft { exception ->
Timber.tag("TangemPayRequestPerformer").e(exception)
errorConverter.convert(exception)
}
return performRequest(userWalletId, requestBlock)
}
suspend fun <T : Any> request(requestBlock: suspend (header: String) -> ApiResponse<T>): T =
withContext(dispatchers.io) {
performRequest(
requestBlock = requestBlock,
getTokens = ::getAccessTokens,
refreshTokens = ::refreshAuthTokens,
@Deprecated("Use perform request instead", replaceWith = ReplaceWith("performRequest"))
suspend fun <T : Any> request(
userWalletId: UserWalletId,
requestBlock: suspend (header: String) ->
ApiResponse<T>,
): T = withContext(dispatchers.io) {
performRequest(userWalletId, requestBlock = requestBlock)
// to keep behaviour as previous
.fold(
ifRight = { it },
ifLeft = { error -> error("Cannot perform request: $error") },
)
}
private suspend fun <T : Any> performRequest(
requestBlock: suspend (header: String) -> ApiResponse<T>,
getTokens: (suspend () -> VisaAuthTokens),
refreshTokens: (suspend () -> VisaAuthTokens)? = null,
): T = runCatching {
val tokens = getTokens()
val header = tokens.getAuthHeader()
requestBlock(header).getOrThrow()
}.getOrElse { error ->
val unauthorizedCode = ApiResponseError.HttpException.Code.UNAUTHORIZED
if (error is ApiResponseError.HttpException && refreshTokens != null && error.code == unauthorizedCode) {
refreshOrJoin(refreshTokens)
performRequest(requestBlock, refreshTokens = null, getTokens = getTokens)
} else {
throw error
}
}
private suspend fun refreshOrJoin(refreshTokens: suspend () -> VisaAuthTokens): VisaAuthTokens {
val jobToAwait: Deferred<VisaAuthTokens> =
refreshTokensMutex.withLock {
val current = refreshTokensJob
if (current == null || current.isCompleted) {
coroutineScope {
async { refreshTokens() }.also { refreshTokensJob = it }
}
} else {
current
suspend fun <T : Any> performWithStaticToken(
requestBlock: suspend (header: String) -> ApiResponse<T>,
): Either<VisaApiError, T> = withContext(dispatchers.io) {
catch(
block = {
val staticToken =
environmentConfigStorage.getConfigSync().bffStaticToken ?: error("BFF static token is null")
when (val apiResponse = requestBlock(staticToken)) {
is ApiResponse.Error -> errorConverter.convert(apiResponse.cause).left()
is ApiResponse.Success<T> -> apiResponse.data.right()
}
},
catch = { errorConverter.convert(it).left() },
)
}
suspend fun <T : Any> performRequest(
userWalletId: UserWalletId,
requestBlock: suspend (header: String) -> ApiResponse<T>,
): Either<VisaApiError, T> = withContext(dispatchers.io) {
catch(
block = {
val tokens = getAccessTokens(userWalletId)
val now = Instant.now()
val accessExpiresAt = Instant.ofEpochSecond(tokens.expiresAt)
val refreshExpiresAt = Instant.ofEpochSecond(tokens.refreshExpiresAt)
val apiResponse: ApiResponse<T> = if (accessExpiresAt.isAfter(now)) {
requestBlock(tokens.getAuthHeader())
} else if (accessExpiresAt.isBefore(now) && refreshExpiresAt.isAfter(now)) {
val newTokens = refreshOrJoin(refreshTokens = { refreshAuthTokens(userWalletId) })
requestBlock(newTokens.getAuthHeader())
} else {
return@catch VisaApiError.RefreshTokenExpired.left()
}
when (apiResponse) {
is ApiResponse.Error -> errorConverter.convert(apiResponse.cause).left()
is ApiResponse.Success<T> -> apiResponse.data.right()
}
},
catch = { errorConverter.convert(it).left() },
).onLeft { visaApiError -> Timber.tag("TangemPayRequestPerformer").e(visaApiError.toString()) }
}
private suspend fun refreshOrJoin(refreshTokens: suspend () -> TangemPayAuthTokens): TangemPayAuthTokens {
val jobToAwait: Deferred<TangemPayAuthTokens> = refreshTokensMutex.withLock {
val current = refreshTokensJob
if (current == null || current.isCompleted) {
coroutineScope {
async { refreshTokens() }.also { refreshTokensJob = it }
}
} else {
current
}
}
val result = try {
jobToAwait.await()
} finally {
@ -116,28 +145,28 @@ internal class TangemPayRequestPerformer @Inject constructor(
return result
}
suspend fun getCustomerWalletAddress(): String {
suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String {
val existingAddress = customerWalletAddress.value
if (existingAddress != null) {
return existingAddress
}
val storedAddress = tangemPayStorage.getCustomerWalletAddress(
userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPay().walletId,
userWalletId = userWalletId,
) ?: error("Can not find customer address")
customerWalletAddress.value = storedAddress
return storedAddress
}
private suspend fun getAccessTokens(): VisaAuthTokens {
val walletAddress = getCustomerWalletAddress()
private suspend fun getAccessTokens(userWalletId: UserWalletId): TangemPayAuthTokens {
val walletAddress = getCustomerWalletAddress(userWalletId)
val tokens = tangemPayStorage.getAuthTokens(walletAddress) ?: error("Auth tokens are not stored")
return tokens
}
private suspend fun refreshAuthTokens(): VisaAuthTokens {
val customerWalletAddress = getCustomerWalletAddress()
val refreshToken = getAccessTokens().refreshToken.value
private suspend fun refreshAuthTokens(userWalletId: UserWalletId): TangemPayAuthTokens {
val customerWalletAddress = getCustomerWalletAddress(userWalletId)
val refreshToken = getAccessTokens(userWalletId).refreshToken
val tokens = authDataSource.refreshAuthTokens(refreshToken)
.fold(
ifLeft = { error -> error("Cannot refresh tokens: ${error.message}") },

View file

@ -12,6 +12,8 @@ class TangemPayErrorConverter(moshi: Moshi) : Converter<Throwable, VisaApiError>
override fun convert(value: Throwable): VisaApiError {
return if (value is ApiResponseError.HttpException) {
if (value.code == ApiResponseError.HttpException.Code.NOT_FOUND) return VisaApiError.NotPaeraCustomer
val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode
return runCatching {
visaErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode

View file

@ -8,7 +8,8 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import javax.inject.Inject
internal class TangemPayWalletsManager @Inject constructor(
// TODO remove after implement wallet selector in pay
class TangemPayWalletsManager @Inject constructor(
private val manager: UserWalletsListManager,
private val repository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,

View file

@ -5,15 +5,13 @@ import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.pay.models.request.*
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSession
import com.tangem.domain.visa.model.VisaAuthSignedChallenge
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
@ -21,6 +19,7 @@ import javax.inject.Inject
internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
private val visaAuthApi: TangemPayApi,
private val tangemPayAuthApi: TangemPayAuthApi,
private val dispatchers: CoroutineDispatcherProvider,
) : VisaAuthRemoteDataSource {
@ -66,15 +65,19 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
override suspend fun getCustomerWalletAuthChallenge(
customerWalletAddress: String,
customerWalletId: String,
): Either<VisaApiError, VisaAuthChallenge.Wallet> = withContext(dispatchers.io) {
request {
visaAuthApi.generateNonceByCustomerWallet(
GenerateNonceByCustomerWalletRequest(customerWalletAddress = customerWalletAddress),
tangemPayAuthApi.generateNonceByCustomerWallet(
request = GenerateNonceByCustomerWalletRequest(
customerWalletAddress = customerWalletAddress,
customerWalletId = customerWalletId,
),
).getOrThrow()
}.map { response ->
VisaAuthChallenge.Wallet(
challenge = response.result.nonce,
session = VisaAuthSession(response.result.sessionId),
challenge = response.nonce,
session = VisaAuthSession(response.sessionId),
)
}
}
@ -83,37 +86,42 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
sessionId: String,
signature: String,
nonce: String,
): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
): Either<VisaApiError, TangemPayAuthTokens> = withContext(dispatchers.io) {
request {
visaAuthApi.getTokenByCustomerWallet(
GetTokenByCustomerWalletRequest(
tangemPayAuthApi.getTokenByCustomerWallet(
request = GetTokenByCustomerWalletRequest(
authType = "customer_wallet",
sessionId = sessionId,
signature = signature,
messageFormat = "Tangem Pay wants to sign in with your account. Nonce: $nonce",
),
).getOrThrow()
}.map { response ->
VisaAuthTokens(
response.result.accessToken,
VisaAuthTokens.RefreshToken(
response.result.refreshToken,
VisaAuthTokens.RefreshToken.Type.CardWallet,
),
TangemPayAuthTokens(
accessToken = response.accessToken,
expiresAt = response.expiresAt,
refreshToken = response.refreshToken,
refreshExpiresAt = response.refreshExpiresAt,
)
}
}
override suspend fun refreshCustomerWalletAuthTokens(
refreshToken: VisaAuthTokens.RefreshToken,
): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
refreshToken: String,
): Either<VisaApiError, TangemPayAuthTokens> = withContext(dispatchers.io) {
request {
visaAuthApi.refreshCustomerWalletAccessToken(
RefreshCustomerWalletAccessTokenRequest(refreshToken = refreshToken.value),
tangemPayAuthApi.refreshCustomerWalletAccessToken(
request = RefreshCustomerWalletAccessTokenRequest(
authType = "customer_wallet",
refreshToken = refreshToken,
),
).getOrThrow()
}.map { response ->
VisaAuthTokens(
accessToken = response.result.accessToken,
refreshToken = refreshToken.copy(value = response.result.refreshToken),
TangemPayAuthTokens(
accessToken = response.accessToken,
expiresAt = response.expiresAt,
refreshToken = response.refreshToken,
refreshExpiresAt = response.refreshExpiresAt,
)
}
}

View file

@ -27,6 +27,7 @@ dependencies {
implementation(deps.spongecastle.core)
/** Libs - Other */
implementation(deps.timber)
implementation(deps.jodatime)
implementation(deps.androidx.paging.runtime)
implementation(deps.moshi)

View file

@ -61,6 +61,7 @@ sealed class VisaApiError(
fun isUnknown() = this is UnknownWithoutCode || this is Unknown
data object RefreshTokenExpired : VisaApiError(104004001)
data object NotPaeraCustomer : VisaApiError(104004002)
companion object {
fun fromBackendError(backendErrorCode: Int): VisaApiError {

View file

@ -0,0 +1,18 @@
package com.tangem.domain.visa.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = true)
data class TangemPayAuthTokens(
@Json(name = "access_token") val accessToken: String,
@Json(name = "expires_at") val expiresAt: Long,
@Json(name = "refresh_token") val refreshToken: String,
@Json(name = "refresh_expires_at") val refreshExpiresAt: Long,
)
fun TangemPayAuthTokens.getAuthHeader(): String {
return "Bearer $accessToken"
}

View file

@ -1,3 +1,3 @@
package com.tangem.domain.visa.model
data class TangemPayInitialCredentials(val customerWalletAddress: String, val authTokens: VisaAuthTokens)
data class TangemPayInitialCredentials(val customerWalletAddress: String, val authTokens: TangemPayAuthTokens)

View file

@ -1,12 +1,12 @@
package com.tangem.domain.pay.datasource
import arrow.core.Either
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VisaAuthTokens
interface TangemPayAuthDataSource {
suspend fun produceInitialCredentials(cardId: String): Either<Throwable, TangemPayInitialCredentials>
suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, VisaAuthTokens>
suspend fun refreshAuthTokens(refreshToken: String): Either<Throwable, TangemPayAuthTokens>
}

View file

@ -1,5 +1,6 @@
package com.tangem.domain.pay.model
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import java.math.BigDecimal
data class MainScreenCustomerInfo(
@ -16,13 +17,8 @@ data class CustomerInfo(
data class ProductInstance(
val id: String,
val cardId: String,
val status: Status,
) {
enum class Status {
ACTIVE,
INACTIVE,
}
}
val cardFrozenState: TangemPayCardFrozenState,
)
data class CardInfo(
val lastFourDigits: String,

View file

@ -0,0 +1,8 @@
package com.tangem.domain.pay.model
sealed interface TangemPayCustomerInfoError {
data object UnavailableError : TangemPayCustomerInfoError
data object RefreshNeededError : TangemPayCustomerInfoError
data object UnknownError : TangemPayCustomerInfoError
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.KycStartInfo
interface KycRepository {
@ -9,5 +10,5 @@ interface KycRepository {
/**
* Returns KYC data to start or continue the survey
*/
suspend fun getKycStartInfo(): Either<UniversalError, KycStartInfo>
suspend fun getKycStartInfo(userWalletId: UserWalletId): Either<UniversalError, KycStartInfo>
}

View file

@ -2,23 +2,27 @@ package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.visa.error.VisaApiError
import kotlinx.coroutines.Job
interface OnboardingRepository {
suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean>
suspend fun isTangemPayInitialDataProduced(): Boolean
suspend fun isTangemPayInitialDataProduced(userWalletId: UserWalletId): Boolean
suspend fun produceInitialData()
suspend fun produceInitialData(userWalletId: UserWalletId)
suspend fun getCustomerInfo(): Either<UniversalError, CustomerInfo>
suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo>
/**
* Returns only if the user already authorised at least once
*/
suspend fun getMainScreenCustomerInfo(): Either<UniversalError, MainScreenCustomerInfo>
suspend fun createOrder(userWalletId: UserWalletId): Job
fun getSavedCustomerInfo(): CustomerInfo?
suspend fun clearOrderId(userWalletId: UserWalletId)
suspend fun getOrderId(userWalletId: UserWalletId): String?
suspend fun getOrderStatus(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, OrderStatus>
suspend fun checkCustomerWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean>
fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo?
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
@ -10,18 +11,22 @@ import kotlinx.coroutines.flow.Flow
interface TangemPayCardDetailsRepository {
suspend fun getCardBalance(): Either<UniversalError, TangemPayCardBalance>
suspend fun getCardBalance(userWalletId: UserWalletId): Either<UniversalError, TangemPayCardBalance>
suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails>
suspend fun revealCardDetails(userWalletId: UserWalletId): Either<UniversalError, TangemPayCardDetails>
suspend fun setPin(pin: String): Either<UniversalError, SetPinResult>
suspend fun setPin(userWalletId: UserWalletId, pin: String): Either<UniversalError, SetPinResult>
suspend fun isAddToWalletDone(): Either<UniversalError, Boolean>
suspend fun isAddToWalletDone(userWalletId: UserWalletId): Either<UniversalError, Boolean>
suspend fun setAddToWalletAsDone(): Either<UniversalError, Unit>
suspend fun setAddToWalletAsDone(userWalletId: UserWalletId): Either<UniversalError, Unit>
suspend fun freezeCard(userWalletId: UserWalletId, cardId: String): Either<UniversalError, TangemPayCardFrozenState>
suspend fun unfreezeCard(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardFrozenState>
suspend fun freezeCard(cardId: String): Either<UniversalError, TangemPayCardFrozenState>
suspend fun unfreezeCard(cardId: String): Either<UniversalError, TangemPayCardFrozenState>
fun cardFrozenState(cardId: String): Flow<TangemPayCardFrozenState>
suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState?
}

View file

@ -2,20 +2,16 @@ package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.Either.Companion.catch
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.repository.OnboardingRepository
class ProduceTangemPayInitialDataUseCase(
private val repository: OnboardingRepository,
) {
suspend operator fun invoke(): Either<Throwable, Unit> {
suspend operator fun invoke(userWalletId: UserWalletId): Either<Throwable, Unit> {
return catch {
val isDataProduced = repository.isTangemPayInitialDataProduced()
if (isDataProduced) {
return@catch Unit
} else {
repository.produceInitialData()
}
repository.produceInitialData(userWalletId)
}
}
}

View file

@ -1,7 +1,19 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.raise.catch
import arrow.core.right
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayCustomerInfoError
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import timber.log.Timber
private const val TAG = "TangemPayMainScreenCustomerInfoUseCase"
/**
* Returns tangem pay customer info for the main screen banner
@ -11,5 +23,96 @@ class TangemPayMainScreenCustomerInfoUseCase(
private val repository: OnboardingRepository,
) {
suspend operator fun invoke(): MainScreenCustomerInfo? = repository.getMainScreenCustomerInfo().getOrNull()
suspend operator fun invoke(
userWalletId: UserWalletId,
): Either<TangemPayCustomerInfoError, MainScreenCustomerInfo> = catch(
block = {
repository.checkCustomerWallet(userWalletId)
.fold(
ifLeft = { TangemPayCustomerInfoError.UnknownError.left() },
ifRight = { hasTangemPay ->
if (hasTangemPay) {
proceedWithPaeraCustomerResult(userWalletId)
} else {
TangemPayCustomerInfoError.UnknownError.left() // ignore if there's no TangemPay
}
},
)
},
catch = { error ->
Timber.tag(TAG).e(error)
TangemPayCustomerInfoError.UnknownError.left()
},
)
private suspend fun proceedWithPaeraCustomerResult(
userWalletId: UserWalletId,
): Either<TangemPayCustomerInfoError, MainScreenCustomerInfo> {
val orderId = repository.getOrderId(userWalletId)
return if (orderId != null) {
proceedWithOrderId(userWalletId = userWalletId, orderId = orderId)
} else {
proceedWithoutOrder(userWalletId = userWalletId)
}
}
private suspend fun proceedWithoutOrder(
userWalletId: UserWalletId,
): Either<TangemPayCustomerInfoError, MainScreenCustomerInfo> {
return repository.getCustomerInfo(userWalletId)
.mapLeft { error -> error.mapErrorForCustomer() }
.map { customerInfo ->
if (customerInfo.cardInfo == null) {
// If order id wasn't saved -> start order creation and get customer info
repository.createOrder(userWalletId)
}
MainScreenCustomerInfo(info = customerInfo, orderStatus = OrderStatus.UNKNOWN)
}
}
private suspend fun proceedWithOrderId(
userWalletId: UserWalletId,
orderId: String,
): Either<TangemPayCustomerInfoError, MainScreenCustomerInfo> {
return repository.getOrderStatus(userWalletId, orderId = orderId)
.fold(
ifLeft = { error ->
error.mapErrorForCustomer().left()
},
ifRight = { orderStatus ->
when (orderStatus) {
// Kyc is passed and user waits for order creation -> no need to get customer info
OrderStatus.NEW,
OrderStatus.PROCESSING,
-> MainScreenCustomerInfo(
info = CustomerInfo(productInstance = null, isKycApproved = true, cardInfo = null),
orderStatus = orderStatus,
).right()
// Order was created/cancelled -> clear order id and get customer info
OrderStatus.COMPLETED,
OrderStatus.CANCELED,
OrderStatus.UNKNOWN,
-> {
repository.clearOrderId(userWalletId)
// If order was cancelled -> start order creation
if (orderStatus == OrderStatus.CANCELED) repository.createOrder(userWalletId)
repository.getCustomerInfo(userWalletId = userWalletId)
.mapLeft { it.mapErrorForCustomer() }
.map { customerInfo ->
MainScreenCustomerInfo(info = customerInfo, orderStatus = orderStatus)
}
}
}
},
)
}
private fun VisaApiError.mapErrorForCustomer(): TangemPayCustomerInfoError {
return if (this !is VisaApiError.NotPaeraCustomer) {
TangemPayCustomerInfoError.UnavailableError
} else {
TangemPayCustomerInfoError.UnknownError
}
}
}

View file

@ -1,10 +1,12 @@
package com.tangem.domain.tangempay.repository
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
interface TangemPayTxHistoryRepository {
fun getTxHistoryBatchFlow(
userWalletId: UserWalletId,
batchSize: Int,
context: TangemPayTxHistoryListBatchingContext,
): TangemPayTxHistoryListBatchFlow

View file

@ -2,6 +2,7 @@ package com.tangem.domain.visa.datasource
import arrow.core.Either
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSignedChallenge
import com.tangem.domain.visa.model.VisaAuthTokens
@ -20,17 +21,16 @@ interface VisaAuthRemoteDataSource {
suspend fun getCustomerWalletAuthChallenge(
customerWalletAddress: String,
customerWalletId: String,
): Either<VisaApiError, VisaAuthChallenge.Wallet>
suspend fun getTokenWithCustomerWallet(
sessionId: String,
signature: String,
nonce: String,
): Either<VisaApiError, VisaAuthTokens>
): Either<VisaApiError, TangemPayAuthTokens>
suspend fun refreshCustomerWalletAuthTokens(
refreshToken: VisaAuthTokens.RefreshToken,
): Either<VisaApiError, VisaAuthTokens>
suspend fun refreshCustomerWalletAuthTokens(refreshToken: String): Either<VisaApiError, TangemPayAuthTokens>
suspend fun getAccessTokens(signedChallenge: VisaAuthSignedChallenge): Either<VisaApiError, VisaAuthTokens>

View file

@ -2,10 +2,11 @@ package com.tangem.features.kyc
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
interface KycComponent : ComposableContentComponent {
data object Params
data class Params(val userWalletId: UserWalletId)
interface Factory : ComponentFactory<Params, KycComponent>
}

View file

@ -3,27 +3,36 @@ package com.tangem.features.kyc
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.domain.pay.KycStartInfo
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Stable
@ModelScoped
class DefaultKycModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val kycRepository: KycRepository,
) : Model() {
private val params: KycComponent.Params = paramsContainer.require()
private val _uiState: MutableStateFlow<KycStartInfo?> = MutableStateFlow(null)
val uiState = _uiState.asStateFlow()
init {
modelScope.launch {
kycRepository.getKycStartInfo().getOrNull()?.let { _uiState.emit(it) }
try {
kycRepository.getKycStartInfo(params.userWalletId).getOrNull()?.let { _uiState.emit(it) }
} catch (e: Exception) {
Timber.e(e)
}
}
}
}

View file

@ -63,6 +63,7 @@ class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor(
)
TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = params,
)
TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),

View file

@ -14,9 +14,10 @@ import com.tangem.features.tangempay.ui.TangemPayChangePinScreen
internal class TangemPayChangePinComponent(
private val appComponentContext: AppComponentContext,
params: TangemPayDetailsContainerComponent.Params,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val model: TangemPayChangePinModel = getOrCreateModel()
private val model: TangemPayChangePinModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {

View file

@ -41,6 +41,7 @@ internal class TangemPayDetailsComponent(
private val txHistoryComponent = DefaultTangemPayTxHistoryComponent(
appComponentContext = child("txHistoryComponent"),
params = DefaultTangemPayTxHistoryComponent.Params(
userWalletId = params.userWalletId,
customerWalletAddress = params.config.customerWalletAddress,
uiActions = model,
),

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM
import com.tangem.features.tangempay.model.TangemPayTxHistoryModel
import com.tangem.features.tangempay.ui.tangemPayTxHistoryItems
@ -22,5 +23,9 @@ internal class DefaultTangemPayTxHistoryComponent(
tangemPayTxHistoryItems(listState, state)
}
data class Params(val customerWalletAddress: String, val uiActions: TangemPayTxHistoryUiActions)
data class Params(
val userWalletId: UserWalletId,
val customerWalletAddress: String,
val uiActions: TangemPayTxHistoryUiActions,
)
}

View file

@ -78,7 +78,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
uiState.transformerUpdate(
transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails),
)
cardDetailsRepository.revealCardDetails()
cardDetailsRepository.revealCardDetails(params.params.userWalletId)
.onRight { cardDetails ->
uiState.transformerUpdate(
transformer = DetailsRevealedStateTransformer(

View file

@ -3,28 +3,31 @@ 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.domain.pay.model.SetPinResult
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.entity.TangemPayChangePinUM
import kotlinx.coroutines.flow.*
import com.tangem.features.tangempay.model.transformers.PinCodeChangeTransformer
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayChangePinModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
) : Model() {
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
val uiState: StateFlow<TangemPayChangePinUM>
field = MutableStateFlow(getInitialState())
@ -35,7 +38,15 @@ internal class TangemPayChangePinModel @Inject constructor(
private fun onClickSubmit() {
modelScope.launch {
uiState.update { it.copy(submitButtonLoading = true) }
val result = cardDetailsRepository.setPin(uiState.value.pinCode).getOrNull()
val result = try {
cardDetailsRepository.setPin(
userWalletId = params.userWalletId,
pin = uiState.value.pinCode,
).getOrNull()
} catch (e: Exception) {
Timber.e(e)
return@launch
}
uiState.update { it.copy(submitButtonLoading = false) }
when (result) {
SetPinResult.SUCCESS -> router.push(TangemPayDetailsInnerRoute.ChangePINSuccess)

View file

@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ -123,7 +124,13 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun freezeCard() {
modelScope.launch {
cardDetailsRepository.freezeCard(cardId = params.config.cardId)
val result = try {
cardDetailsRepository.freezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId)
} catch (e: Exception) {
Timber.e(e)
return@launch
}
result
.onLeft {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)))
}
@ -155,7 +162,13 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun unfreezeCard() {
modelScope.launch {
cardDetailsRepository.unfreezeCard(cardId = params.config.cardId)
val result = try {
cardDetailsRepository.unfreezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId)
} catch (e: Exception) {
Timber.e(e)
return@launch
}
result
.onLeft {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)))
}
@ -204,7 +217,12 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun fetchBalance(): Job {
return modelScope.launch {
val result = cardDetailsRepository.getCardBalance().onRight { balance = it }
val result = try {
cardDetailsRepository.getCardBalance(params.userWalletId).onRight { balance = it }
} catch (e: Exception) {
Timber.e(e)
return@launch
}
uiState.update(DetailsBalanceTransformer(balance = result))
}.saveIn(fetchBalanceJobHolder)
}
@ -217,7 +235,12 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun fetchAddToWalletBanner() {
modelScope.launch {
val isDone = cardDetailsRepository.isAddToWalletDone().getOrNull() ?: false
val isDone = try {
cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true
} catch (e: Exception) {
Timber.e(e)
return@launch
}
uiState.update(
transformer = DetailsAddToWalletBannerTransformer(
onClickBanner = ::onClickAddToWalletBlock,
@ -244,7 +267,11 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun onClickCloseAddToWalletBlock() {
modelScope.launch {
cardDetailsRepository.setAddToWalletAsDone()
try {
cardDetailsRepository.setAddToWalletAsDone(params.userWalletId)
} catch (e: Exception) {
Timber.e(e)
}
uiState.update(
transformer = DetailsAddToWalletBannerTransformer(
onClickBanner = ::onClickAddToWalletBlock,

View file

@ -46,7 +46,7 @@ internal class TangemPayTxHistoryModel @Inject constructor(
}
private fun launchPagination() {
modelScope.launch { listManager.launchPagination() }
modelScope.launch { listManager.launchPagination(params.userWalletId) }
}
private fun subscribeToUiItemChanges() {

View file

@ -9,6 +9,6 @@ internal class DetailsHiddenStateTransformer(
) : Transformer<TangemPayCardDetailsUM> {
override fun transform(prevState: TangemPayCardDetailsUM): TangemPayCardDetailsUM {
return stateFactory.getInitialState()
return stateFactory.getInitialState().copy(cardFrozenState = prevState.cardFrozenState)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.tangempay.utils
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
@ -36,8 +37,9 @@ internal class TangemPayTxHistoryListManager(
val emptyStatus: Flow<Boolean> = state.map { it.isEmpty }.distinctUntilChanged()
val paginationStatus: Flow<PaginationStatus<*>> = state.map { it.status }.distinctUntilChanged()
suspend fun launchPagination() = coroutineScope {
suspend fun launchPagination(userWalletId: UserWalletId) = coroutineScope {
val batchFlow = repository.getTxHistoryBatchFlow(
userWalletId = userWalletId,
context = TangemPayTxHistoryListBatchingContext(actionsFlow = actionsFlow, coroutineScope = this),
batchSize = 50,
)

View file

@ -32,6 +32,9 @@ dependencies {
/** Domain */
implementation(projects.domain.visa)
/** Data **/
implementation(projects.data.visa)
/** Compose */
implementation(deps.compose.foundation)
implementation(deps.compose.material3)

View file

@ -6,10 +6,11 @@ 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.features.tangempay.TangemPayConstants
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.model.transformers.TangemPayOnboardingButtonLoadingTransformer
import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState
@ -17,13 +18,14 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import com.tangem.utils.transformer.update as transformerUpdate
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import com.tangem.utils.transformer.update as transformerUpdate
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class TangemPayOnboardingModel @Inject constructor(
paramsContainer: ParamsContainer,
private val router: Router,
@ -31,6 +33,7 @@ internal class TangemPayOnboardingModel @Inject constructor(
private val repository: OnboardingRepository,
private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase,
private val urlOpener: UrlOpener,
private val tangemPayWalletsManager: TangemPayWalletsManager,
) : Model() {
private val params = paramsContainer.require<TangemPayOnboardingComponent.Params>()
@ -66,7 +69,11 @@ internal class TangemPayOnboardingModel @Inject constructor(
}
private suspend fun checkCustomerInfo() {
repository.getCustomerInfo()
// TODO implement selector
repository.getCustomerInfo(
userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking().walletId,
)
// selector
.onRight { customerInfo ->
when {
!customerInfo.isKycApproved -> {
@ -88,32 +95,42 @@ internal class TangemPayOnboardingModel @Inject constructor(
private fun onGetCardClick() {
uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = true))
modelScope.launch {
val result = produceInitialDataUseCase()
// TODO implement selector
val userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking().walletId
val result = produceInitialDataUseCase(userWalletId)
if (result.isLeft()) {
Timber.e("Error producing initial data: ${result.leftOrNull()?.message}")
uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false))
return@launch
}
repository.getCustomerInfo()
.fold(
ifLeft = {
Timber.e("Error getCustomerInfo: ${it.errorCode}")
uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false))
},
ifRight = { customerInfo ->
if (customerInfo.isKycApproved) {
back()
} else {
openKyc()
}
},
)
// TODO implement selector
repository.getCustomerInfo(
userWalletId = userWalletId,
).fold(
ifLeft = {
Timber.e("Error getCustomerInfo: ${it.errorCode}")
uiState.transformerUpdate(TangemPayOnboardingButtonLoadingTransformer(isLoading = false))
},
ifRight = { customerInfo ->
if (customerInfo.isKycApproved) {
back()
} else {
openKyc()
}
},
)
}
}
private fun openKyc() {
router.replaceAll(AppRoute.Wallet, AppRoute.Kyc)
// TODO implement selector
router.replaceAll(
AppRoute.Wallet,
AppRoute.Kyc(
userWalletId = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking().walletId,
),
)
}
private fun back() {

View file

@ -0,0 +1,26 @@
package com.tangem.feature.wallet.child.wallet.model
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.TangemPayCustomerInfoError
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class TangemPayMainInfoManager @Inject constructor(
private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase,
) {
val mainScreenCustomerInfo:
StateFlow<Pair<UserWalletId, Either<TangemPayCustomerInfoError, MainScreenCustomerInfo>>?>
field = MutableStateFlow(null)
suspend fun refreshTangemPayInfo(userWalletId: UserWalletId) {
val info = tangemPayMainScreenCustomerInfoUseCase(userWalletId)
mainScreenCustomerInfo.value = Pair(userWalletId, info)
}
}

View file

@ -20,10 +20,9 @@ import com.tangem.domain.nft.ObserveAndClearNFTCacheIfNeedUseCase
import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayCustomerInfoError
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.settings.*
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.visa.model.TangemPayCardFrozenState
@ -94,13 +93,13 @@ internal class WalletModel @Inject constructor(
private val getIsHuaweiDeviceWithoutGoogleServicesUseCase: GetIsHuaweiDeviceWithoutGoogleServicesUseCase,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val userWalletsListRepository: UserWalletsListRepository,
private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase,
private val tangemPayOnboardingRepository: OnboardingRepository,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val tangemPayMainInfoManager: TangemPayMainInfoManager,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
) : Model() {
@ -137,7 +136,8 @@ internal class WalletModel @Inject constructor(
subscribeOnSelectedWalletFlow()
subscribeToScreenBackgroundState()
subscribeOnPushNotificationsPermission()
subscribeToTangemPayInfo()
subscribeTangemPayOnWalletState()
subscribeOnTangemPayInfoUpdates()
enableNotificationsIfNeeded()
clickIntents.initialize(innerWalletRouter, modelScope)
@ -349,57 +349,86 @@ internal class WalletModel @Inject constructor(
.saveIn(clearNFTCacheJobHolder)
}
private fun subscribeToTangemPayInfo() {
private fun subscribeTangemPayOnWalletState() {
/**
* Update state each time a user opens/returns to wallet screen
* and every minute while user stays on the main screen
*/
screenLifecycleProvider.isBackgroundState.onEach { inBackground ->
// fast exit
if (!tangemPayFeatureToggles.isTangemPayEnabled) return@onEach
if (!tangemPayFeatureToggles.isTangemPayEnabled) return
// Don't refresh customer info periodically if the card was already issued, only update on swipe to refresh
val savedCustomerInfo = tangemPayOnboardingRepository.getSavedCustomerInfo()
if (savedCustomerInfo?.cardInfo != null) {
updateTangemPay(MainScreenCustomerInfo(info = savedCustomerInfo, orderStatus = OrderStatus.COMPLETED))
return@onEach
}
combine(
flow = screenLifecycleProvider.isBackgroundState,
flow2 = uiState.mapNotNull {
it.wallets.getOrNull(it.selectedWalletIndex)?.walletCardState?.id
}.distinctUntilChanged(),
transform = ::Pair,
).onEach { (inBackground, userWalletId) ->
val savedCustomerInfo =
tangemPayOnboardingRepository.getSavedCustomerInfo(userWalletId)
updateTangemPayJobHolder.cancel()
val isShouldLaunchPeriodicUpdate = savedCustomerInfo?.cardInfo == null &&
tangemPayOnboardingRepository.isTangemPayInitialDataProduced(userWalletId)
modelScope.launch {
// fast exit
val initialDataProduced = tangemPayOnboardingRepository.isTangemPayInitialDataProduced()
if (!initialDataProduced) return@launch
if (!inBackground) {
refreshTangemPayInfo()
while (isActive) {
delay(TANGEM_PAY_UPDATE_INTERVAL)
refreshTangemPayInfo()
if (isShouldLaunchPeriodicUpdate) {
updateTangemPayJobHolder.cancel()
modelScope.launch {
if (!inBackground) {
tangemPayMainInfoManager.refreshTangemPayInfo(userWalletId)
while (isActive) {
delay(TANGEM_PAY_UPDATE_INTERVAL)
tangemPayMainInfoManager.refreshTangemPayInfo(userWalletId)
}
}
}
}.saveIn(updateTangemPayJobHolder)
}.saveIn(updateTangemPayJobHolder)
} else {
// Don't refresh customer info periodically if the card was already issued, only update on swipe to refresh
tangemPayMainInfoManager.refreshTangemPayInfo(userWalletId)
}
}.launchIn(modelScope)
}
private suspend fun refreshTangemPayInfo() {
val info = tangemPayMainScreenCustomerInfoUseCase()
if (info != null) updateTangemPay(info)
private fun subscribeOnTangemPayInfoUpdates() {
if (!tangemPayFeatureToggles.isTangemPayEnabled) return
tangemPayMainInfoManager.mainScreenCustomerInfo
.filterNotNull()
.distinctUntilChanged()
.onEach { (userWalletId, mainInfoData) ->
mainInfoData.onLeft { tangemPayError ->
when (tangemPayError) {
TangemPayCustomerInfoError.RefreshNeededError -> {
stateHolder.update(
transformer = TangemPayRefreshNeededStateTransformer(
onRefreshClick = { clickIntents.onRefreshPayToken(userWalletId) },
),
)
}
TangemPayCustomerInfoError.UnavailableError -> {
stateHolder.update(
transformer = TangemPayUnavailableStateTransformer,
)
}
else -> {
// do not draw TangemPay block
Timber.e("Failed when loading main screen TangemPay info: $tangemPayError")
}
}
}.onRight { data -> updateTangemPay(data, userWalletId) }
}
.launchIn(modelScope)
}
private suspend fun updateTangemPay(info: MainScreenCustomerInfo) {
private suspend fun updateTangemPay(data: MainScreenCustomerInfo, userWalletId: UserWalletId) {
val cardFrozenState =
info.info.productInstance?.cardId?.let { cardDetailsRepository.cardFrozenStateSync(it) }
data.info.productInstance?.cardId?.let { cardDetailsRepository.cardFrozenStateSync(it) }
?: TangemPayCardFrozenState.Unfrozen
stateHolder.update(
transformer = TangemPayInitialStateTransformer(
value = info,
value = data,
cardFrozenState = cardFrozenState,
onClickKyc = innerWalletRouter::openTangemPayOnboarding,
openDetails = { config ->
innerWalletRouter.openTangemPayDetails(
userWalletId = stateHolder.getSelectedWalletId(),
userWalletId = userWalletId,
config = config,
)
},

View file

@ -1,45 +1,45 @@
package com.tangem.feature.wallet.child.wallet.model.intents
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.feature.wallet.child.wallet.model.TangemPayMainInfoManager
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.transformers.TangemPayInitialStateTransformer
import com.tangem.features.tangempay.TangemPayFeatureToggles
import kotlinx.coroutines.launch
import javax.inject.Inject
internal interface TangemPayIntents {
suspend fun onPullToRefresh()
fun onRefreshPayToken(userWalletId: UserWalletId)
}
@ModelScoped
internal class TangemPayClickIntentsImplementor @Inject constructor(
private val stateHolder: WalletStateController,
private val mainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase,
private val featureToggles: TangemPayFeatureToggles,
private val onboardingRepository: OnboardingRepository,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val produceInitialDataTangemPay: ProduceTangemPayInitialDataUseCase,
private val tangemPayInfoManager: TangemPayMainInfoManager,
) : BaseWalletClickIntents(), TangemPayIntents {
override suspend fun onPullToRefresh() {
if (!featureToggles.isTangemPayEnabled || !onboardingRepository.isTangemPayInitialDataProduced()) return
val info = mainScreenCustomerInfoUseCase()
val userWalletId = stateHolder.getSelectedWalletId()
if (info != null) {
val cardFrozenState =
info.info.productInstance?.cardId?.let { cardDetailsRepository.cardFrozenStateSync(it) }
?: TangemPayCardFrozenState.Unfrozen
stateHolder.update(
transformer = TangemPayInitialStateTransformer(
value = info,
onClickKyc = router::openTangemPayOnboarding,
openDetails = { config -> router.openTangemPayDetails(userWalletId, config) },
cardFrozenState = cardFrozenState,
),
)
if (!featureToggles.isTangemPayEnabled ||
!onboardingRepository.isTangemPayInitialDataProduced(userWalletId)
) {
return
}
tangemPayInfoManager.refreshTangemPayInfo(userWalletId)
}
override fun onRefreshPayToken(userWalletId: UserWalletId) {
modelScope.launch {
produceInitialDataTangemPay.invoke(userWalletId)
tangemPayInfoManager.refreshTangemPayInfo(userWalletId)
}
}
}

View file

@ -4,8 +4,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent
import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.Program
import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.*
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
@ -50,16 +49,16 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
is WalletNotification.RateApp -> MainScreen.HowDoYouLikeTangem
is WalletNotification.Critical.BackupError -> MainScreen.BackupError
is WalletNotification.NoteMigration -> MainScreen.NotePromo
is WalletNotification.SwapPromo -> PromoAnalyticsEvent.NoticePromotionBanner(
is WalletNotification.SwapPromo -> NoticePromotionBanner(
source = AnalyticsParam.ScreensSources.Main,
program = Program.Empty, // Use it on new promo action
)
is WalletNotification.Sepa -> PromoAnalyticsEvent.NoticePromotionBanner(
is WalletNotification.Sepa -> NoticePromotionBanner(
source = AnalyticsParam.ScreensSources.Main,
program = Program.Sepa,
)
is WalletNotification.ReferralPromo -> MainScreen.ReferralPromo
is WalletNotification.VisaPresalePromo -> PromoAnalyticsEvent.VisaWaitlistPromo
is WalletNotification.VisaPresalePromo -> VisaWaitlistPromo
is WalletNotification.UnlockWallets -> null // See [SelectedWalletAnalyticsSender]
is WalletNotification.Informational.NoAccount,
is WalletNotification.Warning.LowSignatures,
@ -73,6 +72,8 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
is WalletNotification.Critical.SeedPhraseNotification -> MainScreen.NoticeSeedPhraseSupport
is WalletNotification.Critical.SeedPhraseSecondNotification -> MainScreen.NoticeSeedPhraseSupportSecond
is WalletNotification.PushNotifications -> WalletScreenAnalyticsEvent.PushBannerPromo.PushBanner
is WalletNotification.Warning.TangemPayRefreshNeeded -> null
WalletNotification.Warning.TangemPayUnreachable -> null
}
}
}

View file

@ -23,4 +23,10 @@ internal sealed class TangemPayState {
val balanceText: TextReference,
val onClick: () -> Unit,
) : TangemPayState()
data class RefreshNeeded(
val notification: WalletNotification,
) : TangemPayState()
data class TemporaryUnavailable(val notification: WalletNotification) : TangemPayState()
}

View file

@ -143,6 +143,24 @@ sealed class WalletNotification(val config: NotificationConfig) {
title = resourceReference(R.string.yield_module_main_view_approve_notification_title),
subtitle = resourceReference(R.string.yield_module_main_view_approve_notification_description),
)
data object TangemPayUnreachable : Warning(
title = resourceReference(id = R.string.tangempay_temporarily_unavailable),
subtitle = resourceReference(id = R.string.tangempay_service_unreachable_try_later),
)
data class TangemPayRefreshNeeded(
@DrawableRes val tangemIcon: Int?,
val onRefreshClick: () -> Unit,
) : Warning(
title = resourceReference(id = R.string.tangempay_payment_account_sync_needed),
subtitle = resourceReference(id = R.string.tangempay_use_tangem_device_to_restore_payment_account),
buttonsState = ButtonsState.PrimaryButtonConfig(
text = resourceReference(id = R.string.home_button_scan),
iconResId = tangemIcon,
onClick = onRefreshClick,
),
)
}
sealed class Informational(

View file

@ -0,0 +1,21 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification.Warning.TangemPayRefreshNeeded
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
internal class TangemPayRefreshNeededStateTransformer(
private val onRefreshClick: () -> Unit,
) : WalletScreenStateTransformer {
override fun transform(prevState: WalletScreenState): WalletScreenState {
val tangemPayState = TangemPayState.RefreshNeeded(
notification = TangemPayRefreshNeeded(
tangemIcon = R.drawable.ic_tangem_24,
onRefreshClick = onRefreshClick,
),
)
return prevState.copy(tangemPayState = tangemPayState)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
internal object TangemPayUnavailableStateTransformer : WalletScreenStateTransformer {
override fun transform(prevState: WalletScreenState): WalletScreenState {
return prevState.copy(
tangemPayState = TangemPayState.TemporaryUnavailable(
notification = WalletNotification.Warning.TangemPayUnreachable,
),
)
}
}

View file

@ -11,6 +11,7 @@ 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.components.notifications.NotificationConfig.ButtonsState.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -30,7 +31,7 @@ internal fun TangemPayMainScreenBlock(state: TangemPayState, isBalanceHidden: Bo
iconSize = 36.dp,
subtitle = state.description,
iconResId = state.iconRes,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
buttonsState = SecondaryButtonConfig(
text = state.buttonText,
onClick = state.onButtonClick,
shouldShowProgress = state.showProgress,
@ -40,13 +41,15 @@ internal fun TangemPayMainScreenBlock(state: TangemPayState, isBalanceHidden: Bo
}
is TangemPayState.Card -> TangemPayCardMainBlock(state, isBalanceHidden, modifier)
is TangemPayState.Empty -> Unit
is TangemPayState.RefreshNeeded -> TangemPayRefreshBlock(state, modifier)
is TangemPayState.TemporaryUnavailable -> TangemPayUnavailableBlock(state, modifier)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ResetCardScreenPreview() {
private fun TangemPayMainScreenBlockPreview() {
TangemThemePreview {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
TangemPayMainScreenBlock(

View file

@ -0,0 +1,77 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.visa
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.inputrow.InputRowImageBase
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.resourceReference
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.state.model.WalletNotification
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification.Warning.TangemPayRefreshNeeded
@Composable
internal fun TangemPayRefreshBlock(state: TangemPayState.RefreshNeeded, modifier: Modifier = Modifier) {
Column(modifier) {
Notification(
config = state.notification.config,
iconTint = when (state.notification) {
is WalletNotification.Critical -> TangemTheme.colors.icon.warning
is WalletNotification.Informational -> TangemTheme.colors.icon.accent
is WalletNotification.RateApp -> TangemTheme.colors.icon.attention
is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1
is WalletNotification.UsedOutdatedData -> TangemTheme.colors.text.attention
else -> null
},
)
SpacerH12()
BlockCard(
modifier = Modifier
.clip(RoundedCornerShape(size = TangemTheme.dimens.radius14))
.background(TangemTheme.colors.background.primary),
) {
InputRowImageBase(
modifier = Modifier.padding(
all = TangemTheme.dimens.spacing12,
),
subtitle = resourceReference(R.string.tangempay_payment_account),
caption = resourceReference(R.string.tangempay_payment_account_sync_needed),
subtitleColor = TangemTheme.colors.text.tertiary,
captionColor = TangemTheme.colors.text.tertiary,
iconResWebp = R.drawable.img_visa_36,
)
}
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPayRefreshBlockPreview() {
TangemThemePreview {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
TangemPayRefreshBlock(
state = TangemPayState.RefreshNeeded(
TangemPayRefreshNeeded(
tangemIcon = R.drawable.ic_tangem_24,
onRefreshClick = {},
),
),
modifier = Modifier,
)
}
}
}

View file

@ -0,0 +1,75 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.visa
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.inputrow.InputRowImageBase
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
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.state.model.WalletNotification
import com.tangem.utils.StringsSigns.DASH_SIGN
@Composable
internal fun TangemPayUnavailableBlock(state: TangemPayState.TemporaryUnavailable, modifier: Modifier = Modifier) {
Column(modifier) {
Notification(
config = state.notification.config,
iconTint = when (state.notification) {
is WalletNotification.Critical -> TangemTheme.colors.icon.warning
is WalletNotification.Informational -> TangemTheme.colors.icon.accent
is WalletNotification.RateApp -> TangemTheme.colors.icon.attention
is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1
is WalletNotification.UsedOutdatedData -> TangemTheme.colors.text.attention
else -> null
},
)
SpacerH12()
BlockCard(
modifier = Modifier
.clip(RoundedCornerShape(size = TangemTheme.dimens.radius14))
.background(TangemTheme.colors.background.primary),
) {
InputRowImageBase(
modifier = Modifier.padding(
all = TangemTheme.dimens.spacing12,
),
subtitle = resourceReference(R.string.tangempay_payment_account),
caption = TextReference.Str(DASH_SIGN),
subtitleColor = TangemTheme.colors.text.tertiary,
captionColor = TangemTheme.colors.text.tertiary,
iconResWebp = R.drawable.img_visa_36,
)
}
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPayUnavailableBlockPreview() {
TangemThemePreview {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
TangemPayUnavailableBlock(
state = TangemPayState.TemporaryUnavailable(
WalletNotification.Warning.TangemPayUnreachable,
),
modifier = Modifier,
)
}
}
}