Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-13 16:20:59 +04:00
parent 23264bc7f0
commit 8c37dbd5b0
31 changed files with 422 additions and 121 deletions

View file

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

View file

@ -25,6 +25,7 @@ dependencies {
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.nft.models)
implementation(projects.domain.feedback.models)
implementation(projects.domain.visa.models)
/* Libs - Other */
api(deps.kotlin.serialization)

View file

@ -18,6 +18,7 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.pay.TangemPayDetailsConfig
import kotlinx.serialization.Serializable
@SuppressLint("UnsafeOptInUsageError")
@ -388,8 +389,7 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class TangemPayDetails(
val customerWalletAddress: String,
val cardNumberEnd: String,
val config: TangemPayDetailsConfig,
) : AppRoute(path = "/tangem_pay_details")
@Serializable

View file

@ -17,6 +17,7 @@ data class CustomerMeResponse(
@Json(name = "product_instance") val productInstance: ProductInstance?,
@Json(name = "payment_account") val paymentAccount: PaymentAccount?,
@Json(name = "kyc") val kyc: Kyc?,
@Json(name = "depositAddress") val depositAddress: String?,
@Json(name = "card") val card: Card?,
@Json(name = "balance") val balance: Balance?,
)

View file

@ -1,5 +1,6 @@
package com.tangem.data.common.currency
import com.tangem.blockchain.blockchains.ethereum.Chain
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
@ -73,6 +74,17 @@ class CryptoCurrencyFactory(
)
}
fun createCoin(chainId: Int, extraDerivationPath: String?, userWallet: UserWallet): CryptoCurrency.Coin? {
val blockchain: Blockchain? = Chain.entries.find { it.id == chainId }?.blockchain
return if (blockchain != null) {
createCoin(blockchain, extraDerivationPath, userWallet)
} else {
Timber.e("Unable to get blockchain from chainId == $chainId")
null
}
}
fun createCoin(
blockchain: Blockchain,
extraDerivationPath: String?,

View file

@ -0,0 +1,66 @@
package com.tangem.data.pay
import arrow.core.Either
import com.squareup.moshi.Moshi
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.error.UniversalError
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.ReceiveAddressModel.NameService
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.pay.DataForReceive
import com.tangem.domain.pay.DataForReceiveFactory
import kotlinx.coroutines.CancellationException
import timber.log.Timber
import javax.inject.Inject
private const val TAG = "TangemPay: TokenReceiveConfigFactory"
internal class DefaultDataForReceiveFactory @Inject constructor(
@NetworkMoshi moshi: Moshi,
private val tangemPayWalletsManager: TangemPayWalletsManager,
excludedBlockchains: ExcludedBlockchains,
) : DataForReceiveFactory {
private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
CryptoCurrencyFactory(excludedBlockchains)
}
private val errorConverter by lazy(mode = LazyThreadSafetyMode.NONE) { TangemPayErrorConverter(moshi) }
override fun getDataForReceive(depositAddress: String, chainId: Int): Either<UniversalError, DataForReceive> {
return try {
val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPayBlocking()
/**
* Create [CryptoCurrency.Coin] only for F&F.
* Later will use [CryptoCurrency.Token] when contractAddresses will be provided by BFF.
*/
val currency = cryptoCurrencyFactory.createCoin(
chainId = chainId,
extraDerivationPath = null,
userWallet = wallet,
) ?: error("Cannot create crypto currency from chainId $chainId")
val result = DataForReceive(
currency = currency,
walletId = wallet.walletId,
receiveAddress = listOf(ReceiveAddressModel(nameService = NameService.Default, value = depositAddress)),
)
Either.Right(result)
} catch (exception: Exception) {
when (exception) {
is CancellationException -> {
throw exception
}
else -> {
Timber.tag(TAG).e(exception)
Either.Left(errorConverter.convert(exception))
}
}
}
}
}

View file

@ -1,9 +1,11 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.DefaultDataForReceiveFactory
import com.tangem.data.pay.repository.DefaultCardDetailsRepository
import com.tangem.data.pay.repository.DefaultKycRepository
import com.tangem.data.pay.repository.DefaultTangemPayTxHistoryRepository
import com.tangem.data.pay.repository.DefaultOnboardingRepository
import com.tangem.domain.pay.DataForReceiveFactory
import com.tangem.domain.pay.repository.CardDetailsRepository
import com.tangem.domain.pay.repository.KycRepository
import com.tangem.domain.pay.repository.OnboardingRepository
@ -37,6 +39,10 @@ internal interface TangemPayDataModule {
@Singleton
fun bindCardDetailsRepository(repository: DefaultCardDetailsRepository): CardDetailsRepository
@Binds
@Singleton
fun bindDataForReceiveFactory(factory: DefaultDataForReceiveFactory): DataForReceiveFactory
companion object {
@Provides
@Singleton

View file

@ -102,6 +102,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
balance = balance.availableBalance,
currencyCode = balance.currency,
customerWalletAddress = paymentAccount.customerWalletAddress,
depositAddress = response.depositAddress,
)
} else {
null

View file

@ -5,27 +5,19 @@ import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.error.UniversalError
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.data.pay.util.TangemPayWalletsManager
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
@ -37,9 +29,7 @@ internal class TangemPayRequestPerformer @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val tangemPayStorage: TangemPayStorage,
private val authDataSource: TangemPayAuthDataSource,
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val tangemPayWalletsManager: TangemPayWalletsManager,
private val walletManagersFacade: WalletManagersFacade,
private val networkFactory: NetworkFactory,
) {
@ -49,7 +39,7 @@ internal class TangemPayRequestPerformer @Inject constructor(
private val refreshTokensMutex = Mutex()
private var refreshTokensJob: Deferred<VisaAuthTokens>? = null
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
private val errorConverter = TangemPayErrorConverter(moshi)
suspend fun <T : Any> runWithErrorLogs(tag: String, requestBlock: suspend () -> T): Either<UniversalError, T> {
return try {
@ -62,7 +52,7 @@ internal class TangemPayRequestPerformer @Inject constructor(
}
else -> {
Timber.tag(tag).e(exception)
Either.Left(mapError(exception))
Either.Left(errorConverter.convert(exception))
}
}
}
@ -86,12 +76,6 @@ internal class TangemPayRequestPerformer @Inject constructor(
)
}
private fun getWallets(): Flow<List<UserWallet>> = if (hotWalletFeatureToggles.isHotWalletEnabled) {
userWalletsListRepository.userWallets.map { requireNotNull(it) }
} else {
userWalletsListManager.userWallets
}
private suspend fun <T : Any> performRequest(
requestBlock: suspend (header: String) -> ApiResponse<T>,
getTokens: (suspend () -> VisaAuthTokens),
@ -143,11 +127,7 @@ internal class TangemPayRequestPerformer @Inject constructor(
}
private suspend fun fetchAuthInputData(): AuthInputData {
val userWallets = getWallets()
.filter { it.isNotEmpty() }
.first()
val wallet = userWallets.find { it is UserWallet.Cold } as? UserWallet.Cold
?: error("Cannot find cold user wallet")
val wallet = tangemPayWalletsManager.getDefaultWalletForTangemPay()
val network = networkFactory.create(
blockchain = Blockchain.Polygon,
@ -179,21 +159,6 @@ internal class TangemPayRequestPerformer @Inject constructor(
tangemPayStorage.storeAuthTokens(customerWalletAddress, tokens)
return tokens
}
private fun mapError(throwable: Throwable): UniversalError {
return if (throwable is ApiResponseError.HttpException) {
val errorBody = throwable.errorBody ?: return VisaApiError.UnknownWithoutCode
return runCatching {
visaErrorAdapter.fromJson(errorBody)?.error?.code ?: throwable.code.numericCode
}.map {
VisaApiError.fromBackendError(it)
}.getOrElse {
VisaApiError.UnknownWithoutCode
}
} else {
VisaApiError.UnknownWithoutCode
}
}
}
internal data class AuthInputData(

View file

@ -0,0 +1,28 @@
package com.tangem.data.pay.util
import com.squareup.moshi.Moshi
import com.tangem.core.error.UniversalError
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.converter.Converter
class TangemPayErrorConverter(moshi: Moshi) : Converter<Throwable, UniversalError> {
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
override fun convert(value: Throwable): UniversalError {
return if (value is ApiResponseError.HttpException) {
val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode
return runCatching {
visaErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode
}.map {
VisaApiError.fromBackendError(it)
}.getOrElse {
VisaApiError.UnknownWithoutCode
}
} else {
VisaApiError.UnknownWithoutCode
}
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.data.pay.util
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import javax.inject.Inject
internal class TangemPayWalletsManager @Inject constructor(
private val manager: UserWalletsListManager,
private val repository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) {
suspend fun getDefaultWalletForTangemPay(): UserWallet.Cold {
val userWalletsFlow = if (useNewRepository()) repository.userWallets else manager.userWallets
val userWallets = userWalletsFlow.filter { !it.isNullOrEmpty() }.first()
return findColdWallet(userWallets)
}
fun getDefaultWalletForTangemPayBlocking(): UserWallet.Cold {
val userWallets = if (useNewRepository()) repository.userWallets.value else manager.userWalletsSync
return findColdWallet(userWallets)
}
private fun useNewRepository(): Boolean = hotWalletFeatureToggles.isHotWalletEnabled
private fun findColdWallet(userWallets: List<UserWallet>?): UserWallet.Cold {
return userWallets?.find { it is UserWallet.Cold } as? UserWallet.Cold
?: error("Cannot find cold user wallet")
}
}

View file

@ -13,6 +13,7 @@ data class TokenReceiveConfig(
val receiveAddress: List<ReceiveAddressModel>,
val tokenReceiveNotification: List<TokenReceiveNotification> = emptyList(),
val asset: Asset = Asset.Currency,
val type: TokenReceiveType = TokenReceiveType.Default,
)
@Serializable
@ -34,4 +35,25 @@ data class TokenReceiveNotification(
enum class Asset {
Currency, NFT
}
@Serializable
sealed class TokenReceiveType {
/**
* Default setting.
* TokenReceiveComponent will use [CryptoCurrency] to get token icon and name
*/
data object Default : TokenReceiveType()
/**
* Custom setting.
* TokenReceiveComponent will use custom icon and name
*/
data class Custom(
val tokenIconUrl: String,
val tokenName: String,
val fallbackTint: Int,
val fallbackBackground: Int,
) : TokenReceiveType()
}

View file

@ -0,0 +1,11 @@
package com.tangem.domain.pay
import kotlinx.serialization.Serializable
@Serializable
data class TangemPayDetailsConfig(
val customerWalletAddress: String,
val cardNumberEnd: String,
val chainId: Int,
val depositAddress: String?,
)

View file

@ -0,0 +1,18 @@
package com.tangem.domain.pay
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
interface DataForReceiveFactory {
fun getDataForReceive(depositAddress: String, chainId: Int): Either<UniversalError, DataForReceive>
}
data class DataForReceive(
val walletId: UserWalletId,
val currency: CryptoCurrency,
val receiveAddress: List<ReceiveAddressModel>,
)

View file

@ -23,5 +23,6 @@ data class CustomerInfo(
val balance: BigDecimal,
val currencyCode: String,
val customerWalletAddress: String,
val depositAddress: String?,
)
}

View file

@ -2,8 +2,9 @@ package com.tangem.features.tangempay.components
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.pay.TangemPayDetailsConfig
interface TangemPayDetailsComponent : ComposableContentComponent {
data class Params(val customerWalletAddress: String, val cardNumberEnd: String)
data class Params(val config: TangemPayDetailsConfig)
interface Factory : ComponentFactory<Params, TangemPayDetailsComponent>
}

View file

@ -21,6 +21,7 @@ dependencies {
/** Features api */
implementation(projects.features.tangempay.details.api)
implementation(projects.features.txhistory.api)
implementation(projects.features.tokenRecieve.api)
/** Domain */
implementation(projects.domain.balanceHiding)
@ -36,6 +37,7 @@ dependencies {
implementation(deps.compose.material3)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.decompose.ext.compose)
/** DI */
implementation(deps.hilt.android)

View file

@ -4,13 +4,21 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.NavigationBar3ButtonsScrim
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent
import com.tangem.features.tangempay.model.TangemPayDetailsModel
import com.tangem.features.tangempay.ui.TangemPayDetailsScreen
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -18,17 +26,26 @@ import dagger.assisted.AssistedInject
internal class DefaultTangemPayDetailsComponent @AssistedInject constructor(
@Assisted private val appComponentContext: AppComponentContext,
@Assisted private val params: TangemPayDetailsComponent.Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
) : AppComponentContext by appComponentContext, TangemPayDetailsComponent {
private val model: TangemPayDetailsModel = getOrCreateModel(params = params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = TokenReceiveConfig.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
private val txHistoryComponent = DefaultTangemPayTxHistoryComponent(
appComponentContext = child("txHistoryComponent"),
params = DefaultTangemPayTxHistoryComponent.Params(customerWalletAddress = params.customerWalletAddress),
params = DefaultTangemPayTxHistoryComponent.Params(customerWalletAddress = params.config.customerWalletAddress),
)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
NavigationBar3ButtonsScrim()
TangemPayDetailsScreen(
@ -36,8 +53,20 @@ internal class DefaultTangemPayDetailsComponent @AssistedInject constructor(
txHistoryComponent = txHistoryComponent,
modifier = modifier,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: TokenReceiveConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create(
context = childByContext(componentContext),
params = TokenReceiveComponent.Params(
config = config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
@AssistedFactory
interface Factory : TangemPayDetailsComponent.Factory {
override fun create(

View file

@ -0,0 +1,56 @@
package com.tangem.features.tangempay.entity
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.utils.CardDetailsFormatUtil
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.persistentListOf
private const val CARD_NUMBER_SART_DIGITS_COUNT = 12
private const val DATE_PART_LENGTH = 2
private const val CVV_LENGTH = 3
internal class TangemPayDetailsStateFactory(
private val cardNumberEnd: String,
private val onBack: () -> Unit,
private val onRefresh: (ShowRefreshState) -> Unit,
private val onReceive: () -> Unit,
private val onReveal: () -> Unit,
private val onCopy: (String) -> Unit,
) {
private val cardStartMasked = maskedBlock(CARD_NUMBER_SART_DIGITS_COUNT)
private val dateMasked = maskedBlock(DATE_PART_LENGTH)
private val cvvMasked = maskedBlock(CVV_LENGTH)
fun getInitialState() = TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = onBack, items = null),
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = onRefresh),
balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(
actionButtons = persistentListOf(
ActionButtonConfig(
text = resourceReference(id = R.string.common_receive),
iconResId = R.drawable.ic_arrow_down_24,
onClick = onReceive,
),
),
),
cardDetailsUM = TangemPayCardDetailsUM(
number = CardDetailsFormatUtil.formatCardNumber(cardNumber = "$cardStartMasked$cardNumberEnd"),
expiry = CardDetailsFormatUtil.formatDate(month = dateMasked, year = dateMasked),
cvv = cvvMasked,
buttonText = TextReference.Res(R.string.tangempay_card_details_reveal_text),
onClick = onReveal,
onCopy = onCopy,
isHidden = true,
),
isBalanceHidden = false,
)
private fun maskedBlock(count: Int) = StringsSigns.DOT.repeat(count)
}

View file

@ -1,35 +1,45 @@
package com.tangem.features.tangempay.model
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.toArgb
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
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.clipboard.ClipboardManager
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.TokenReceiveType
import com.tangem.domain.pay.DataForReceiveFactory
import com.tangem.domain.pay.repository.CardDetailsRepository
import com.tangem.features.tangempay.components.TangemPayDetailsComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig
import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.features.tangempay.model.transformers.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Custom token name and icon url. Will be used only for F&F.
*/
private const val TOKEN_NAME = "USDC"
private const val TOKEN_ICON_URL = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/usd-coin.png"
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class TangemPayDetailsModel @Inject constructor(
@ -37,23 +47,61 @@ internal class TangemPayDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val cardDetailsRepository: CardDetailsRepository,
private val dataForReceiveFactory: DataForReceiveFactory,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val params: TangemPayDetailsComponent.Params = paramsContainer.require()
private val stateFactory = TangemPayDetailsStateFactory(
cardNumberEnd = params.config.cardNumberEnd,
onBack = router::pop,
onRefresh = ::onRefreshSwipe,
onReceive = ::onClickReceive,
onReveal = ::revealCardDetails,
onCopy = ::copyData,
)
val uiState: StateFlow<TangemPayDetailsUM>
field = MutableStateFlow(getInitialState())
field = MutableStateFlow(stateFactory.getInitialState())
private val refreshStateJobHolder = JobHolder()
private val fetchBalanceJobHolder = JobHolder()
private val revealCardDetailsJobHolder = JobHolder()
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
init {
fetchBalance()
}
private fun onClickReceive() {
val depositAddress = params.config.depositAddress
if (depositAddress == null) {
showError()
} else {
dataForReceiveFactory.getDataForReceive(depositAddress = depositAddress, chainId = params.config.chainId)
.onRight {
val config = TokenReceiveConfig(
shouldShowWarning = false,
cryptoCurrency = it.currency,
userWalletId = it.walletId,
showMemoDisclaimer = false,
receiveAddress = it.receiveAddress,
type = TokenReceiveType.Custom(
tokenName = TOKEN_NAME,
tokenIconUrl = TOKEN_ICON_URL,
fallbackTint = TangemColorPalette.Black.toArgb(),
fallbackBackground = TangemColorPalette.Meadow.toArgb(),
),
)
bottomSheetNavigation.activate(config)
}
.onLeft { showError() }
}
}
private fun fetchBalance(): Job {
return modelScope.launch {
val result = cardDetailsRepository.getCardBalance()
@ -63,25 +111,13 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun onRefreshSwipe(refreshState: ShowRefreshState) {
modelScope.launch {
hideCardDetails()
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = refreshState.value))
fetchBalance().join()
uiState.update(TangemPayDetailsRefreshTransformer(isRefreshing = false))
}.saveIn(refreshStateJobHolder)
}
private fun getInitialState() = TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = router::pop, items = null),
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = ::onRefreshSwipe),
balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(actionButtons = persistentListOf()),
cardDetailsUM = TangemPayCardDetailsUM(),
isBalanceHidden = false,
).let {
DetailsHiddenStateTransformer(
onClickReveal = ::revealCardDetails,
cardNumberEnd = params.cardNumberEnd,
).transform(it)
}
private fun revealCardDetails() {
modelScope.launch {
uiState.update(
@ -93,17 +129,11 @@ internal class TangemPayDetailsModel @Inject constructor(
transformer = DetailsRevealedStateTransformer(
details = it,
onClickHide = ::hideCardDetails,
onClickCopy = ::copyData,
),
)
}
.onLeft {
uiState.update(
transformer = DetailsHiddenStateTransformer(
onClickReveal = ::revealCardDetails,
cardNumberEnd = params.cardNumberEnd,
),
)
uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory))
showError()
}
}.saveIn(revealCardDetailsJobHolder)
@ -112,12 +142,7 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun hideCardDetails() {
modelScope.launch {
revealCardDetailsJobHolder.cancel()
uiState.update(
transformer = DetailsHiddenStateTransformer(
onClickReveal = ::revealCardDetails,
cardNumberEnd = params.cardNumberEnd,
),
)
uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory))
}
}

View file

@ -26,7 +26,7 @@ internal class DetailsBalanceTransformer(
fiatBalance = getBalanceText(balance.value),
// TODO [REDACTED_TASK_KEY]: Add crypto balance when the BFF is ready
cryptoBalance = "",
actionButtons = persistentListOf(),
actionButtons = prevState.balanceBlockState.actionButtons,
)
}
}

View file

@ -1,38 +1,14 @@
package com.tangem.features.tangempay.model.transformers
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.features.tangempay.utils.CardDetailsFormatUtil
import com.tangem.utils.StringsSigns
import com.tangem.utils.transformer.Transformer
private const val CARD_NUMBER_SART_DIGITS_COUNT = 12
private const val DATE_PART_LENGTH = 2
private const val CVV_LENGTH = 3
internal class DetailsHiddenStateTransformer(
private val onClickReveal: () -> Unit,
private val cardNumberEnd: String,
private val stateFactory: TangemPayDetailsStateFactory,
) : Transformer<TangemPayDetailsUM> {
private val cardStartMasked = maskedBlock(CARD_NUMBER_SART_DIGITS_COUNT)
private val dateMasked = maskedBlock(DATE_PART_LENGTH)
private val cvvMasked = maskedBlock(CVV_LENGTH)
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
val cardDetailsUM = TangemPayCardDetailsUM(
number = CardDetailsFormatUtil.formatCardNumber(cardNumber = "$cardStartMasked$cardNumberEnd"),
expiry = CardDetailsFormatUtil.formatDate(month = dateMasked, year = dateMasked),
cvv = cvvMasked,
buttonText = TextReference.Res(R.string.tangempay_card_details_reveal_text),
onClick = onClickReveal,
onCopy = {},
isHidden = true,
)
return prevState.copy(cardDetailsUM = cardDetailsUM)
return prevState.copy(cardDetailsUM = stateFactory.getInitialState().cardDetailsUM)
}
private fun maskedBlock(count: Int) = StringsSigns.DOT.repeat(count)
}

View file

@ -11,7 +11,6 @@ import com.tangem.utils.transformer.Transformer
internal class DetailsRevealedStateTransformer(
private val details: TangemPayCardDetails,
private val onClickHide: (() -> Unit),
private val onClickCopy: ((String) -> Unit),
) : Transformer<TangemPayDetailsUM> {
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
@ -21,7 +20,7 @@ internal class DetailsRevealedStateTransformer(
cvv = details.cvv,
onClick = onClickHide,
buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text),
onCopy = onClickCopy,
onCopy = prevState.cardDetailsUM.onCopy,
isHidden = false,
)
return prevState.copy(cardDetailsUM = cardDetailsUM)

View file

@ -77,6 +77,7 @@ internal class DefaultTokenReceiveComponent @AssistedInject constructor(
is TokenReceiveRoutes.QrCode -> TokenReceiveQrCodeComponent(
appComponentContext = appComponentContext,
params = TokenReceiveQrCodeComponent.TokenReceiveQrCodeParams(
type = model.params.config.type,
cryptoCurrency = model.params.config.cryptoCurrency,
address = model.state.value.addresses.find { it.value == config.address } ?: error(
"Address has to be there",

View file

@ -7,6 +7,7 @@ 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.ComposableContentComponent
import com.tangem.domain.models.TokenReceiveType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource
import com.tangem.features.tokenreceive.entity.ReceiveAddress
@ -36,5 +37,6 @@ internal class TokenReceiveQrCodeComponent(
val address: ReceiveAddress,
val callback: TokenReceiveQrCodeModelCallback,
val onDismiss: () -> Unit,
val type: TokenReceiveType = TokenReceiveType.Default,
)
}

View file

@ -1,13 +1,17 @@
package com.tangem.features.tokenreceive.entity
import androidx.compose.ui.graphics.Color
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.TokenReceiveType
import com.tangem.domain.models.TokenReceiveNotification
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.ens.EnsAddress
@ -25,6 +29,7 @@ internal class TokenReceiveStateFactory(
private val cryptoCurrency: CryptoCurrency,
private val addresses: List<ReceiveAddressModel>,
private val tokenReceiveNotification: List<TokenReceiveNotification>,
private val tokenReceiveType: TokenReceiveType,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
@ -35,7 +40,10 @@ internal class TokenReceiveStateFactory(
addresses = addresses,
cryptoCurrency = cryptoCurrency,
),
iconState = iconStateConverter.convert(cryptoCurrency),
iconState = when (tokenReceiveType) {
is TokenReceiveType.Default -> iconStateConverter.convert(cryptoCurrency)
is TokenReceiveType.Custom -> getCustomCurrencyIconState(tokenReceiveType)
},
network = cryptoCurrency.network.name,
isEnsResultLoading = false,
notificationConfigs = getNotifications(
@ -159,4 +167,13 @@ internal class TokenReceiveStateFactory(
}
}
}
private fun getCustomCurrencyIconState(type: TokenReceiveType.Custom) = CurrencyIconState.TokenIcon(
url = type.tokenIconUrl,
topBadgeIconResId = cryptoCurrency.networkIconResId,
fallbackTint = Color(type.fallbackTint),
fallbackBackground = Color(type.fallbackBackground),
isGrayscale = false,
shouldShowCustomBadge = false,
)
}

View file

@ -9,6 +9,7 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.models.Asset
import com.tangem.domain.models.TokenReceiveType
import com.tangem.domain.models.network.Network
import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource
@ -48,6 +49,7 @@ internal class TokenReceiveModel @Inject constructor(
addresses = params.config.receiveAddress,
tokenReceiveNotification = params.config.tokenReceiveNotification,
currentStateProvider = Provider { state.value },
tokenReceiveType = params.config.type,
)
}
@ -96,9 +98,12 @@ internal class TokenReceiveModel @Inject constructor(
}
internal fun getTokenName(): String {
return when (val asset = params.config.asset) {
Asset.Currency -> params.config.cryptoCurrency.symbol
Asset.NFT -> asset.name
return when (val type = params.config.type) {
is TokenReceiveType.Default -> when (val asset = params.config.asset) {
Asset.Currency -> params.config.cryptoCurrency.symbol
Asset.NFT -> asset.name
}
is TokenReceiveType.Custom -> type.tokenName
}
}

View file

@ -5,6 +5,8 @@ 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.ui.extensions.TextReference
import com.tangem.domain.models.TokenReceiveType.Default
import com.tangem.domain.models.TokenReceiveType.Custom
import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource
import com.tangem.features.tokenreceive.component.TokenReceiveQrCodeComponent
import com.tangem.features.tokenreceive.ui.state.QrCodeUM
@ -27,7 +29,12 @@ internal class TokenReceiveQrCodeModel @Inject constructor(
QrCodeUM(
network = params.cryptoCurrency.network.name,
addressValue = params.address.value,
addressName = TextReference.Str("${params.cryptoCurrency.name} (${params.cryptoCurrency.symbol})"),
addressName = when (params.type) {
is Default ->
TextReference.Str("${params.cryptoCurrency.name} (${params.cryptoCurrency.symbol})")
is Custom ->
TextReference.Str(params.type.tokenName)
},
onCopyClick = {
params.callback.onCopyClick(
address = params.address,

View file

@ -12,6 +12,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.redux.StateDialog
import com.tangem.domain.tokens.model.details.TokenAction
@ -108,8 +109,8 @@ internal class DefaultWalletRouter @Inject constructor(
router.push(AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.ContinueOnboarding))
}
override fun openTangemPayDetails(customerWalletAddress: String, cardNumberEnd: String) {
router.push(AppRoute.TangemPayDetails(customerWalletAddress, cardNumberEnd))
override fun openTangemPayDetails(config: TangemPayDetailsConfig) {
router.push(AppRoute.TangemPayDetails(config))
}
override fun openYieldSupplyBottomSheet(

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.feature.wallet.navigation.WalletRoute
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig
@ -59,7 +60,7 @@ internal interface InnerWalletRouter {
fun openTangemPayOnboarding()
fun openTangemPayDetails(customerWalletAddress: String, cardNumberEnd: String)
fun openTangemPayDetails(config: TangemPayDetailsConfig)
/** Open BS abput yield supply active and all money deposited in AAVE */
fun openYieldSupplyBottomSheet(

View file

@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.OrderStatus.CANCELED
@ -14,11 +15,17 @@ import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCr
import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createKycInProgressState
import java.util.Currency
/**
* Hardcode Polygon chain id only for F&F.
* Later chain id will be fetched from BFF.
*/
private const val POLYGON_CHAIN_ID = 137
internal class TangemPayInitialStateTransformer(
private val value: MainScreenCustomerInfo? = null,
private val onClickIssue: () -> Unit = {},
private val onClickKyc: () -> Unit = {},
private val openDetails: (customerWalletAddress: String, cardNumberEnd: String) -> Unit = { _, _ -> },
private val openDetails: (config: TangemPayDetailsConfig) -> Unit = {},
) : WalletScreenStateTransformer {
override fun transform(prevState: WalletScreenState): WalletScreenState {
@ -40,7 +47,16 @@ internal class TangemPayInitialStateTransformer(
private fun getCardInfoState(cardInfo: CardInfo): TangemPayState = TangemPayState.Card(
lastFourDigits = TextReference.Str("*${cardInfo.lastFourDigits}"),
balanceText = TextReference.Str(getBalanceText(cardInfo)),
onClick = { openDetails(cardInfo.customerWalletAddress, cardInfo.lastFourDigits) },
onClick = {
openDetails(
TangemPayDetailsConfig(
customerWalletAddress = cardInfo.customerWalletAddress,
cardNumberEnd = cardInfo.lastFourDigits,
chainId = POLYGON_CHAIN_ID,
depositAddress = cardInfo.depositAddress,
),
)
},
)
private fun getBalanceText(cardInfo: CardInfo): String {