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

@ -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(),
)
}