Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-15 08:15:22 -07:00
parent ebe84a667e
commit eb2c1d765a
31 changed files with 1034 additions and 123 deletions

View file

@ -85,6 +85,18 @@ interface TangemPayApi {
@Body body: FreezeUnfreezeCardRequest,
): ApiResponse<FreezeUnfreezeCardResponse>
@GET("v1/fees/{type}")
suspend fun getFee(
@Header("Authorization") authHeader: String,
@Path("type") type: String,
): ApiResponse<FeeResponse>
@POST("v1/customer/card/reissue")
suspend fun reissueCard(
@Header("Authorization") authHeader: String,
@Body body: ReissueCardRequest,
): ApiResponse<ReissueCardResponse>
@POST("v1/customer/card/withdraw/data")
suspend fun getWithdrawData(
@Header("Authorization") authHeader: String,

View file

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

View file

@ -0,0 +1,17 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class FeeResponse(
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "type") val type: String,
@Json(name = "amount") val amount: String,
@Json(name = "currency") val currency: String,
@Json(name = "description") val description: String,
)
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ReissueCardResponse(
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "order_id") val orderId: String,
@Json(name = "status") val status: String,
)
}

View file

@ -2,7 +2,9 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -20,4 +22,12 @@ internal object TangemPayStoresModule {
dataStore = RuntimeDataStore(),
)
}
@Provides
@Singleton
fun provideTangemPayReissueCardStore(): TangemPayReissueCardStore {
return DefaultTangemPayReissueCardStore(
feeStore = RuntimeDataStore(),
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.domain.models.TangemPayReissueCardFee
import com.tangem.domain.models.wallet.UserWalletId
internal class DefaultTangemPayReissueCardStore(
private val feeStore: RuntimeDataStore<TangemPayReissueCardFee>,
) : TangemPayReissueCardStore {
override suspend fun storeReissueFee(
userWalletId: UserWalletId,
tangemPayReissueCardFee: TangemPayReissueCardFee,
) {
feeStore.store(userWalletId.stringValue, tangemPayReissueCardFee)
}
override suspend fun getReissueFee(userWalletId: UserWalletId): TangemPayReissueCardFee? {
return feeStore.getSyncOrNull(userWalletId.stringValue)
}
override suspend fun storeReissueOrderId(cardId: String, orderId: String) {
// TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs
}
override suspend fun getOrderId(cardId: String): String? {
// TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs
return null
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.models.TangemPayReissueCardFee
import com.tangem.domain.models.wallet.UserWalletId
interface TangemPayReissueCardStore {
suspend fun storeReissueFee(userWalletId: UserWalletId, tangemPayReissueCardFee: TangemPayReissueCardFee)
suspend fun getReissueFee(userWalletId: UserWalletId): TangemPayReissueCardFee?
suspend fun storeReissueOrderId(cardId: String, orderId: String)
suspend fun getOrderId(cardId: String): String?
}

View file

@ -1556,6 +1556,17 @@
<string name="tangem_pay_explore_transaction">Explore transaction</string>
<string name="tangem_pay_fee_subtitle">Service fees</string>
<string name="tangem_pay_fee_title">Fee</string>
<string name="tangempay_card_details_reissue_card">Replace card</string>
<string name="tangempay_reissue_card_title">Replace your card?</string>
<string name="tangempay_reissue_card_description">This generates a new set of card details. Your old details will stop working. You can\'t undo this.</string>
<string name="tangempay_reissue_card_fee_label">Replacement fee</string>
<string name="tangempay_reissue_card_confirm">Replace card</string>
<string name="tangempay_reissue_card_in_progress">Replacing your digital card</string>
<string name="tangempay_reissue_card_in_progress_description">Usually takes up to 5 minutes. In rare cases, up to 48 hours.</string>
<string name="tangempay_reissue_card_insufficient_funds">Insufficient funds to replace the card</string>
<string name="tangempay_reissue_card_insufficient_funds_title">Unable to cover fee</string>
<string name="tangempay_reissue_card_insufficient_funds_subtitle">Deposit USDC to payment account to cover the issuing fee</string>
<string name="tangempay_reissue_card_fee_unreachable_error_title">Replacement fee info unreachable</string>
<string name="tangem_pay_freeze_card_alert_body">Keep your money safe. You can unfreeze anytime.</string>
<string name="tangem_pay_freeze_card_alert_title">Freeze your card?</string>
<string name="tangem_pay_freeze_card_failed">Failed to freeze the card. Try again later.</string>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M11.888,8.087C11.238,7.437 11.382,6.343 12.241,6.015C12.558,5.894 12.878,5.789 13.201,5.7C14.09,5.456 15.001,5.333 15.934,5.333C18.912,5.333 21.445,6.367 23.534,8.433C25.623,10.5 26.667,13.022 26.667,16V16.233L27.868,15.033C28.383,14.518 29.219,14.518 29.734,15.033C30.25,15.549 30.25,16.385 29.734,16.9L26.401,20.233C25.812,20.823 24.857,20.823 24.267,20.233L20.934,16.9C20.419,16.385 20.419,15.549 20.934,15.033C21.45,14.518 22.285,14.518 22.801,15.033L24.001,16.233V16C24.001,13.778 23.218,11.889 21.651,10.333C20.084,8.778 18.179,8 15.934,8C15.356,8 14.79,8.067 14.234,8.2C13.997,8.257 13.762,8.326 13.528,8.407C12.96,8.605 12.314,8.513 11.888,8.087ZM2.267,16.967C1.752,16.451 1.752,15.616 2.267,15.1L6.075,11.293C6.402,10.965 6.933,10.965 7.26,11.293L11.068,15.1C11.583,15.616 11.583,16.451 11.068,16.967C10.552,17.482 9.716,17.482 9.201,16.967L8.001,15.767V16C8.001,18.222 8.784,20.111 10.351,21.667C11.917,23.222 13.823,24 16.067,24C16.645,24 17.212,23.933 17.767,23.8C18.005,23.743 18.24,23.674 18.473,23.593C19.042,23.395 19.688,23.487 20.114,23.913C20.763,24.563 20.619,25.657 19.761,25.985C19.444,26.106 19.124,26.211 18.801,26.3C17.912,26.545 17.001,26.667 16.067,26.667C13.09,26.667 10.556,25.633 8.467,23.567C6.379,21.5 5.334,18.978 5.334,16V15.767L4.134,16.967C3.619,17.482 2.783,17.482 2.267,16.967Z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,16c4.418,0 8,-3.582 8,-8 0,-4.418 -3.582,-8 -8,-8C3.582,0 0,3.582 0,8c0,4.418 3.582,8 8,8Z"
android:strokeWidth="0.5"
android:fillColor="#3e73c4"/>
<path
android:pathData="M10.011,9.062c0,-1.062 -0.64,-1.426 -1.92,-1.578 -0.914,-0.122 -1.097,-0.364 -1.097,-0.789 0,-0.425 0.305,-0.698 0.914,-0.698 0.549,0 0.854,0.182 1.005,0.637 0.016,0.044 0.045,0.082 0.083,0.109 0.038,0.027 0.084,0.042 0.131,0.042h0.488c0.028,0.001 0.056,-0.004 0.082,-0.015 0.026,-0.01 0.05,-0.026 0.07,-0.046 0.02,-0.02 0.036,-0.044 0.046,-0.07 0.011,-0.026 0.016,-0.054 0.015,-0.082v-0.03c-0.06,-0.33 -0.226,-0.63 -0.474,-0.855 -0.248,-0.225 -0.563,-0.362 -0.897,-0.389V4.571c0,-0.122 -0.091,-0.213 -0.243,-0.243h-0.458c-0.122,0 -0.213,0.091 -0.243,0.243V5.269c-0.914,0.121 -1.493,0.728 -1.493,1.487 0,1.001 0.609,1.395 1.889,1.548 0.854,0.152 1.128,0.334 1.128,0.82 0,0.485 -0.426,0.819 -1.005,0.819 -0.793,0 -1.066,-0.333 -1.158,-0.789 -0.03,-0.121 -0.122,-0.182 -0.213,-0.182h-0.518c-0.028,-0.001 -0.056,0.004 -0.082,0.015 -0.026,0.01 -0.05,0.026 -0.07,0.046 -0.02,0.02 -0.036,0.044 -0.046,0.07 -0.01,0.026 -0.016,0.054 -0.015,0.082v0.03c0.122,0.759 0.609,1.305 1.615,1.457v0.729c0,0.121 0.091,0.213 0.243,0.243h0.458c0.122,0 0.213,-0.091 0.243,-0.243V10.67c0.914,-0.152 1.523,-0.789 1.523,-1.609v0.001Z"
android:strokeWidth="0.5"
android:fillColor="#ffffff"/>
<path
android:pathData="M6.446,12.248c-2.377,-0.85 -3.596,-3.49 -2.712,-5.826 0.457,-1.275 1.462,-2.246 2.712,-2.701 0.122,-0.06 0.183,-0.152 0.183,-0.303v-0.425c0,-0.121 -0.06,-0.212 -0.183,-0.243 -0.031,0 -0.091,0 -0.122,0.03 -0.686,0.214 -1.322,0.562 -1.873,1.023 -0.551,0.461 -1.005,1.027 -1.336,1.664 -0.331,0.637 -0.533,1.334 -0.594,2.05 -0.061,0.716 0.02,1.437 0.239,2.121 0.548,1.7 1.859,3.005 3.565,3.551 0.122,0.06 0.244,0 0.274,-0.122 0.031,-0.03 0.031,-0.061 0.031,-0.122v-0.425c0,-0.091 -0.091,-0.212 -0.183,-0.273ZM9.676,2.78c-0.122,-0.061 -0.244,0 -0.274,0.121 -0.031,0.031 -0.031,0.061 -0.031,0.122v0.425c0,0.122 0.091,0.243 0.183,0.303 2.377,0.85 3.596,3.49 2.712,5.826 -0.457,1.275 -1.462,2.246 -2.712,2.701 -0.122,0.06 -0.183,0.152 -0.183,0.303v0.425c0,0.121 0.06,0.212 0.183,0.243 0.031,0 0.091,0 0.122,-0.03 0.686,-0.214 1.322,-0.562 1.873,-1.023 0.551,-0.461 1.005,-1.027 1.336,-1.664 0.331,-0.637 0.533,-1.334 0.594,-2.05 0.061,-0.716 -0.02,-1.437 -0.239,-2.121 -0.548,-1.73 -1.889,-3.035 -3.565,-3.581Z"
android:strokeWidth="0.5"
android:fillColor="#ffffff"/>
</vector>

View file

@ -10,6 +10,7 @@ import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer
import com.tangem.data.pay.repository.*
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase
@ -71,6 +72,10 @@ internal interface TangemPayDataModule {
@Singleton
fun bindCustomerOrderRepository(repository: DefaultCustomerOrderRepository): CustomerOrderRepository
@Binds
@Singleton
fun bindReissueCardRepository(repository: DefaultReissueCardRepository): TangemPayReissueCardRepository
@Binds
@Singleton
fun bindTangemPayCryptoCurrencyFactory(

View file

@ -0,0 +1,104 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.right
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.ReissueCardRequest
import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import com.tangem.domain.models.TangemPayReissueCardFee
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayReissueOrderInfo
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject
internal class DefaultReissueCardRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayReissueCardStore: TangemPayReissueCardStore,
) : TangemPayReissueCardRepository {
override suspend fun getReissueCardFee(userWalletId: UserWalletId): Either<VisaApiError, TangemPayReissueCardFee> =
either {
runSuspendCatching {
tangemPayReissueCardStore.getReissueFee(userWalletId)?.let { return Either.Right(it) }
}
val response = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getFee(
authHeader = authHeader,
type = CARD_REPLACEMENT_FEE_TYPE,
)
}.bind()
val result = response.result
val fee = TangemPayReissueCardFee(
amount = result.amount.toBigDecimal(),
currencyCode = result.currency,
)
runSuspendCatching {
tangemPayReissueCardStore.storeReissueFee(userWalletId, fee)
}
fee
}
override suspend fun reissueCard(
userWalletId: UserWalletId,
cardId: String,
): Either<VisaApiError, TangemPayReissueOrderInfo> = either {
val response = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.reissueCard(
authHeader = authHeader,
body = ReissueCardRequest(cardId = cardId),
)
}.bind()
TangemPayReissueOrderInfo(response.result.orderId, OrderStatus.fromString(response.result.status))
}
override suspend fun storeReissueOrderId(cardId: String, orderId: String): Either<UniversalError, Unit> =
runSuspendCatching {
tangemPayReissueCardStore.storeReissueOrderId(cardId, orderId)
}.fold(
onSuccess = { Unit.right() },
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun getReissueOrderInfo(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayReissueOrderInfo?> = either {
val orderId = runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull()
if (orderId == null) {
return null.right()
}
val order = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getOrder(authHeader, orderId)
}.bind()
val result = order.result ?: raise(VisaApiError.Unspecified)
TangemPayReissueOrderInfo(
orderId = result.id,
orderStatus = when (result.status) {
OrderResponse.Result.Status.NEW -> OrderStatus.NEW
OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING
OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED
OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED
},
)
}
private companion object {
const val CARD_REPLACEMENT_FEE_TYPE = "CARD_REPLACEMENT"
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.models
import java.math.BigDecimal
data class TangemPayReissueCardFee(
val amount: BigDecimal,
val currencyCode: String,
)

View file

@ -1,9 +1,22 @@
package com.tangem.domain.pay.model
import java.util.Locale
enum class OrderStatus {
UNKNOWN, // TODO remove it after TangemPay accounts refactor TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED
NEW,
PROCESSING,
COMPLETED,
CANCELED,
;
companion object {
fun fromString(value: String) = when (value.uppercase(Locale.US)) {
"NEW" -> NEW
"PROCESSING" -> PROCESSING
"COMPLETED" -> COMPLETED
"CANCELED" -> CANCELED
else -> UNKNOWN
}
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.domain.pay.model
data class TangemPayReissueOrderInfo(
val orderId: String,
val orderStatus: OrderStatus,
)

View file

@ -0,0 +1,22 @@
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.models.TangemPayReissueCardFee
import com.tangem.domain.pay.model.TangemPayReissueOrderInfo
import com.tangem.domain.visa.error.VisaApiError
interface TangemPayReissueCardRepository {
suspend fun getReissueCardFee(userWalletId: UserWalletId): Either<VisaApiError, TangemPayReissueCardFee>
suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either<VisaApiError, TangemPayReissueOrderInfo>
suspend fun storeReissueOrderId(cardId: String, orderId: String): Either<UniversalError, Unit>
suspend fun getReissueOrderInfo(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayReissueOrderInfo?>
}

View file

@ -192,6 +192,21 @@ sealed class TangemPayAnalyticsEvents(
event = "Visa KYC Canceled",
)
class ReplaceCardClicked : TangemPayAnalyticsEvents(
categoryName = "Visa Screen",
event = "Visa Replace Card Clicked",
)
class ReplaceCardConfirmationPopupOpened : TangemPayAnalyticsEvents(
categoryName = "Visa Screen",
event = "Visa Replace Card Confirmation Popup Opened",
)
class ReplaceCardConfirmed : TangemPayAnalyticsEvents(
categoryName = "Visa Screen",
event = "Visa Replace Card Confirmed",
)
class MainVisaPermanentBannerClicked : TangemPayAnalyticsEvents(
categoryName = "Visa Onboarding",
event = "Visa Permanent Banner Clicked",

View file

@ -14,6 +14,7 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -21,6 +22,7 @@ import dagger.assisted.AssistedInject
internal class DefaultTangemPayCardPageComponent @AssistedInject constructor(
@Assisted private val appComponentContext: AppComponentContext,
@Assisted private val params: TangemPayCardPageComponent.Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
) : AppComponentContext by appComponentContext, TangemPayCardPageComponent {
private val stackNavigation = StackNavigation<TangemPayDetailsInnerRoute>()
@ -56,6 +58,7 @@ internal class DefaultTangemPayCardPageComponent @AssistedInject constructor(
TangemPayDetailsInnerRoute.Details -> TangemPayCardPageScreenComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = params,
tokenReceiveComponentFactory = tokenReceiveComponentFactory,
)
TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
@ -19,10 +20,12 @@ import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetails
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
import com.tangem.features.tangempay.model.TangemPayCardPageModel
import com.tangem.features.tangempay.ui.TangemPayCardPageScreen
import com.tangem.features.tokenreceive.TokenReceiveComponent
internal class TangemPayCardPageScreenComponent(
private val appComponentContext: AppComponentContext,
private val params: TangemPayCardPageComponent.Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val model: TangemPayCardPageModel = getOrCreateModel(params = params)
@ -57,7 +60,9 @@ internal class TangemPayCardPageScreenComponent(
TangemPayCardPageScreen(
state = state,
cardDetailsBlockComponent = cardDetailsBlockComponent,
cardDetailsState = cardDetailsState,
cardDetailsState = cardDetailsState.copy(
isActive = !state.isReissueInProgress,
),
modifier = modifier,
)
bottomSheet.child?.instance?.BottomSheet()
@ -77,6 +82,32 @@ internal class TangemPayCardPageScreenComponent(
listener = model,
),
)
is TangemPayDetailsNavigation.ReissueCard -> TangemPayReissueCardComponent(
appComponentContext = context,
params = TangemPayReissueCardComponent.Params(
listener = model,
userWalletId = params.userWalletId,
cardId = params.config.cardId,
),
)
is TangemPayDetailsNavigation.AddFunds -> TangemPayAddFundsComponent(
appComponentContext = context,
params = TangemPayAddFundsComponent.Params(
listener = model,
walletId = navigation.walletId,
cryptoBalance = navigation.cryptoBalance,
fiatBalance = navigation.fiatBalance,
depositAddress = navigation.depositAddress,
chainId = navigation.chainId,
),
)
is TangemPayDetailsNavigation.Receive -> tokenReceiveComponentFactory.create(
context = context,
params = TokenReceiveComponent.Params(
config = navigation.config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
else -> error("Unsupported bottom sheet navigation: $navigation")
}
}

View file

@ -130,6 +130,7 @@ internal class TangemPayDetailsComponent(
listener = model,
),
)
else -> error("Unsupported bottom sheet navigation: $navigation")
}
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.tangempay.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.TangemPayReissueOrderInfo
import com.tangem.features.tangempay.model.TangemPayReissueCardModel
import com.tangem.features.tangempay.ui.TangemPayReissueCardContent
internal class TangemPayReissueCardComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
private val model: TangemPayReissueCardModel = getOrCreateModel(params = params)
override fun dismiss() = model.onDismiss()
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
TangemPayReissueCardContent(state = state)
}
data class Params(
val listener: ReissueCardListener,
val userWalletId: UserWalletId,
val cardId: String,
)
}
internal interface ReissueCardListener {
fun onReissueOrderCreate(order: TangemPayReissueOrderInfo)
fun onDismissReissueCard()
fun onClickAddFunds()
}

View file

@ -11,6 +11,7 @@ import com.tangem.features.tangempay.model.TangemPayDetailsModel
import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel
import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel
import com.tangem.features.tangempay.model.TangemPayTxHistoryModel
import com.tangem.features.tangempay.model.TangemPayReissueCardModel
import com.tangem.features.tangempay.model.TangemPayViewPinModel
import dagger.Binds
import dagger.Module
@ -71,4 +72,9 @@ internal interface TangemPayModelModule {
@IntoMap
@ClassKey(TangemPayEditDisplayNameModel::class)
fun bindTangemPayEditDisplayNameModel(model: TangemPayEditDisplayNameModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayReissueCardModel::class)
fun bindTangemPayReissueCardModel(model: TangemPayReissueCardModel): Model
}

View file

@ -1,39 +1,37 @@
package com.tangem.features.tangempay.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
internal data class TangemPayCardPageUM(
val addToWalletBlockState: AddToWalletBlockState? = null,
val settings: ImmutableList<TangemPayCardPageSetting> = persistentListOf(
TangemPayCardPageSetting.ChangePIN,
TangemPayCardPageSetting.FreezeCard,
),
val settings: ImmutableList<TangemPayCardPageSetting>,
val onBackClick: () -> Unit,
val onSettingClick: (TangemPayCardPageSetting) -> Unit,
val addToWalletBlockState: AddToWalletBlockState? = null,
val isReissueInProgress: Boolean = false,
) {
companion object {
fun stub(
addToWalletBlockState: AddToWalletBlockState? = AddToWalletBlockState(onClick = {}, onClickClose = {}),
settings: ImmutableList<TangemPayCardPageSetting> = persistentListOf(
TangemPayCardPageSetting.ChangePIN,
TangemPayCardPageSetting.FreezeCard,
TangemPayCardPageSetting.ReplaceCard,
TangemPayCardPageSetting(TextReference.Str("Pin Code")) {},
TangemPayCardPageSetting(TextReference.Str("Freeze Card")) {},
TangemPayCardPageSetting(TextReference.Str("Reissue Card")) {},
),
isReissueInProgress: Boolean = false,
) = TangemPayCardPageUM(
addToWalletBlockState = addToWalletBlockState,
settings = settings,
onBackClick = {},
onSettingClick = {},
isReissueInProgress = isReissueInProgress,
)
}
}
@Immutable
internal sealed class TangemPayCardPageSetting {
data object ChangePIN : TangemPayCardPageSetting()
data object FreezeCard : TangemPayCardPageSetting()
data object ReplaceCard : TangemPayCardPageSetting()
}
internal data class TangemPayCardPageSetting(
val title: TextReference,
val onSettingClick: () -> Unit,
)

View file

@ -32,4 +32,7 @@ internal sealed class TangemPayDetailsNavigation {
val userWalletId: UserWalletId,
val cardId: String,
) : TangemPayDetailsNavigation()
@Serializable
data object ReissueCard : TangemPayDetailsNavigation()
}

View file

@ -31,6 +31,7 @@ internal data class TangemPayCardDetailsUM(
val isLoading: Boolean = false,
val cardFrozenState: TangemPayCardFrozenState,
val displayNameState: DisplayNameState?,
val isActive: Boolean = true,
)
internal sealed interface DisplayNameState {

View file

@ -0,0 +1,37 @@
package com.tangem.features.tangempay.entity
import androidx.compose.runtime.Immutable
@Immutable
internal data class TangemPayReissueCardUM(
val feeAmount: String,
val isFeeLoading: Boolean,
val isReissuingInProgress: Boolean,
val error: TangemPayReissueCardError?,
val onConfirmClick: () -> Unit,
val onRetryFee: () -> Unit,
val onAddFundsClick: () -> Unit,
val onDismissRequest: () -> Unit,
) {
companion object {
fun stub(
feeAmount: String = "$4.25",
isFeeLoading: Boolean = false,
error: TangemPayReissueCardError = TangemPayReissueCardError.InitialDataLoading,
isReissuingInProgress: Boolean = false,
) = TangemPayReissueCardUM(
feeAmount = feeAmount,
isFeeLoading = isFeeLoading,
error = error,
isReissuingInProgress = isReissuingInProgress,
onConfirmClick = {},
onRetryFee = {},
onAddFundsClick = {},
onDismissRequest = {},
)
}
}
internal enum class TangemPayReissueCardError {
InsufficientFunds, InitialDataLoading
}

View file

@ -4,15 +4,27 @@ import androidx.compose.runtime.Stable
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayReissueOrderInfo
import com.tangem.domain.pay.model.TangemPayTopUpData
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.components.AddFundsListener
import com.tangem.features.tangempay.components.ReissueCardListener
import com.tangem.features.tangempay.components.TangemPayCardPageComponent
import com.tangem.features.tangempay.components.ViewPinListener
import com.tangem.features.tangempay.details.impl.R
@ -25,6 +37,7 @@ import com.tangem.features.tangempay.utils.TangemPayMessagesFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
@ -33,43 +46,55 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class TangemPayCardPageModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val analytics: AnalyticsEventHandler,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val uiMessageSender: UiMessageSender,
) : Model(), ViewPinListener {
private val reissueCardRepository: TangemPayReissueCardRepository,
) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener {
private val params: TangemPayCardPageComponent.Params = paramsContainer.require()
private var currentFrozenState: TangemPayCardFrozenState = params.config.cardFrozenState
private val addToWalletBannerJobHolder = JobHolder()
private val addFundsJobHolder = JobHolder()
val uiState: StateFlow<TangemPayCardPageUM>
field = MutableStateFlow(
TangemPayCardPageUM(
onBackClick = router::pop,
onSettingClick = ::onSettingClick,
settings = persistentListOf(
TangemPayCardPageSetting(
title = TextReference.Res(R.string.tangempay_card_details_change_pin),
onSettingClick = ::onClickChangePIN,
),
TangemPayCardPageSetting(
title = TextReference.Res(R.string.tangempay_card_details_freeze_card),
onSettingClick = ::onClickFreezeOrUnfreezeCard,
),
TangemPayCardPageSetting(
title = TextReference.Res(R.string.tangempay_card_details_reissue_card),
onSettingClick = ::onClickReissueCard,
),
),
),
)
val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation()
init {
// TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed
fetchAddToWalletBanner()
subscribeToCardFrozenState()
}
private fun onSettingClick(setting: TangemPayCardPageSetting) = when (setting) {
TangemPayCardPageSetting.ChangePIN -> onClickChangePIN()
TangemPayCardPageSetting.FreezeCard -> onClickFreezeOrUnfreezeCard()
TangemPayCardPageSetting.ReplaceCard -> Unit // TODO v_rodionov #[REDACTED_TASK_KEY]
}
private fun onClickChangePIN() {
if (!params.config.isPinSet) {
router.push(TangemPayDetailsInnerRoute.ChangePIN)
@ -94,6 +119,82 @@ internal class TangemPayCardPageModel @Inject constructor(
}
}
private fun onClickReissueCard() {
analytics.send(TangemPayAnalyticsEvents.ReplaceCardClicked())
bottomSheetNavigation.activate(TangemPayDetailsNavigation.ReissueCard)
}
override fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) {
bottomSheetNavigation.dismiss()
onReissueOrderStatusReceived(order.orderStatus)
if (order.orderStatus != OrderStatus.CANCELED) {
modelScope.launch {
reissueCardRepository.storeReissueOrderId(params.config.cardId, order.orderId)
}
} else {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong)))
}
}
override fun onDismissReissueCard() {
bottomSheetNavigation.dismiss()
}
override fun onClickAddFunds() {
bottomSheetNavigation.dismiss()
modelScope.launch {
val balance = cardDetailsRepository.getCardBalance(params.userWalletId).getOrNull()
val depositAddress = balance?.depositAddress
if (balance == null || depositAddress == null) {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_error)))
return@launch
}
bottomSheetNavigation.activate(
TangemPayDetailsNavigation.AddFunds(
walletId = params.userWalletId,
fiatBalance = balance.fiatBalance,
cryptoBalance = balance.cryptoBalance,
depositAddress = depositAddress,
chainId = params.config.chainId,
),
)
}.saveIn(addFundsJobHolder)
}
override fun onClickReceive(data: TangemPayTopUpData) {
bottomSheetNavigation.dismiss()
val config = TokenReceiveConfig(
shouldShowWarning = true,
cryptoCurrency = data.currency,
userWalletId = data.walletId,
showMemoDisclaimer = false,
receiveAddress = data.receiveAddress,
)
bottomSheetNavigation.activate(TangemPayDetailsNavigation.Receive(config))
}
override fun onClickSwap(data: TangemPayTopUpData) {
bottomSheetNavigation.dismiss()
router.push(
AppRoute.Swap(
currencyFrom = data.currency,
userWalletId = data.walletId,
isInitialReverseOrder = true,
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
tangemPayInput = AppRoute.Swap.TangemPayInput(
cryptoAmount = data.cryptoBalance,
fiatAmount = data.fiatBalance,
depositAddress = data.depositAddress,
isWithdrawal = false,
),
),
)
}
override fun onDismissAddFunds() {
bottomSheetNavigation.dismiss()
}
private fun freezeCard() {
modelScope.launch {
cardDetailsRepository.freezeCard(
@ -174,4 +275,18 @@ internal class TangemPayCardPageModel @Inject constructor(
override fun onDismissViewPin() {
bottomSheetNavigation.dismiss()
}
private fun onReissueOrderStatusReceived(orderStatus: OrderStatus) {
when (orderStatus) {
OrderStatus.NEW, OrderStatus.PROCESSING, OrderStatus.COMPLETED, OrderStatus.UNKNOWN -> {
uiState.update { state ->
state.copy(
addToWalletBlockState = null,
isReissueInProgress = true,
)
}
}
OrderStatus.CANCELED -> Unit
}
}
}

View file

@ -0,0 +1,119 @@
package com.tangem.features.tangempay.model
import androidx.compose.runtime.Stable
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.components.TangemPayReissueCardComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayReissueCardError
import com.tangem.features.tangempay.entity.TangemPayReissueCardUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayReissueCardModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val reissueCardRepository: TangemPayReissueCardRepository,
private val uiMessageSender: UiMessageSender,
private val analytics: AnalyticsEventHandler,
) : Model() {
private val params = paramsContainer.require<TangemPayReissueCardComponent.Params>()
private val reissueJobHolder = JobHolder()
private val loadDataJobHolder = JobHolder()
val state: StateFlow<TangemPayReissueCardUM>
field = MutableStateFlow(
TangemPayReissueCardUM(
feeAmount = "",
isFeeLoading = true,
error = null,
isReissuingInProgress = false,
onConfirmClick = ::onConfirm,
onRetryFee = ::loadData,
onAddFundsClick = { params.listener.onClickAddFunds() },
onDismissRequest = ::onDismiss,
),
)
init {
analytics.send(TangemPayAnalyticsEvents.ReplaceCardConfirmationPopupOpened())
loadData()
}
fun onDismiss() {
reissueJobHolder.cancel()
params.listener.onDismissReissueCard()
}
private fun onConfirm() {
analytics.send(TangemPayAnalyticsEvents.ReplaceCardConfirmed())
state.update { it.copy(isReissuingInProgress = true) }
modelScope.launch {
reissueCardRepository.reissueCard(
userWalletId = params.userWalletId,
cardId = params.cardId,
).onLeft {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong)))
onDismiss()
}.onRight { order ->
params.listener.onReissueOrderCreate(order)
}
}.saveIn(reissueJobHolder)
}
private fun loadData() {
state.update { it.copy(isFeeLoading = true, error = null) }
modelScope.launch {
val (cardBalance, fee) = coroutineScope {
val balanceDeferred = async { cardDetailsRepository.getCardBalance(params.userWalletId).getOrNull() }
val feeDeferred = async { reissueCardRepository.getReissueCardFee(params.userWalletId).getOrNull() }
balanceDeferred.await() to feeDeferred.await()
}
val error = if (fee == null || cardBalance == null) {
TangemPayReissueCardError.InitialDataLoading
} else if (cardBalance.availableForWithdrawal < fee.amount) {
TangemPayReissueCardError.InsufficientFunds
} else {
null
}
state.update { state ->
state.copy(
feeAmount = fee?.let {
fee.amount.format {
val symbol = getJavaCurrencyByCode(fee.currencyCode).symbol
fiat(fee.currencyCode, symbol)
}
}.orEmpty(),
isFeeLoading = false,
error = error,
)
}
}.saveIn(loadDataJobHolder)
}
}

View file

@ -12,12 +12,27 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -27,8 +42,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
@ -45,6 +58,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
@ -53,12 +68,12 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.DisplayNameState
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.model.CardDataType
import com.tangem.domain.models.account.CardDisplayName
private const val ICON_FADE_DURATION_MS = 300
private val CustomCardBlockColor = Color(0x1F828282)
@ -101,7 +116,7 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M
shape = RoundedCornerShape(16.dp),
),
) {
if (shouldShowDetails) {
if (shouldShowDetails && state.isActive) {
TangemPayCardDetailsShownBlock(
cardNumber = state.number,
expiry = state.expiry,
@ -114,11 +129,7 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M
)
} else {
TangemPayCardDetailsHiddenBlock(
cardFrozenState = state.cardFrozenState,
isLoading = state.isLoading,
shortCardNumber = state.numberShort,
onShowDetails = state.onClick,
displayNameState = state.displayNameState,
state = state,
)
}
}
@ -126,16 +137,9 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M
@Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries")
@Composable
private fun TangemPayCardDetailsHiddenBlock(
shortCardNumber: String,
cardFrozenState: TangemPayCardFrozenState,
isLoading: Boolean,
onShowDetails: () -> Unit,
displayNameState: DisplayNameState?,
modifier: Modifier = Modifier,
) {
private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize()) {
val imageResId = when (cardFrozenState) {
val imageResId = when (state.cardFrozenState) {
is TangemPayCardFrozenState.Frozen -> R.drawable.img_tangem_pay_visa_frozen
else -> R.drawable.img_tangem_pay_visa
}
@ -144,75 +148,78 @@ private fun TangemPayCardDetailsHiddenBlock(
painter = painterResource(id = imageResId),
contentDescription = null,
)
ConstraintLayout(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
.fillMaxWidth(),
) {
val (displayNameRef, cardNumberRef, frozenIconRef, buttonRef) = createRefs()
if (displayNameState != null) {
CardDisplayName(
state = displayNameState,
modifier = Modifier.constrainAs(displayNameRef) {
start.linkTo(parent.start)
bottom.linkTo(cardNumberRef.top)
width = Dimension.wrapContent
},
)
}
Text(
text = shortCardNumber,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.constantWhite,
if (state.isActive) {
ConstraintLayout(
modifier = Modifier
.constrainAs(cardNumberRef) {
start.linkTo(parent.start)
bottom.linkTo(parent.bottom)
}
.padding(bottom = 8.dp),
)
when (cardFrozenState) {
is TangemPayCardFrozenState.Frozen -> Icon(
modifier = Modifier
.constrainAs(frozenIconRef) {
start.linkTo(cardNumberRef.end, margin = 4.dp)
top.linkTo(cardNumberRef.top)
bottom.linkTo(cardNumberRef.bottom)
}
.padding(bottom = 8.dp)
.size(16.dp),
painter = painterResource(id = R.drawable.ic_snow_24),
contentDescription = null,
tint = TangemTheme.colors.icon.constant,
)
TangemPayCardFrozenState.Pending -> CircularProgressIndicator(
modifier = Modifier
.constrainAs(frozenIconRef) {
start.linkTo(cardNumberRef.end, margin = 4.dp)
top.linkTo(cardNumberRef.top)
bottom.linkTo(cardNumberRef.bottom)
}
.padding(bottom = 8.dp)
.size(16.dp),
color = TangemTheme.colors.text.constantWhite,
strokeWidth = 1.dp,
)
TangemPayCardFrozenState.Unfrozen -> Unit
}
.align(Alignment.BottomCenter)
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
.fillMaxWidth(),
) {
val (displayNameRef, cardNumberRef, frozenIconRef, buttonRef) = createRefs()
TangemPayCardDetailsCustomButton(
modifier = Modifier.constrainAs(buttonRef) {
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
text = stringResourceSafe(id = R.string.tangempay_card_details_show_details),
onClick = onShowDetails,
showProgress = isLoading,
)
if (state.displayNameState != null) {
CardDisplayName(
state = state.displayNameState,
modifier = Modifier.constrainAs(displayNameRef) {
start.linkTo(parent.start)
bottom.linkTo(cardNumberRef.top)
width = Dimension.wrapContent
},
)
}
Text(
text = state.numberShort,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.constantWhite,
modifier = Modifier
.constrainAs(cardNumberRef) {
start.linkTo(parent.start)
bottom.linkTo(parent.bottom)
}
.padding(bottom = 8.dp),
)
when (state.cardFrozenState) {
is TangemPayCardFrozenState.Frozen -> Icon(
modifier = Modifier
.constrainAs(frozenIconRef) {
start.linkTo(cardNumberRef.end, margin = 4.dp)
top.linkTo(cardNumberRef.top)
bottom.linkTo(cardNumberRef.bottom)
}
.padding(bottom = 8.dp)
.size(16.dp),
painter = painterResource(id = R.drawable.ic_snow_24),
contentDescription = null,
tint = TangemTheme.colors.icon.constant,
)
TangemPayCardFrozenState.Pending -> CircularProgressIndicator(
modifier = Modifier
.constrainAs(frozenIconRef) {
start.linkTo(cardNumberRef.end, margin = 4.dp)
top.linkTo(cardNumberRef.top)
bottom.linkTo(cardNumberRef.bottom)
}
.padding(bottom = 8.dp)
.size(16.dp),
color = TangemTheme.colors.text.constantWhite,
strokeWidth = 1.dp,
)
TangemPayCardFrozenState.Unfrozen -> Unit
}
TangemPayCardDetailsCustomButton(
modifier = Modifier.constrainAs(buttonRef) {
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
text = stringResourceSafe(id = R.string.tangempay_card_details_show_details),
onClick = state.onClick,
showProgress = state.isLoading,
)
}
}
}
}

View file

@ -32,6 +32,10 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -46,12 +50,6 @@ import com.tangem.features.tangempay.entity.TangemPayCardPageUM
import kotlinx.collections.immutable.ImmutableList
private const val CONTENT_FADE_DURATION_MS = 300
private val TangemPayCardPageSetting.titleRes
get() = when (this) {
TangemPayCardPageSetting.ChangePIN -> R.string.tangempay_card_details_change_pin
TangemPayCardPageSetting.FreezeCard -> R.string.tangempay_card_details_freeze_card
TangemPayCardPageSetting.ReplaceCard -> R.string.common_error // TODO v_rodionov #[REDACTED_TASK_KEY]
}
@Composable
internal fun TangemPayCardPageScreen(
@ -110,20 +108,35 @@ internal fun TangemPayCardPageScreen(
enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)),
exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)),
) {
TangemPayCardPageSettingsBlock(
settings = state.settings,
onSettingClick = state.onSettingClick,
)
if (state.isReissueInProgress) {
TangemPayReplacingCardBlock()
} else {
TangemPayCardPageSettingsBlock(
settings = state.settings,
)
}
}
}
}
}
}
@Composable
private fun TangemPayReplacingCardBlock(modifier: Modifier = Modifier) {
Notification(
modifier = modifier,
config = NotificationConfig(
iconResId = com.tangem.core.ui.R.drawable.ic_update_32,
iconTint = NotificationConfig.IconTint.Accent,
title = resourceReference(R.string.tangempay_reissue_card_in_progress),
subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
),
)
}
@Composable
private fun TangemPayCardPageSettingsBlock(
settings: ImmutableList<TangemPayCardPageSetting>,
onSettingClick: (TangemPayCardPageSetting) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -147,7 +160,7 @@ private fun TangemPayCardPageSettingsBlock(
settings.fastForEach { item ->
TangemPayCardPageSettingRow(
item = item,
onClick = { onSettingClick(item) },
onClick = item.onSettingClick,
)
}
}
@ -167,7 +180,7 @@ private fun TangemPayCardPageSettingRow(
contentAlignment = Alignment.CenterStart,
) {
Text(
text = stringResourceSafe(item.titleRes),
text = item.title.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)

View file

@ -0,0 +1,218 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayReissueCardError
import com.tangem.features.tangempay.entity.TangemPayReissueCardUM
@Composable
internal fun TangemPayReissueCardContent(state: TangemPayReissueCardUM) {
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = state.onDismissRequest,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors.background.tertiary,
onBack = state.onDismissRequest,
title = {
TangemModalBottomSheetTitle(
title = TextReference.EMPTY,
endIconRes = R.drawable.ic_close_24,
onEndClick = state.onDismissRequest,
)
},
) {
Content(state)
}
}
@Composable
private fun Content(state: TangemPayReissueCardUM) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(TangemTheme.dimens.spacing56)
.clip(CircleShape)
.background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_update_32),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
modifier = Modifier.size(32.dp),
)
}
SpacerH24()
Text(
text = stringResourceSafe(R.string.tangempay_reissue_card_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
SpacerH8()
Text(
text = stringResourceSafe(R.string.tangempay_reissue_card_description),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
SpacerH24()
FeeBlock(state)
if (state.error != null) {
SpacerH16()
ErrorBlock(
error = state.error,
onRetryFee = state.onRetryFee,
onAddFundsClick = state.onAddFundsClick,
)
}
SpacerH24()
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.tangempay_reissue_card_confirm),
enabled = state.error == null && !state.isFeeLoading,
showProgress = state.isReissuingInProgress,
onClick = state.onConfirmClick,
)
SpacerH16()
}
}
@Composable
private fun FeeBlock(state: TangemPayReissueCardUM) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersMedium,
)
.padding(TangemTheme.dimens.spacing12),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResourceSafe(R.string.tangempay_reissue_card_fee_label),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
AnimatedContent(
targetState = when {
state.isFeeLoading -> null
state.error == TangemPayReissueCardError.InitialDataLoading -> ""
else -> state.feeAmount
},
) { fee ->
if (fee != null) {
Text(
text = fee,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
)
} else {
TextShimmer(style = TangemTheme.typography.body1, text = "$0.00")
}
}
}
}
@Composable
private fun ErrorBlock(error: TangemPayReissueCardError, onAddFundsClick: () -> Unit, onRetryFee: () -> Unit) {
when (error) {
TangemPayReissueCardError.InsufficientFunds -> Notification(
config = NotificationConfig(
title = resourceReference(R.string.tangempay_reissue_card_insufficient_funds_title),
subtitle = resourceReference(R.string.tangempay_reissue_card_insufficient_funds_subtitle),
iconResId = R.drawable.img_usdc_16,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.tangempay_card_details_add_funds),
iconResId = R.drawable.ic_plus_24,
onClick = onAddFundsClick,
),
),
containerColor = TangemTheme.colors.background.action,
modifier = Modifier.fillMaxWidth(),
)
TangemPayReissueCardError.InitialDataLoading -> Notification(
config = NotificationConfig(
title = resourceReference(R.string.tangempay_reissue_card_fee_unreachable_error_title),
subtitle = resourceReference(R.string.send_fee_unreachable_error_text),
iconResId = R.drawable.img_attention_20,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = onRetryFee,
),
),
containerColor = TangemTheme.colors.background.action,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun preview() = TangemThemePreview {
TangemPayReissueCardContent(
state = TangemPayReissueCardUM.stub(),
)
}