Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-28 14:11:23 +03:00
commit 0470e1f010
1144 changed files with 48463 additions and 11166 deletions

View file

@ -11,6 +11,9 @@ android {
namespace = "com.tangem.features.tokendetails.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** AndroidX */
@ -34,7 +37,6 @@ dependencies {
implementation(deps.arrow.core)
implementation(deps.jodatime)
implementation(deps.kotlin.immutable.collections)
implementation(deps.reKotlin)
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(deps.lifecycle.compose)
@ -68,6 +70,8 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.card)
implementation(projects.domain.demo)
implementation(projects.domain.dynamicAddresses)
implementation(projects.domain.dynamicAddresses.models)
implementation(projects.domain.markets.models)
implementation(projects.domain.models)
implementation(projects.domain.notifications.models)
@ -111,4 +115,10 @@ dependencies {
implementation(deps.decompose.ext.compose)
/** Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -24,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetails
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokenreceive.TokenReceiveComponent
@ -97,6 +98,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
TokenDetailsScreen(
tokenDetailsUM = tokenDetailsUM,
tokenMarketBlockComponent = tokenMarketBlockComponent,
yieldSupplyComponent = yieldSupplyComponent,
modifier = modifier,
)
} else {
@ -148,6 +150,10 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
cloreMigrationModel = model.cloreMigrationModel,
onDismiss = model.bottomSheetNavigation::dismiss,
)
is TokenDetailsBottomSheetConfig.DynamicAddresses -> DynamicAddressesBottomSheetComponent(
dynamicAddressesDelegate = model.dynamicAddressesDelegate,
onDismiss = model.bottomSheetNavigation::dismiss,
)
}
@AssistedFactory

View file

@ -9,8 +9,8 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -18,7 +18,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.notifications.models.NotificationType
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
@ -27,12 +26,12 @@ import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnaly
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
@Suppress("LongParameterList")
internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
@ -128,9 +127,6 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) {
val isMultiCurrency = userWallet.isMultiCurrency
// single-currency wallet with token (NODL)
userWallet is UserWallet.Cold &&
userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
when {
isMultiCurrency -> cryptoCurrencyBalanceFetcher(
userWalletId = userWallet.walletId,
@ -150,7 +146,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
val derivationPath = queryParams[DERIVATION_PATH_KEY]
getCryptoCurrencies(userWalletId = userWallet.walletId)?.firstOrNull { currency ->
val isNetwork = currency.network.backendId.equals(networkId, ignoreCase = true)
val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true)
val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
val isDefaultDerivation = currency.network.derivationPath is Network.DerivationPath.Card

View file

@ -4,7 +4,9 @@ import com.tangem.blockchainsdk.utils.isNeedToCreateAccountWithoutReserve
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -34,6 +36,7 @@ internal class GetCurrencyWarningsUseCase @Inject constructor(
private val currencyChecksRepository: CurrencyChecksRepository,
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
suspend operator fun invoke(
@ -52,7 +55,12 @@ internal class GetCurrencyWarningsUseCase @Inject constructor(
flow2 = flowOf(currencyChecksRepository.getRentInfoWarning(userWalletId, currencyStatus)),
flow3 = flowOf(currencyChecksRepository.getExistentialDeposit(userWalletId, currency.network)),
flow4 = flowOf(currencyChecksRepository.getFeeResourceAmount(userWalletId, currency.network)),
) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource ->
flow5 = if (currency is CryptoCurrency.Coin) {
dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, currency.network)
} else {
flowOf(false)
},
) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource, hasExtraFunds ->
setOfNotNull(
maybeRentWarning,
maybeEdWarning?.let { getExistentialDepositWarning(currency, it) },
@ -64,6 +72,7 @@ internal class GetCurrencyWarningsUseCase @Inject constructor(
getAssetRequirementsWarning(userWalletId = userWalletId, currency = currency),
getMigrationFromMaticToPolWarning(currency),
getCloreMigrationWarning(currency),
DynamicAddressesWarnings.FundsFound.takeIf { hasExtraFunds },
)
}.flowOn(dispatchers.io)
}

View file

@ -62,6 +62,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
is TokenDetailsNotification.MigrationClore,
is TokenDetailsNotification.UsedOutdatedData,
-> null
is TokenDetailsNotification.DynamicAddressesFundsFound -> null // TODO: [REDACTED_TASK_KEY] analytics event
}
}
}

View file

@ -0,0 +1,362 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.model
import com.tangem.common.core.TangemSdkError
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.common.ui.amountScreen.utils.getFiatString
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase
import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.Provider
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class DynamicAddressesDelegate @AssistedInject constructor(
private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase,
private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase,
private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val getDerivedXpubUseCase: GetDerivedXpubUseCase,
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
@Assisted private val userWallet: UserWallet,
@Assisted private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
@Assisted private val appCurrencyProvider: Provider<AppCurrency>,
@Assisted private val coroutineScope: CoroutineScope,
@Assisted("showBottomSheet") private val showBottomSheet: () -> Unit,
@Assisted("dismissBottomSheet") private val dismissBottomSheet: () -> Unit,
@Assisted("onDynamicAddressesStateChanged") private val onDynamicAddressesStateChanged: () -> Unit,
) {
private val userWalletId get() = userWallet.walletId
private val _bottomSheetConfig = MutableStateFlow<DynamicAddressesBottomSheetConfig>(
DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = false,
onEnableClick = {},
),
)
val bottomSheetConfig: StateFlow<DynamicAddressesBottomSheetConfig> = _bottomSheetConfig.asStateFlow()
// region Entry point
fun onDynamicAddressesClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
coroutineScope.launch(dispatchers.main) {
val status = dynamicAddressesRepository.getStatus(userWalletId, network).first()
when (status) {
DynamicAddressesStatus.ENABLED,
DynamicAddressesStatus.ENABLED_REQUIRES_SETUP,
-> onDisableFlow(network)
DynamicAddressesStatus.DISABLED -> onEnableFlow(network)
}
}
}
// endregion
// region Enable flow
private suspend fun onEnableFlow(network: Network) {
val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)
if (hasConflicts) {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(
onDismissClick = dismissBottomSheet,
)
showBottomSheet()
return
}
val isCardScanRequired = !isXpubAlreadyDerived(network)
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = isCardScanRequired,
onEnableClick = ::onEnableClick,
)
showBottomSheet()
}
private fun onEnableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = false,
isLoading = true,
onEnableClick = {},
)
val xpub = getExtendedPublicKeyUseCase(userWalletId, network).fold(
ifLeft = { error ->
if (isUserCancellation(error)) {
dismissBottomSheet()
} else {
TangemLogger.e("Failed to get XPUB: ${error.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
}
return@launch
},
ifRight = { it },
)
enableDynamicAddressesUseCase(userWalletId, network, xpub).fold(
ifLeft = { error ->
when (error) {
is EnableDynamicAddressesError.ConflictingCustomTokens -> {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(
onDismissClick = dismissBottomSheet,
)
}
is EnableDynamicAddressesError.ServiceError -> {
TangemLogger.e("Failed to enable dynamic addresses: ${error.cause.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
}
}
},
ifRight = {
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_enabled_toast_title)),
)
},
)
}
}
// endregion
// region Disable flow
private fun onDisableFlow(network: Network) {
coroutineScope.launch(dispatchers.main) {
disableDynamicAddressesUseCase(userWalletId, network).fold(
ifLeft = { error ->
TangemLogger.e("Failed to check disable: ${error.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
showBottomSheet()
},
ifRight = { isConsolidationRequired ->
if (!isConsolidationRequired) {
showSimpleDisableSheet()
} else {
showDisableSheetAndLoadFee()
}
},
)
}
}
private fun showSimpleDisableSheet() {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation(
onDisableClick = ::onSimpleDisableClick,
onReadMoreClick = ::onReadMoreClick,
)
showBottomSheet()
}
private fun onSimpleDisableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
coroutineScope.launch(dispatchers.main) {
runSuspendCatching { dynamicAddressesRepository.disable(userWalletId, network) }
.onSuccess {
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_disabled_popup_title)),
)
}
.onFailure { e ->
TangemLogger.e("Failed to disable dynamic addresses: ${e.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
}
}
}
private fun showDisableSheetAndLoadFee() {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading,
onDisableClick = ::onDisableClick,
onRefreshFee = ::loadDisableFee,
onReadMoreClick = ::onReadMoreClick,
)
showBottomSheet()
loadDisableFee()
}
private fun loadDisableFee() {
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading,
)
val status = cryptoCurrencyStatusProvider()
val currency = status?.currency
val balance = status?.value?.amount
val address = status?.value?.networkAddress?.defaultAddress?.value
if (currency == null || balance == null || address == null) {
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error,
)
return@launch
}
getFeeUseCase(
amount = balance,
destination = address,
userWallet = userWallet,
cryptoCurrency = currency,
).fold(
ifLeft = {
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error,
)
},
ifRight = { txFee ->
val fee = txFee.normal
val fiatFormatted = getFiatString(
value = fee.amount.value,
rate = status.value.fiatRate,
appCurrency = appCurrencyProvider(),
approximate = true,
)
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Content(
feeSymbol = currency.symbol,
fiatFormatted = fiatFormatted,
),
)
},
)
}
}
private fun disableWithConsolidationConfig(): DynamicAddressesBottomSheetConfig.DisableWithConsolidation {
return _bottomSheetConfig.value as? DynamicAddressesBottomSheetConfig.DisableWithConsolidation
?: DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
onDisableClick = ::onDisableClick,
onRefreshFee = ::loadDisableFee,
onReadMoreClick = ::onReadMoreClick,
)
}
private fun onReadMoreClick() {
// TODO: Replace with actual URL
}
private fun onDisableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
isSending = true,
)
val txData = createConsolidationTransactionUseCase(userWalletId, network).fold(
ifLeft = { error ->
TangemLogger.e("Failed to create consolidation tx: ${error.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
return@launch
},
ifRight = { it },
)
sendTransactionUseCase(
txData = txData,
userWallet = userWallet,
network = network,
).fold(
ifLeft = { error ->
if (error is SendTransactionError.UserCancelledError) {
dismissBottomSheet()
} else {
TangemLogger.e("Failed to send consolidation tx: $error")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
}
},
ifRight = {
try {
dynamicAddressesRepository.disable(userWalletId, network)
} catch (e: Exception) {
TangemLogger.e("Failed to disable dynamic addresses after consolidation: ${e.message}")
}
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
SnackbarMessage(
message = resourceReference(R.string.dynamic_addresses_disabled_popup_title),
),
)
},
)
}
}
// endregion
// region Common
private suspend fun isXpubAlreadyDerived(network: Network): Boolean {
return getDerivedXpubUseCase(userWalletId, network) != null
}
private fun isUserCancellation(error: Throwable): Boolean {
return error is TangemSdkError.UserCancelled || error.cause is TangemSdkError.UserCancelled
}
// endregion
@AssistedFactory
interface Factory {
@Suppress("LongParameterList")
fun create(
userWallet: UserWallet,
cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
appCurrencyProvider: Provider<AppCurrency>,
coroutineScope: CoroutineScope,
@Assisted("showBottomSheet") showBottomSheet: () -> Unit,
@Assisted("dismissBottomSheet") dismissBottomSheet: () -> Unit,
@Assisted("onDynamicAddressesStateChanged") onDynamicAddressesStateChanged: () -> Unit,
): DynamicAddressesDelegate
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState
@ -114,6 +115,12 @@ internal class ExpressTransactionsModel @Inject constructor(
router.openUrl(url)
}
override fun onReadAboutCrossChainBridgesClick() {
modelScope.launch {
router.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.AboutCrossChainBridges))
}
}
override fun onConfirmDisposeExpressStatus() {
uiMessageSender.send(
DialogMessage(

View file

@ -48,6 +48,10 @@ interface TokenDetailsClickIntents {
fun onGenerateExtendedKey()
fun onDynamicAddressesClick()
fun onDynamicAddressesFundsFoundLearnMoreClick()
fun onCopyAddress(): TextReference?
fun onAssociateClick()
@ -96,6 +100,8 @@ interface ExpressTransactionsClickIntents {
fun onOpenUrlClick(url: String)
fun onReadAboutCrossChainBridgesClick()
fun onConfirmDisposeExpressStatus()
fun onDisposeExpressStatus()
@ -125,6 +131,10 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onGenerateExtendedKey() { /* no op */ }
override fun onDynamicAddressesClick() { /* no op */ }
override fun onDynamicAddressesFundsFoundLearnMoreClick() { /* no op */ }
override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }
override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }

View file

@ -0,0 +1,89 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.model
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
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.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.tokendetails.impl.R
import javax.inject.Inject
@ModelScoped
internal class TokenDetailsDialogFactory @Inject constructor(
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) {
fun showConfirmHideToken(currency: CryptoCurrency, onConfirm: () -> Unit) {
uiMessageSender.send(
DialogMessage(
title = resourceReference(
id = R.string.token_details_hide_alert_title,
formatArgs = wrappedList(currency.name),
),
message = resourceReference(R.string.token_details_hide_alert_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.token_details_hide_alert_hide),
isWarning = true,
onClick = onConfirm,
)
},
secondActionBuilder = { cancelAction() },
),
)
}
fun showLinkedTokens(currency: CryptoCurrency) {
uiMessageSender.send(
DialogMessage(
title = resourceReference(
id = R.string.token_details_unable_hide_alert_title,
formatArgs = wrappedList(currency.symbol),
),
message = resourceReference(
id = R.string.token_details_unable_hide_alert_message,
formatArgs = wrappedList(currency.name, currency.symbol, currency.network.name),
),
),
)
}
fun showDismissIncompleteTransactionConfirm(onConfirm: () -> Unit) {
uiMessageSender.send(
DialogMessage(
message = resourceReference(R.string.warning_kaspa_unfinished_token_transaction_discard_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_yes),
onClick = onConfirm,
)
},
secondActionBuilder = { cancelAction() },
),
)
}
fun showError(text: TextReference) {
uiMessageSender.send(DialogMessage(message = text))
}
fun showConfirmHideExpressStatus(onConfirm: () -> Unit) {
uiMessageSender.send(
DialogMessage(
title = resourceReference(R.string.express_status_hide_dialog_title),
message = resourceReference(R.string.express_status_hide_dialog_text),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_hide),
onClick = onConfirm,
)
},
secondActionBuilder = { cancelAction() },
),
)
}
}

View file

@ -2,13 +2,18 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import arrow.core.merge
import arrow.core.right
import com.tangem.utils.logging.TangemLogger
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.address.AddressType
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.receive.AddressModel
@ -16,31 +21,30 @@ import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
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.navigation.url.UrlOpener
import com.tangem.common.ui.tokens.getUnavailabilityReasonText
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
@ -53,6 +57,7 @@ import com.tangem.domain.models.TokenReceiveNotification
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -81,6 +86,7 @@ import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase
@ -93,13 +99,19 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.Token
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender
import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDetailsBottomSheetConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.TokenDetailsExpressStatusFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceLoadingTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetTopBarTitleTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.ToggleBalanceTypeTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateStakingNotificationTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateNotificationsTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTopBarMenuTransformer
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokendetails.impl.R
import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter
@ -109,7 +121,6 @@ import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -118,6 +129,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions", "PropertyUsedBeforeDeclaration")
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@Stable
@ModelScoped
internal class TokenDetailsModel @Inject constructor(
@ -154,7 +166,6 @@ internal class TokenDetailsModel @Inject constructor(
private val appRouter: AppRouter,
private val router: InnerTokenDetailsRouter,
private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val receiveAddressesFactory: ReceiveAddressesFactory,
private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase,
private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase,
@ -163,6 +174,17 @@ internal class TokenDetailsModel @Inject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase,
private val signCloreMessageUseCase: SignCloreMessageUseCase,
private val isXpubSupportedUseCase: IsXpubSupportedUseCase,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val dynamicAddressesDelegateFactory: DynamicAddressesDelegate.Factory,
private val dialogFactory: TokenDetailsDialogFactory,
private val userWalletsListRepository: UserWalletsListRepository,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val walletIconUMConverter: WalletIconUMConverter,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val designFeatureToggles: DesignFeatureToggles,
private val redesignStateController: TokenDetailsStateController,
) : Model(),
TokenDetailsClickIntents,
ExpressTransactionsClickIntents,
@ -183,6 +205,8 @@ internal class TokenDetailsModel @Inject constructor(
private val stakingJobHolder = JobHolder()
private val yieldSupplyBalanceJobHolder = JobHolder()
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
private val redesignBalanceJobHolder = JobHolder()
private val redesignEarnJobHolder = JobHolder()
private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null
private var account: Account.CryptoPortfolio? = null
@ -204,11 +228,10 @@ internal class TokenDetailsModel @Inject constructor(
userWalletId = userWalletId,
)
private val internalUiState = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency))
val uiState: StateFlow<TokenDetailsState> = internalUiState
val uiState: StateFlow<TokenDetailsState>
field = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency))
private val internalRedesignUiState = MutableStateFlow(createInitialRedesignState())
val redesignUiState: StateFlow<TokenDetailsUM> = internalRedesignUiState
val redesignUiState: StateFlow<TokenDetailsUM> get() = redesignStateController.uiState
// region Clore migration
// TODO: Remove after Clore migration ends ([REDACTED_TASK_KEY])
@ -226,6 +249,22 @@ internal class TokenDetailsModel @Inject constructor(
}
// endregion
// region Dynamic Addresses
val dynamicAddressesDelegate by lazy(mode = LazyThreadSafetyMode.NONE) {
dynamicAddressesDelegateFactory.create(
userWallet = userWallet,
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
appCurrencyProvider = Provider { selectedAppCurrencyFlow.value },
coroutineScope = modelScope,
showBottomSheet = {
bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.DynamicAddresses)
},
dismissBottomSheet = bottomSheetNavigation::dismiss,
onDynamicAddressesStateChanged = ::onDynamicAddressesStateChanged,
)
}
// endregion Dynamic Addresses
private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
tokenDetailsExpressStatusFactory.create(
clickIntents = this,
@ -249,6 +288,7 @@ internal class TokenDetailsModel @Inject constructor(
}
init {
initRedesign()
updateTopBarMenu()
initButtons()
updateContent()
@ -299,7 +339,7 @@ internal class TokenDetailsModel @Inject constructor(
private fun handleBalanceHiding() {
getBalanceHidingSettingsUseCase()
.onEach { settings ->
internalUiState.value = stateFactory.getStateWithUpdatedHidden(
uiState.value = stateFactory.getStateWithUpdatedHidden(
isBalanceHidden = settings.isBalanceHidden,
)
}
@ -315,7 +355,7 @@ internal class TokenDetailsModel @Inject constructor(
.distinctUntilChanged()
.onEach { state ->
sendButtonsEvents(state.states)
internalUiState.value = stateFactory.getManageButtonsState(actions = state.states)
uiState.value = stateFactory.getManageButtonsState(actions = state.states)
}
.flowOn(dispatchers.main)
.launchIn(modelScope)
@ -347,8 +387,15 @@ internal class TokenDetailsModel @Inject constructor(
.distinctUntilChanged()
.onEach { warnings ->
val updatedState = stateFactory.getStateWithNotifications(warnings)
notificationsAnalyticsSender.send(internalUiState.value, updatedState.notifications)
internalUiState.value = updatedState
notificationsAnalyticsSender.send(uiState.value, updatedState.notifications)
uiState.value = updatedState
redesignStateController.update(
UpdateNotificationsTransformer(
warnings = warnings,
clickIntents = this@TokenDetailsModel,
),
)
}
.launchIn(modelScope)
.saveIn(warningsJobHolder)
@ -361,7 +408,7 @@ internal class TokenDetailsModel @Inject constructor(
.map { it.status.right() }
.distinctUntilChanged()
.onEach { maybeCurrencyStatus ->
internalUiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus)
uiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus)
maybeCurrencyStatus.onRight { status ->
sendOneTimeBalanceLoadedAnalyticsEvent(status)
cryptoCurrencyStatus = status
@ -383,7 +430,7 @@ internal class TokenDetailsModel @Inject constructor(
.distinctUntilChanged()
.onEach { waitForFirstExpressStatusEmmit.value = true }
.onEach { expressTxs ->
internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs(
uiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs(
expressTxs = expressTxs,
updateBalance = ::updateNetworkToSwapBalance,
)
@ -393,11 +440,11 @@ internal class TokenDetailsModel @Inject constructor(
delay = EXPRESS_STATUS_UPDATE_DELAY,
task = {
runSuspendCatching {
expressStatusFactory.getUpdatedExpressStatuses(internalUiState.value.expressTxs)
expressStatusFactory.getUpdatedExpressStatuses(uiState.value.expressTxs)
}
},
onSuccess = { updatedTxs ->
internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs(
uiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs(
updatedTxs,
::updateNetworkToSwapBalance,
)
@ -418,14 +465,14 @@ internal class TokenDetailsModel @Inject constructor(
}
yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value)
.onEach { formatted ->
internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted)
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted)
}
.flowOn(dispatchers.main)
.launchIn(modelScope)
.saveIn(yieldSupplyBalanceJobHolder)
} else {
yieldSupplyBalanceJobHolder.cancel()
internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(
YieldSupplyRewardBalance.empty(),
)
}
@ -462,7 +509,7 @@ internal class TokenDetailsModel @Inject constructor(
null
}
internalUiState.update { state ->
uiState.update { state ->
stateFactory.getStakingInfoState(
state = state,
stakingEntryInfo = stakingEntryInfo,
@ -484,39 +531,41 @@ internal class TokenDetailsModel @Inject constructor(
).getOrElse { false }
val isSupported = isXPUBSupported()
val isDynamicAddressesAvailable = isSupported && isDynamicAddressesAvailable()
internalUiState.value = stateFactory.getStateWithUpdatedMenu(
uiState.value = stateFactory.getStateWithUpdatedMenu(
userWallet = userWallet,
hasDerivations = hasDerivations,
isSupported = isSupported,
isDynamicAddressesAvailable = isDynamicAddressesAvailable,
)
}
}
private fun isDynamicAddressesAvailable(): Boolean {
if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return false
if (cryptoCurrency !is CryptoCurrency.Coin) return false
val networkId = cryptoCurrency.network.rawId
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(networkId)) return false
return isDefaultBaseDerivation(cryptoCurrency.network.derivationPath, networkId)
}
private fun isDefaultBaseDerivation(derivationPath: Network.DerivationPath, networkId: String): Boolean {
val pathValue = derivationPath.value ?: return false
val nodes = runCatching { DerivationPath(pathValue).nodes }.getOrNull() ?: return false
if (nodes.size < BASE_DERIVATION_NODE_COUNT) return false
val purposeNode = nodes.first()
val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false
if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false
return DynamicAddressesDerivationChecker.isBaseDerivation(pathValue)
}
private suspend fun isXPUBSupported(): Boolean {
return getExtendedPublicKeyForCurrencyUseCase.isSupported(
userWalletId = userWalletId,
network = cryptoCurrency.network,
)
.mapLeft { throwable ->
analyticsExceptionHandler.sendException(
event = ExceptionAnalyticsEvent(
exception = throwable,
params = mapOf(
"blockchainId" to cryptoCurrency.network.id.rawId.value,
"networkId" to cryptoCurrency.network.backendId,
),
),
)
TangemLogger.e(
"Unable to get wallet manager for user wallet $userWalletId and network ${cryptoCurrency.network}",
throwable,
)
false
}
.merge()
return isXpubSupportedUseCase(userWalletId = userWalletId, network = cryptoCurrency.network)
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
@ -656,6 +705,19 @@ internal class TokenDetailsModel @Inject constructor(
openStaking()
}
override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick()
override fun onDynamicAddressesFundsFoundLearnMoreClick() {
// TODO: open "Learn more" URL once the destination is decided
}
private fun onDynamicAddressesStateChanged() {
updateTopBarMenu()
modelScope.launch(dispatchers.main) {
cryptoCurrencyBalanceFetcher.invokeAndAwait(userWalletId = userWalletId, currency = cryptoCurrency)
}
}
override fun onGenerateExtendedKey() {
modelScope.launch(dispatchers.main) {
val extendedKey = getExtendedPublicKeyForCurrencyUseCase(
@ -729,7 +791,7 @@ internal class TokenDetailsModel @Inject constructor(
} else {
appRouter.push(
AppRoute.Swap(
currencyFrom = cryptoCurrency,
cryptoCurrency = cryptoCurrency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.Token.value,
),
@ -748,9 +810,9 @@ internal class TokenDetailsModel @Inject constructor(
)
if (canHide) {
showConfirmHideTokenDialog(cryptoCurrency)
dialogFactory.showConfirmHideToken(currency = cryptoCurrency, onConfirm = ::onHideConfirmed)
} else {
showLinkedTokensDialog(cryptoCurrency)
dialogFactory.showLinkedTokens(currency = cryptoCurrency)
}
}
}
@ -844,7 +906,12 @@ internal class TokenDetailsModel @Inject constructor(
}
override fun onRefreshSwipe(isRefreshing: Boolean) {
internalUiState.value = stateFactory.getRefreshingState()
uiState.value = stateFactory.getRefreshingState()
redesignStateController.update(
SetBalanceLoadingTransformer(
currencyIconState = redesignStateController.value.balanceBlockUM.currencyIconState,
),
)
modelScope.launch(dispatchers.main) {
listOf(
@ -856,29 +923,29 @@ internal class TokenDetailsModel @Inject constructor(
subscribeOnExpressTransactionsUpdates()
},
).awaitAll()
internalUiState.value = stateFactory.getRefreshedState()
uiState.value = stateFactory.getRefreshedState()
}.saveIn(refreshStateJobHolder)
}
override fun onDismissBottomSheet() {
when (val bsContent = internalUiState.value.bottomSheetConfig?.content) {
when (val bsContent = uiState.value.bottomSheetConfig?.content) {
is ExpressStatusBottomSheetConfig -> {
modelScope.launch(dispatchers.main) {
expressStatusFactory.removeTransactionOnBottomSheetClosed(bsContent.value)
}
}
}
internalUiState.value = stateFactory.getStateWithClosedBottomSheet()
uiState.value = stateFactory.getStateWithClosedBottomSheet()
}
override fun onCloseRentInfoNotification() {
internalUiState.value = stateFactory.getStateWithRemovedRentNotification()
uiState.value = stateFactory.getStateWithRemovedRentNotification()
}
override fun onExpressTransactionClick(txId: String) {
val expressTxState = internalUiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId }
val expressTxState = uiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId }
?: return
internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState)
uiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState)
}
override fun onGoToProviderClick(url: String) {
@ -893,6 +960,12 @@ internal class TokenDetailsModel @Inject constructor(
router.openUrl(url)
}
override fun onReadAboutCrossChainBridgesClick() {
modelScope.launch {
router.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.AboutCrossChainBridges))
}
}
override fun onSwapPromoDismiss(promoId: PromoId) {
modelScope.launch(dispatchers.main) {
shouldShowPromoTokenUseCase.neverToShow(promoId)
@ -967,12 +1040,12 @@ internal class TokenDetailsModel @Inject constructor(
}
}
if (message != null) {
showErrorDialog(stringReference(message))
dialogFactory.showError(text = stringReference(message))
TangemLogger.e(message)
}
},
ifRight = {
internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification()
uiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification()
},
)
}
@ -1011,10 +1084,10 @@ internal class TokenDetailsModel @Inject constructor(
}
if (message != null) {
showErrorDialog(message)
dialogFactory.showError(text = message)
}
},
ifRight = { internalUiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() },
ifRight = { uiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() },
)
}
}
@ -1026,7 +1099,9 @@ internal class TokenDetailsModel @Inject constructor(
blockchain = cryptoCurrency.network.name,
),
)
showDismissIncompleteTransactionConfirmDialog()
dialogFactory.showDismissIncompleteTransactionConfirm(
onConfirm = ::onConfirmDismissIncompleteTransactionClick,
)
}
override fun onConfirmDismissIncompleteTransactionClick() {
@ -1036,11 +1111,11 @@ internal class TokenDetailsModel @Inject constructor(
currency = cryptoCurrency,
).fold(
ifLeft = { e ->
showErrorDialog(stringReference(e.message.orEmpty()))
dialogFactory.showError(text = stringReference(e.message.orEmpty()))
TangemLogger.e("Error: $e")
},
ifRight = {
internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification()
uiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification()
},
)
}
@ -1061,7 +1136,8 @@ internal class TokenDetailsModel @Inject constructor(
ifLeft = { e ->
when (e) {
is AssociateAssetError.NotEnoughBalance -> {
showErrorDialog(
dialogFactory.showError(
text =
resourceReference(
id = R.string.warning_hedera_token_association_not_enough_hbar_message,
formatArgs = wrappedList(e.feeCurrency.symbol),
@ -1069,26 +1145,26 @@ internal class TokenDetailsModel @Inject constructor(
)
}
is AssociateAssetError.DataError -> {
showErrorDialog(stringReference(e.message.orEmpty()))
dialogFactory.showError(text = stringReference(e.message.orEmpty()))
TangemLogger.e("Error: $e")
}
}
},
ifRight = { internalUiState.value = stateFactory.getStateWithRemovedHederaAssociateNotification() },
ifRight = { uiState.value = stateFactory.getStateWithRemovedHederaAssociateNotification() },
)
}
}
override fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig) {
internalUiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config)
uiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config)
}
override fun onConfirmDisposeExpressStatus() {
showConfirmHideExpressStatusDialog()
dialogFactory.showConfirmHideExpressStatus(onConfirm = ::onDisposeExpressStatus)
}
override fun onDisposeExpressStatus() {
val bottomSheetState = internalUiState.value.bottomSheetConfig?.content
val bottomSheetState = uiState.value.bottomSheetConfig?.content
if (bottomSheetState is ExpressStatusBottomSheetConfig) {
modelScope.launch {
expressStatusFactory.removeTransactionOnBottomSheetClosed(
@ -1097,7 +1173,7 @@ internal class TokenDetailsModel @Inject constructor(
)
}
}
internalUiState.value = stateFactory.getStateWithClosedBottomSheet()
uiState.value = stateFactory.getStateWithClosedBottomSheet()
}
override fun onYieldInfoClick() {
@ -1118,7 +1194,7 @@ internal class TokenDetailsModel @Inject constructor(
private fun handleUnavailabilityReason(unavailabilityReason: ScenarioUnavailabilityReason): Boolean {
if (unavailabilityReason == ScenarioUnavailabilityReason.None) return false
showErrorDialog(unavailabilityReason.getUnavailabilityReasonText())
dialogFactory.showError(text = unavailabilityReason.getUnavailabilityReasonText())
return true
}
@ -1147,78 +1223,6 @@ internal class TokenDetailsModel @Inject constructor(
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.staking_error_no_validators_title)))
}
private fun showConfirmHideTokenDialog(currency: CryptoCurrency) {
uiMessageSender.send(
DialogMessage(
title = resourceReference(
id = R.string.token_details_hide_alert_title,
formatArgs = wrappedList(currency.name),
),
message = resourceReference(R.string.token_details_hide_alert_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.token_details_hide_alert_hide),
isWarning = true,
onClick = ::onHideConfirmed,
)
},
secondActionBuilder = { cancelAction() },
),
)
}
private fun showLinkedTokensDialog(currency: CryptoCurrency) {
uiMessageSender.send(
DialogMessage(
title = resourceReference(
id = R.string.token_details_unable_hide_alert_title,
formatArgs = wrappedList(currency.symbol),
),
message = resourceReference(
id = R.string.token_details_unable_hide_alert_message,
formatArgs = wrappedList(currency.name, currency.symbol, currency.network.name),
),
),
)
}
private fun showDismissIncompleteTransactionConfirmDialog() {
uiMessageSender.send(
DialogMessage(
message = resourceReference(R.string.warning_kaspa_unfinished_token_transaction_discard_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_yes),
onClick = ::onConfirmDismissIncompleteTransactionClick,
)
},
secondActionBuilder = { cancelAction() },
),
)
}
private fun showConfirmHideExpressStatusDialog() {
uiMessageSender.send(
DialogMessage(
title = resourceReference(R.string.express_status_hide_dialog_title),
message = resourceReference(R.string.express_status_hide_dialog_text),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_hide),
onClick = {
onDisposeExpressStatus()
},
)
},
secondActionBuilder = { cancelAction() },
),
)
}
private fun showErrorDialog(text: TextReference) {
uiMessageSender.send(DialogMessage(message = text))
}
private fun checkForActionUpdates() {
combine(
tokenDetailsDeepLinkActionListener.tokenDetailsActionFlow,
@ -1313,7 +1317,7 @@ internal class TokenDetailsModel @Inject constructor(
TokenAction.Send -> sendCurrency()
TokenAction.Swap -> appRouter.push(
AppRoute.Swap(
currencyFrom = cryptoCurrency,
cryptoCurrency = cryptoCurrency,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.Token.value,
),
@ -1356,30 +1360,156 @@ internal class TokenDetailsModel @Inject constructor(
// endregion Clore migration
private fun createInitialRedesignState(): TokenDetailsUM {
return TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
title = stringReference(cryptoCurrency.name),
subtitle = stringReference(cryptoCurrency.symbol),
menuItems = persistentListOf(),
private fun observeRedesignBalance() {
getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { it.status }
.distinctUntilChanged()
.combine(selectedAppCurrencyFlow) { status, appCurrency -> status to appCurrency }
.onEach { (status, appCurrency) ->
redesignStateController.update(
SetBalanceTransformer(
status = status,
appCurrency = appCurrency,
onToggleBalanceType = ::toggleRedesignBalanceType,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
.saveIn(redesignBalanceJobHolder)
}
private fun toggleRedesignBalanceType() {
redesignStateController.update(ToggleBalanceTypeTransformer())
}
private fun updateRedesignTopBarMenu() {
modelScope.launch(dispatchers.main) {
val hasDerivations = networkHasDerivationUseCase(
userWallet = userWallet,
network = cryptoCurrency.network,
).getOrElse { false }
val isSupported = isXPUBSupported()
redesignStateController.update(
UpdateTopBarMenuTransformer(
userWallet = userWallet,
hasDerivations = hasDerivations,
isXPubSupported = isSupported,
onGenerateExtendedKey = ::onGenerateExtendedKey,
onHideClick = ::onHideClick,
),
)
}
}
private fun initRedesign() {
if (!designFeatureToggles.isRedesignEnabled) return
initRedesignState()
observeRedesignBalance()
updateRedesignTopBarMenu()
observeRedesignTopBarTitle()
observeRedesignStakingNotification()
}
private fun observeRedesignStakingNotification() {
val statusFlow = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { it.status }
.distinctUntilChanged()
val availabilityFlow = getStakingAvailabilityUseCase(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
)
.map { it.getOrElse { StakingAvailability.Unavailable } }
.distinctUntilChanged()
val entryInfoFlow = availabilityFlow.mapLatest { availability ->
(availability as? StakingAvailability.Available)?.let { available ->
getStakingEntryInfoUseCase(
cryptoCurrencyId = cryptoCurrency.id,
symbol = cryptoCurrency.symbol,
stakingOption = available.option,
).getOrNull()
}
}
combine(
flow = statusFlow,
flow2 = availabilityFlow,
flow3 = entryInfoFlow,
flow4 = selectedAppCurrencyFlow,
) { status, availability, entryInfo, appCurrency ->
redesignStateController.update(
UpdateStakingNotificationTransformer(
cryptoCurrencyStatus = status,
stakingAvailability = availability,
stakingEntryInfo = entryInfo,
appCurrency = appCurrency,
clickIntents = this,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
.saveIn(redesignEarnJobHolder)
}
private fun initRedesignState() {
redesignStateController.update(
InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = ::onBackClick,
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol),
stakingBlocksState = null,
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
onRefresh = {},
),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}
private fun observeRedesignTopBarTitle() {
combine(
flow = userWalletsListRepository.userWallets.filterNotNull(),
flow2 = isAccountsModeEnabledUseCase.invoke(),
flow3 = singleAccountListSupplier(userWalletId),
flow4 = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { status -> status.account }
.distinctUntilChanged(),
) { wallets, accountsModeEnabled, accountList, currentAccount ->
val currentWallet = wallets.firstOrNull { it.walletId == userWalletId } ?: userWallet
TopBarTitleInputs(
hasMultipleWallets = wallets.size > 1,
hasMultipleAccounts = accountsModeEnabled && accountList.accounts.size > 1,
walletName = currentWallet.name,
deviceIconUM = walletIconUMConverter.convert(getWalletIconUseCase(currentWallet)),
account = currentAccount,
)
}
.distinctUntilChanged()
.onEach { inputs ->
redesignStateController.update(
SetTopBarTitleTransformer(
cryptoCurrency = cryptoCurrency,
hasMultipleWallets = inputs.hasMultipleWallets,
hasMultipleAccounts = inputs.hasMultipleAccounts,
walletName = inputs.walletName,
deviceIconUM = inputs.deviceIconUM,
account = inputs.account,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private data class TopBarTitleInputs(
val hasMultipleWallets: Boolean,
val hasMultipleAccounts: Boolean,
val walletName: String,
val deviceIconUM: DeviceIconUM,
val account: Account.CryptoPortfolio?,
)
private companion object {
const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L
const val BASE_DERIVATION_NODE_COUNT = 5
}
}

View file

@ -27,4 +27,7 @@ sealed class TokenDetailsBottomSheetConfig : Route {
@Serializable
data object CloreMigration : TokenDetailsBottomSheetConfig()
@Serializable
data object DynamicAddresses : TokenDetailsBottomSheetConfig()
}

View file

@ -4,6 +4,8 @@ import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tokendetails.impl.R
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -23,10 +25,25 @@ internal sealed class TokenDetailsBalanceBlockUM {
override val actionButtons: ImmutableList<TangemButtonUM>,
override val tokenBalanceTypeUM: TokenBalanceTypeUM,
override val currencyIconState: CurrencyIconState,
val displayCryptoBalance: TextReference,
val displayFiatBalance: TextReference,
val displayCryptoBalanceAll: TextReference,
val displayFiatBalanceAll: TextReference,
val displayCryptoBalanceAvailable: TextReference?,
val displayFiatBalanceAvailable: TextReference?,
val isBalanceFlickering: Boolean,
) : TokenDetailsBalanceBlockUM()
) : TokenDetailsBalanceBlockUM() {
val displayCryptoBalance: TextReference
get() = when (tokenBalanceTypeUM.type) {
TokenBalanceTypeUM.Type.ALL -> displayCryptoBalanceAll
TokenBalanceTypeUM.Type.AVAILABLE -> displayCryptoBalanceAvailable ?: displayCryptoBalanceAll
}
val displayFiatBalance: TextReference
get() = when (tokenBalanceTypeUM.type) {
TokenBalanceTypeUM.Type.ALL -> displayFiatBalanceAll
TokenBalanceTypeUM.Type.AVAILABLE -> displayFiatBalanceAvailable ?: displayFiatBalanceAll
}
}
data class Error(
override val actionButtons: ImmutableList<TangemButtonUM>,
@ -34,11 +51,11 @@ internal sealed class TokenDetailsBalanceBlockUM {
override val currencyIconState: CurrencyIconState,
) : TokenDetailsBalanceBlockUM()
fun copyActionButtons(buttons: ImmutableList<TangemButtonUM>): TokenDetailsBalanceBlockUM {
fun copyCurrencyIconState(iconState: CurrencyIconState): TokenDetailsBalanceBlockUM {
return when (this) {
is Content -> this.copy(actionButtons = buttons)
is Error -> this.copy(actionButtons = buttons)
is Loading -> this.copy(actionButtons = buttons)
is Content -> this.copy(currencyIconState = iconState)
is Error -> this.copy(currencyIconState = iconState)
is Loading -> this.copy(currencyIconState = iconState)
}
}
}
@ -54,11 +71,11 @@ internal sealed class TokenBalanceTypeUM {
data class Multiple(
override val type: Type,
val availableTypes: ImmutableList<Type>,
val onSelect: (Type) -> Unit,
val onSelect: () -> Unit,
) : TokenBalanceTypeUM()
enum class Type {
ALL,
AVAILABLE,
enum class Type(val text: TextReference) {
ALL(resourceReference(R.string.token_details_balance_total)),
AVAILABLE(resourceReference(R.string.token_details_balance_available)),
}
}

View file

@ -0,0 +1,75 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class TokenDetailsStateController @Inject constructor() {
val uiState: StateFlow<TokenDetailsUM>
field = MutableStateFlow(value = getInitialState())
val value: TokenDetailsUM get() = uiState.value
fun update(function: (TokenDetailsUM) -> TokenDetailsUM) {
uiState.update(function = function)
}
fun update(transformer: Transformer<TokenDetailsUM>) {
uiState.update(function = transformer::transform)
}
private fun getInitialState(): TokenDetailsUM {
return TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = ""),
subtitle = TextReference.EMPTY,
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(
TangemButtonUM(
text = resourceReference(R.string.tangempay_card_details_add_funds),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_down_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
TangemButtonUM(
text = resourceReference(R.string.common_transfer),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_up_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
notifications = persistentListOf(),
earnBlockState = null,
marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = ""),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
onRefresh = {},
),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}
}

View file

@ -1,24 +1,53 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.compose.runtime.Stable
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@Stable
@Immutable
internal data class TokenDetailsUM(
val topAppBarUM: TokenDetailsTopAppBarUM,
val balanceBlockUM: TokenDetailsBalanceBlockUM,
val notifications: ImmutableList<TangemMessageUM>,
val earnBlockState: EarnBlockUM?,
val marketPriceBlockState: MarketPriceBlockState,
val stakingBlocksState: StakingBlockUM?,
val pullToRefreshConfig: PullToRefreshConfig,
val isBalanceHidden: Boolean,
val isMarketPriceAvailable: Boolean,
)
@Immutable
internal data class TokenDetailsTopAppBarUM(
val title: TextReference,
val titleState: TitleState,
val subtitle: TextReference,
val menuItems: ImmutableList<TextReference>,
)
val onBackClick: () -> Unit,
val menuItems: ImmutableList<TangemDropdownMenuItem>,
) {
@Immutable
sealed interface TitleState {
val tokenName: String
data class Simple(
override val tokenName: String,
) : TitleState
data class WithWallet(
override val tokenName: String,
val walletName: String,
val deviceIconUM: DeviceIconUM,
) : TitleState
data class WithAccount(
override val tokenName: String,
val accountName: TextReference,
val accountIconUM: AccountIconUM.CryptoPortfolio,
) : TitleState
}
}

View file

@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.notifications.CurrencyNotificationConfig
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resourceReference

View file

@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
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.common.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
@ -276,6 +276,17 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
),
)
data class DynamicAddressesFundsFound(
private val onLearnMoreClick: () -> Unit,
) : Warning(
title = resourceReference(id = R.string.dynamic_addresses_notification_funds_found_title),
subtitle = resourceReference(id = R.string.dynamic_addresses_notification_funds_found_description),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(id = R.string.common_learn_more),
onClick = onLearnMoreClick,
),
)
data class YieldSupplyNotTransferedToAave(val tokenName: String, val amount: String) : Warning(
title = resourceReference(
id = R.string.yield_module_amount_not_transfered_to_aave_title,

View file

@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.express
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification
@ -15,6 +16,7 @@ internal data class ExchangeUM(
val statuses: ImmutableList<ExchangeStatusState>,
val notification: ExchangeStatusNotification?,
val showProviderLink: Boolean,
val fromUserWalletId: UserWalletId,
val fromCryptoCurrency: CryptoCurrency,
val toCryptoCurrency: CryptoCurrency,
val hasLongTime: Boolean,

View file

@ -1,7 +1,7 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.tangem.core.ui.extensions.getTintForTokenIcon
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.common.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.IconState

View file

@ -10,6 +10,7 @@ import com.tangem.core.ui.format.bigdecimal.shorted
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.tokens.model.warnings.HederaWarnings
import com.tangem.domain.tokens.model.warnings.KaspaWarnings
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
@ -159,6 +160,9 @@ internal class TokenDetailsNotificationConverter(
onMigrationClick = clickIntents::onCloreMigrationClick,
)
is CryptoCurrencyWarning.UsedOutdatedDataWarning -> UsedOutdatedData
is DynamicAddressesWarnings.FundsFound -> DynamicAddressesFundsFound(
onLearnMoreClick = clickIntents::onDynamicAddressesFundsFoundLearnMoreClick,
)
}
}
@ -168,6 +172,6 @@ internal class TokenDetailsNotificationConverter(
// workaround for networks that users have misunderstanding
private fun CryptoCurrency.shouldMergeFeeNetworkName(): Boolean {
return Blockchain.fromNetworkId(this.network.backendId) == Blockchain.Arbitrum
return Blockchain.fromNetworkId(this.network.rawId) == Blockchain.Arbitrum
}
}

View file

@ -5,7 +5,7 @@ import com.tangem.common.ui.notifications.ExpressNotificationsUM
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList

View file

@ -5,7 +5,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.common.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme

View file

@ -167,12 +167,18 @@ internal class TokenDetailsStateFactory(
userWallet: UserWallet,
hasDerivations: Boolean,
isSupported: Boolean,
isDynamicAddressesAvailable: Boolean = false,
): TokenDetailsState {
return with(currentStateProvider()) {
copy(
topAppBarConfig = topAppBarConfig.copy(
tokenDetailsAppBarMenuConfig = topAppBarConfig.tokenDetailsAppBarMenuConfig
?.updateMenu(userWallet, hasDerivations, isSupported),
?.updateMenu(
userWallet = userWallet,
hasDerivations = hasDerivations,
isSupported = isSupported,
isDynamicAddressesAvailable = isDynamicAddressesAvailable,
),
),
)
}
@ -206,6 +212,7 @@ internal class TokenDetailsStateFactory(
userWallet: UserWallet,
hasDerivations: Boolean,
isSupported: Boolean,
isDynamicAddressesAvailable: Boolean,
): TokenDetailsAppBarMenuConfig? {
if (userWallet is UserWallet.Cold &&
userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
@ -215,6 +222,13 @@ internal class TokenDetailsStateFactory(
return copy(
items = buildList {
if (isDynamicAddressesAvailable) {
TangemDropdownMenuItem(
title = resourceReference(R.string.dynamic_addresses),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = tokenDetailsClickIntents::onDynamicAddressesClick,
).let(::add)
}
if (isSupported && hasDerivations) {
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_generate_xpub),

View file

@ -5,7 +5,7 @@ import com.tangem.common.ui.expressStatus.state.ExpressStatusUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -19,6 +19,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.quote.mapData
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed
@ -38,7 +39,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import java.util.Locale
// Fixme [REDACTED_JIRA]
@Suppress("LargeClass")
@ -98,6 +98,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
val showProviderLink = getShowProviderLink(notification, transaction.status)
result.add(
ExchangeUM(
fromUserWalletId = UserWalletId(swapTransaction.fromUserWalletId),
provider = transaction.provider,
statuses = getStatuses(statusModel?.status),
notification = notification,
@ -230,7 +231,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
} else {
ExchangeStatusNotification.TokenRefunded(
cryptoCurrency = refundToken,
onReadMoreClick = { clickIntents.onOpenUrlClick(url = getAboutCrossChainBridgesLink()) },
onReadMoreClick = clickIntents::onReadAboutCrossChainBridgesClick,
onGoToTokenClick = { clickIntents.onGoToRefundedTokenClick(refundToken) },
)
}
@ -422,12 +423,4 @@ internal class TokenDetailsSwapTransactionsStateConverter(
isDone = isSendingDone,
)
}
private fun getAboutCrossChainBridgesLink(): String {
return if (Locale.getDefault().country == "RU") {
"https://tangem.com/ru/blog/post/an-overview-of-cross-chain-bridges/"
} else {
"https://tangem.com/en/blog/post/an-overview-of-cross-chain-bridges/"
}
}
}

View file

@ -12,8 +12,10 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.*
@ -21,6 +23,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTr
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter
import com.tangem.utils.Provider
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -29,7 +32,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.map
import com.tangem.utils.logging.TangemLogger
@Suppress("LongParameterList")
internal class ExchangeStatusFactory @AssistedInject constructor(
@ -40,6 +42,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val swapTransactionStatusStore: SwapTransactionStatusStore,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val getUserWalletUseCase: GetUserWalletUseCase,
@Assisted private val clickIntents: ExpressTransactionsClickIntents,
@Assisted private val appCurrencyProvider: Provider<AppCurrency>,
@Assisted private val currentStateProvider: Provider<ExpressTransactionsBlockState>,
@ -59,6 +62,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
operator fun invoke(): Flow<PersistentList<ExchangeUM>> {
return swapTransactionRepository.getTransactions(
userWallet = userWallet,
cryptoCurrencyId = cryptoCurrency.id,
).conflate()
.map { savedTransactions ->
@ -93,7 +97,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
return if (swapTx.activeStatus?.isTerminal == true) {
swapTx
} else {
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider)
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId)
if (statusModel != null) {
swapTransactionsStateConverter.updateTxStatus(
@ -106,15 +110,24 @@ internal class ExchangeStatusFactory @AssistedInject constructor(
}
}
private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? {
return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId)
private suspend fun getExchangeStatus(
txId: String,
provider: SwapProvider,
fromUserWalletId: UserWalletId,
): ExchangeStatusModel? {
val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrNull()
return swapRepository.getExchangeStatus(
userWallet = fromUserWallet,
userWalletId = fromUserWalletId,
txId = txId,
)
.fold(
ifLeft = { null },
ifRight = { statusModel ->
sendStatusUpdateAnalytics(statusModel, provider)
val accountId = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = userWallet.walletId,
userWalletId = fromUserWalletId,
currency = cryptoCurrency,
)
.map { it.account.accountId }

View file

@ -1,5 +1,6 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express
import arrow.core.getOrElse
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.datasource.local.swap.ExpressAnalyticsStatus
@ -11,8 +12,10 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.*
@ -21,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter
import com.tangem.utils.Provider
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -29,7 +33,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.map
import com.tangem.utils.logging.TangemLogger
import kotlin.coroutines.cancellation.CancellationException
@Suppress("LongParameterList")
@ -41,6 +44,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val swapTransactionStatusStore: SwapTransactionStatusStore,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val getUserWalletUseCase: GetUserWalletUseCase,
@Assisted private val clickIntents: ExpressTransactionsClickIntents,
@Assisted private val appCurrencyProvider: Provider<AppCurrency>,
@Assisted private val currentStateProvider: Provider<TokenDetailsState>,
@ -94,7 +98,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor(
return if (swapTx.activeStatus?.isTerminal == true) {
swapTx
} else {
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider)
val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId)
if (statusModel != null) {
swapTransactionsStateConverter.updateTxStatus(
@ -107,43 +111,54 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor(
}
}
private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? {
return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId)
.fold(
ifLeft = { null },
ifRight = { statusModel ->
sendStatusUpdateAnalytics(statusModel, provider)
private suspend fun getExchangeStatus(
txId: String,
provider: SwapProvider,
fromUserWalletId: UserWalletId,
): ExchangeStatusModel? {
val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrElse { error ->
TangemLogger.e("Couldn't find userWallet: $error")
return null
}
return swapRepository.getExchangeStatus(
userWallet = fromUserWallet,
userWalletId = fromUserWalletId,
txId = txId,
).fold(
ifLeft = { null },
ifRight = { statusModel ->
sendStatusUpdateAnalytics(statusModel, provider)
val accountId = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = userWallet.walletId,
currency = cryptoCurrency,
)
.map { it.account.accountId }
.getOrNull()
val accountId = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = fromUserWalletId,
currency = cryptoCurrency,
)
.map { it.account.accountId }
.getOrNull()
val refundTokenCurrency = if (accountId != null) {
addRefundCurrencyIfNeeded(
accountId = accountId,
status = statusModel,
type = provider.type,
)
} else {
TangemLogger.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}")
null
}
swapTransactionRepository.storeTransactionState(
txId = txId,
val refundTokenCurrency = if (accountId != null) {
addRefundCurrencyIfNeeded(
accountId = accountId,
status = statusModel,
accountWithCurrency = if (refundTokenCurrency != null) {
Pair(accountId, refundTokenCurrency)
} else {
null
},
type = provider.type,
)
statusModel.copy(refundCurrency = refundTokenCurrency)
},
)
} else {
TangemLogger.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}")
null
}
swapTransactionRepository.storeTransactionState(
txId = txId,
status = statusModel,
accountWithCurrency = if (refundTokenCurrency != null) {
Pair(accountId, refundTokenCurrency)
} else {
null
},
)
statusModel.copy(refundCurrency = refundTokenCurrency)
},
)
}
private suspend fun sendStatusUpdateAnalytics(statusModel: ExchangeStatusModel, provider: SwapProvider) {

View file

@ -0,0 +1,28 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
internal class InitializeWithCryptoCurrencyTransformer(
private val cryptoCurrency: CryptoCurrency,
private val onBackClick: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val iconState = CryptoCurrencyToIconStateConverter().convert(cryptoCurrency)
return prevState.copy(
topAppBarUM = prevState.topAppBarUM.copy(
titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = cryptoCurrency.name),
subtitle = stringReference(cryptoCurrency.symbol),
onBackClick = onBackClick,
),
balanceBlockUM = prevState.balanceBlockUM.copyCurrencyIconState(iconState),
marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol),
)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
internal class SetBalanceLoadingTransformer(
private val currencyIconState: CurrencyIconState,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val prevBalance = prevState.balanceBlockUM
return prevState.copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = prevBalance.actionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = currencyIconState,
),
)
}
}

View file

@ -0,0 +1,131 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.getTotalWithRewardsStakingBalance
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.defaultAmount
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
/**
* Maps [CryptoCurrencyStatus] into [TokenDetailsBalanceBlockUM] and sets it on the state.
*
* Produces [TokenDetailsBalanceBlockUM.Content] for loaded states,
* [TokenDetailsBalanceBlockUM.Loading] for loading,
* [TokenDetailsBalanceBlockUM.Error] for unreachable/no-amount/missed-derivation.
*/
internal class SetBalanceTransformer(
private val status: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val onToggleBalanceType: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val prev = prevState.balanceBlockUM
val balanceBlockUM = when (status.value) {
is CryptoCurrencyStatus.Loading -> TokenDetailsBalanceBlockUM.Loading(
actionButtons = prev.actionButtons,
tokenBalanceTypeUM = prev.tokenBalanceTypeUM,
currencyIconState = prev.currencyIconState,
)
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
is CryptoCurrencyStatus.Custom,
-> buildLoadedContent(prev)
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
-> TokenDetailsBalanceBlockUM.Error(
actionButtons = prev.actionButtons,
tokenBalanceTypeUM = prev.tokenBalanceTypeUM,
currencyIconState = prev.currencyIconState,
)
}
return prevState.copy(balanceBlockUM = balanceBlockUM)
}
private fun buildLoadedContent(prev: TokenDetailsBalanceBlockUM): TokenDetailsBalanceBlockUM.Content {
val stakingCryptoAmount =
(status.value.stakingBalance as? StakingBalance.Data)?.getTotalWithRewardsStakingBalance(
status.currency.network.rawId,
)
val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) }
val hasStaking = !stakingCryptoAmount.isNullOrZero()
val prevType = prev.tokenBalanceTypeUM
val tokenBalanceTypeUM = if (hasStaking) {
TokenBalanceTypeUM.Multiple(
type = (prevType as? TokenBalanceTypeUM.Multiple)?.type ?: TokenBalanceTypeUM.Type.ALL,
availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE),
onSelect = onToggleBalanceType,
)
} else {
TokenBalanceTypeUM.Single
}
return TokenDetailsBalanceBlockUM.Content(
actionButtons = prev.actionButtons,
currencyIconState = prev.currencyIconState,
tokenBalanceTypeUM = tokenBalanceTypeUM,
displayFiatBalanceAll = formatFiatStyled(
fiatAmount = computeTotal(status.value.fiatAmount, stakingFiatAmount),
),
displayCryptoBalanceAll = formatCrypto(
amount = computeTotal(status.value.amount, stakingCryptoAmount),
),
displayFiatBalanceAvailable = if (hasStaking) {
formatFiatStyled(fiatAmount = status.value.fiatAmount)
} else {
null
},
displayCryptoBalanceAvailable = if (hasStaking) {
formatCrypto(amount = status.value.amount)
} else {
null
},
isBalanceFlickering = status.value.sources.total == StatusSource.CACHE,
)
}
private fun computeTotal(base: BigDecimal?, staking: BigDecimal?): BigDecimal? {
if (base == null) return null
return if (staking != null) base + staking else base
}
private fun formatFiatStyled(fiatAmount: BigDecimal?): TextReference {
if (fiatAmount == null) return stringReference(DASH_SIGN)
return fiatAmount.formatStyled {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
).defaultAmount(
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
)
}
}
private fun formatCrypto(amount: BigDecimal?): TextReference {
if (amount == null) return stringReference(DASH_SIGN)
return stringReference(
amount.format { crypto(status.currency).defaultAmount() },
)
}
}

View file

@ -0,0 +1,78 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
import com.tangem.core.res.R as CoreResR
internal class SetTopBarTitleTransformer(
private val cryptoCurrency: CryptoCurrency,
private val hasMultipleWallets: Boolean,
private val hasMultipleAccounts: Boolean,
private val walletName: String,
private val deviceIconUM: DeviceIconUM,
private val account: Account.CryptoPortfolio?,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy(
topAppBarUM = prevState.topAppBarUM.copy(
titleState = createTitleState(),
subtitle = createSubtitle(),
),
)
private fun createTitleState(): TitleState {
val tokenName = cryptoCurrency.name
return when {
hasMultipleAccounts && account != null -> {
val accountNameUM = account.accountName.toUM()
TitleState.WithAccount(
tokenName = tokenName,
accountName = accountNameUM.value,
accountIconUM = AccountIconUM.CryptoPortfolio(
value = account.icon.value,
color = account.icon.color,
),
)
}
hasMultipleWallets -> TitleState.WithWallet(
tokenName = tokenName,
walletName = walletName,
deviceIconUM = deviceIconUM,
)
else -> TitleState.Simple(tokenName = tokenName)
}
}
private fun createSubtitle(): TextReference {
val networkName = cryptoCurrency.network.name
return when (cryptoCurrency) {
is CryptoCurrency.Token -> {
val standardName = cryptoCurrency.network.standardType
.takeIf { it !is Network.StandardType.Unspecified }
?.name
if (standardName != null) {
resourceReference(
CoreResR.string.token_details_toolbar_subtitle_standard,
wrappedList(standardName, networkName),
)
} else {
resourceReference(CoreResR.string.token_details_toolbar_subtitle_network, wrappedList(networkName))
}
}
is CryptoCurrency.Coin -> {
resourceReference(CoreResR.string.token_details_toolbar_subtitle_network, wrappedList(networkName))
}
}
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
/**
* Toggles the balance type between [TokenBalanceTypeUM.Type.ALL] and [TokenBalanceTypeUM.Type.AVAILABLE].
*
* No-op if the current balance state is not [TokenDetailsBalanceBlockUM.Content] or its
* [TokenDetailsBalanceBlockUM.Content.tokenBalanceTypeUM] is not [TokenBalanceTypeUM.Multiple].
*/
internal class ToggleBalanceTypeTransformer : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val content = prevState.balanceBlockUM as? TokenDetailsBalanceBlockUM.Content ?: return prevState
val multiple = content.tokenBalanceTypeUM as? TokenBalanceTypeUM.Multiple ?: return prevState
val nextType = when (multiple.type) {
TokenBalanceTypeUM.Type.ALL -> TokenBalanceTypeUM.Type.AVAILABLE
TokenBalanceTypeUM.Type.AVAILABLE -> TokenBalanceTypeUM.Type.ALL
}
return prevState.copy(
balanceBlockUM = content.copy(
tokenBalanceTypeUM = multiple.copy(type = nextType),
),
)
}
}

View file

@ -0,0 +1,240 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.message.TangemMessageButtonUM
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.tokens.model.warnings.HederaWarnings
import com.tangem.domain.tokens.model.warnings.KaspaWarnings
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import com.tangem.core.res.R as CoreResR
internal class UpdateNotificationsTransformer(
private val warnings: Set<CryptoCurrencyWarning>,
private val clickIntents: TokenDetailsClickIntents,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val notifications = warnings.mapNotNull(::mapWarning).toImmutableList()
return prevState.copy(notifications = notifications)
}
@Suppress("LongMethod")
private fun mapWarning(warning: CryptoCurrencyWarning): TangemMessageUM? {
return when (warning) {
is CryptoCurrencyWarning.SomeNetworksUnreachable -> TangemMessageUM(
id = "networks_unreachable",
title = resourceReference(CoreResR.string.warning_network_unreachable_title),
subtitle = resourceReference(CoreResR.string.warning_network_unreachable_message),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
)
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> createFeeWarning(
FeeWarningParams(
id = "balance_not_enough_for_fee",
currency = warning.tokenCurrency,
networkName = warning.coinCurrency.network.name,
feeCurrencyName = warning.coinCurrency.name,
feeCurrencySymbol = warning.coinCurrency.symbol,
buyCurrency = warning.coinCurrency,
),
)
is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> createFeeWarning(
FeeWarningParams(
id = "custom_token_not_enough_for_fee",
currency = warning.currency,
networkName = warning.feeCurrency?.network?.name ?: warning.networkName,
feeCurrencyName = warning.feeCurrencyName,
feeCurrencySymbol = warning.feeCurrencySymbol,
buyCurrency = warning.feeCurrency,
),
)
is CryptoCurrencyWarning.BeaconChainShutdown -> TangemMessageUM(
id = "beacon_chain_shutdown",
title = resourceReference(CoreResR.string.warning_beacon_chain_retirement_title),
subtitle = resourceReference(CoreResR.string.warning_beacon_chain_retirement_content),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
)
is HederaWarnings.AssociateWarning -> TangemMessageUM(
id = "hedera_associate",
title = resourceReference(CoreResR.string.warning_hedera_missing_token_association_title),
subtitle = resourceReference(CoreResR.string.warning_hedera_missing_token_association_message_brief),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(CoreResR.string.warning_hedera_missing_token_association_button_title),
type = TangemButtonType.Primary,
onClick = clickIntents::onAssociateClick,
),
),
)
is HederaWarnings.AssociateWarningWithFee -> TangemMessageUM(
id = "hedera_associate_fee",
title = resourceReference(CoreResR.string.warning_hedera_missing_token_association_title),
subtitle = resourceReference(
CoreResR.string.warning_hedera_missing_token_association_message,
wrappedList(
warning.fee.format { crypto(symbol = "", decimals = warning.feeCurrencyDecimals) },
warning.feeCurrencySymbol,
),
),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(CoreResR.string.warning_hedera_missing_token_association_button_title),
type = TangemButtonType.Primary,
onClick = clickIntents::onAssociateClick,
),
),
)
is CryptoCurrencyWarning.RequiredTrustline -> TangemMessageUM(
id = "required_trustline",
title = resourceReference(CoreResR.string.warning_token_trustline_title),
subtitle = resourceReference(
CoreResR.string.warning_token_trustline_subtitle,
wrappedList(
warning.requiredAmount.format { crypto(symbol = "", warning.currencyDecimals) }.trim(),
warning.currencySymbol,
),
),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(CoreResR.string.warning_token_trustline_button_title),
type = TangemButtonType.Primary,
onClick = clickIntents::onOpenTrustlineClick,
),
),
)
is KaspaWarnings.IncompleteTransaction -> TangemMessageUM(
id = "kaspa_incomplete",
title = resourceReference(CoreResR.string.warning_kaspa_unfinished_token_transaction_title),
subtitle = resourceReference(
CoreResR.string.warning_kaspa_unfinished_token_transaction_message,
wrappedList(
warning.amount.format { crypto(symbol = "", decimals = warning.currencyDecimals) },
warning.currencySymbol,
),
),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(CoreResR.string.alert_button_try_again),
type = TangemButtonType.Primary,
onClick = clickIntents::onRetryIncompleteTransactionClick,
),
),
onCloseClick = clickIntents::onDismissIncompleteTransactionClick,
)
is CryptoCurrencyWarning.MigrationMaticToPol -> TangemMessageUM(
id = "migration_matic_pol",
title = resourceReference(CoreResR.string.warning_matic_migration_title),
subtitle = resourceReference(CoreResR.string.warning_matic_migration_message),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
)
is CryptoCurrencyWarning.MigrationClore -> TangemMessageUM(
id = "migration_clore",
title = resourceReference(CoreResR.string.warning_clore_migration_title),
subtitle = resourceReference(CoreResR.string.warning_clore_migration_description),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(CoreResR.string.warning_clore_migration_button),
type = TangemButtonType.Primary,
onClick = clickIntents::onCloreMigrationClick,
),
),
)
is DynamicAddressesWarnings.FundsFound -> TangemMessageUM(
id = "dynamic_addresses_funds_found",
title = resourceReference(CoreResR.string.dynamic_addresses_notification_funds_found_title),
subtitle = resourceReference(CoreResR.string.dynamic_addresses_notification_funds_found_description),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(CoreResR.string.common_learn_more),
type = TangemButtonType.Primary,
onClick = clickIntents::onDynamicAddressesFundsFoundLearnMoreClick,
),
),
)
// Non-warning types — skip for redesign
is CryptoCurrencyWarning.ExistentialDeposit,
is CryptoCurrencyWarning.Rent,
is CryptoCurrencyWarning.SomeNetworksNoAccount,
is CryptoCurrencyWarning.TopUpWithoutReserve,
is CryptoCurrencyWarning.SwapPromo,
is CryptoCurrencyWarning.FeeResourceInfo,
is CryptoCurrencyWarning.UsedOutdatedDataWarning,
-> null
}
}
private fun createFeeWarning(params: FeeWarningParams): TangemMessageUM {
val buttons = if (params.buyCurrency != null) {
persistentListOf(
TangemMessageButtonUM(
text = resourceReference(
CoreResR.string.common_buy_currency,
wrappedList(params.feeCurrencySymbol),
),
type = TangemButtonType.Primary,
onClick = { clickIntents.onBuyCoinClick(params.buyCurrency) },
),
)
} else {
persistentListOf()
}
return TangemMessageUM(
id = params.id,
title = resourceReference(
CoreResR.string.warning_send_blocked_funds_for_fee_title,
wrappedList(params.feeCurrencyName),
),
subtitle = resourceReference(
CoreResR.string.warning_send_blocked_funds_for_fee_message,
wrappedList(
params.currency.name,
params.networkName,
params.currency.name,
params.feeCurrencyName,
params.feeCurrencySymbol,
),
),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24),
buttonsUM = buttons,
)
}
private data class FeeWarningParams(
val id: String,
val currency: CryptoCurrency,
val networkName: String,
val feeCurrencyName: String,
val feeCurrencySymbol: String,
val buyCurrency: CryptoCurrency?,
)
}

View file

@ -0,0 +1,282 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.getRewardStakingBalance
import com.tangem.common.getTotalStakingBalance
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.defaultAmount
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.RewardBlockType
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.StakingOption
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.features.tokendetails.impl.R
import com.tangem.lib.crypto.BlockchainUtils.isStakingRewardUnavailable
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
import java.math.BigDecimal
import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.R as CoreUiR
internal class UpdateStakingNotificationTransformer(
private val cryptoCurrencyStatus: CryptoCurrencyStatus,
private val stakingAvailability: StakingAvailability,
private val stakingEntryInfo: StakingEntryInfo?,
private val appCurrency: AppCurrency,
private val clickIntents: TokenDetailsClickIntents,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
return prevState.copy(earnBlockState = buildEarnBlock(prevState.isBalanceHidden))
}
private fun buildEarnBlock(isBalanceHidden: Boolean): EarnBlockUM? {
return when (val availability = stakingAvailability) {
StakingAvailability.TemporaryUnavailable -> buildTemporaryUnavailable()
StakingAvailability.Unavailable -> null
is StakingAvailability.Available -> getStakingInfoBlock(availability, isBalanceHidden)
}
}
private fun buildTemporaryUnavailable(): EarnBlockUM.Content {
return EarnBlockUM.Content(
type = EarnBlockUM.Type.Staking,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_staking_disable_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.staking_native),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Disabled,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.staking_notification_network_error_text),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = null,
)
}
private fun getStakingInfoBlock(
availability: StakingAvailability.Available,
isBalanceHidden: Boolean,
): EarnBlockUM? {
val status = cryptoCurrencyStatus
val stakingBalance = status.value.stakingBalance as? StakingBalance.Data
val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId)
return when {
stakingCryptoAmount.isNullOrZero() && stakingEntryInfo != null -> {
val hasPendingBalances = stakingBalance.hasPendingBalances()
if (!hasPendingBalances) {
buildStakeAvailable(
availability = availability,
isEnabled = isStakingButtonEnabled(status),
)
} else {
buildActiveBlock(
stakingAmount = stakingBalance.getPendingAmount(),
rewardAmount = null,
isBalanceHidden = isBalanceHidden,
)
}
}
stakingCryptoAmount.isNullOrZero() && stakingEntryInfo == null -> null
else -> buildActiveBlock(
stakingAmount = stakingCryptoAmount,
rewardAmount = stakingBalance.getRewardAmount(),
isBalanceHidden = isBalanceHidden,
)
}
}
private fun isStakingButtonEnabled(status: CryptoCurrencyStatus): Boolean {
return status.value is CryptoCurrencyStatus.Loaded ||
status.value is CryptoCurrencyStatus.NoQuote ||
status.value is CryptoCurrencyStatus.Custom
}
private fun buildStakeAvailable(
availability: StakingAvailability.Available,
isEnabled: Boolean,
): EarnBlockUM.Content {
return EarnBlockUM.Content(
type = EarnBlockUM.Type.Staking,
backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(id = R.string.token_details_staking_block_title),
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Accent,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stakeAvailableSubtitle(availability.option.displayApy),
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Primary,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
text = resourceReference(R.string.common_stake),
isEnabled = isEnabled,
),
onClick = clickIntents::onStakeBannerClick,
)
}
private fun stakeAvailableSubtitle(apy: BigDecimal?): TextReference {
return if (apy != null) {
resourceReference(
CoreResR.string.token_details_earn_staking_subtitle,
wrappedList(apy.format { percent() }),
)
} else {
resourceReference(CoreResR.string.staking_notification_earn_rewards_text)
}
}
private fun buildActiveBlock(
stakingAmount: BigDecimal?,
rewardAmount: BigDecimal?,
isBalanceHidden: Boolean,
): EarnBlockUM.Content {
val status = cryptoCurrencyStatus
val fiatRate = status.value.fiatRate
val fiatAmount = stakingAmount?.let { fiatRate?.multiply(it) }
val rewardFiatAmount = rewardAmount?.let { fiatRate?.multiply(it) }
return EarnBlockUM.Content(
type = EarnBlockUM.Type.Staking,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.staking_native),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = getRewardSubtitle(status, rewardFiatAmount),
trailingUM = EarnBlockUM.TrailingUM.Balance(
fiatValue = fiatAmount.formatStyled {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
).defaultAmount(
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
)
},
cryptoValue = stringReference(
stakingAmount.format {
crypto(
symbol = status.currency.symbol,
decimals = status.currency.decimals,
)
},
),
isBalanceHidden = isBalanceHidden,
),
onClick = clickIntents::onStakeBannerClick,
)
}
private fun getRewardSubtitle(
status: CryptoCurrencyStatus,
stakingRewardAmount: BigDecimal?,
): EarnBlockUM.SubtitleUM? {
val blockchainId = status.currency.network.rawId
val isCoin = status.currency.id.isCoin
val stakingBalance = status.value.stakingBalance
val rewardBlockType = when {
stakingBalance is StakingBalance.Data.P2PEthPool -> {
if (stakingBalance.totalRewards.isNullOrZero()) {
RewardBlockType.RewardUnavailable.DefaultRewardUnavailable
} else {
RewardBlockType.EthereumEarnedRewards
}
}
isStakingRewardUnavailable(blockchainId, isCoin) -> {
RewardBlockType.RewardUnavailable.DefaultRewardUnavailable
}
stakingRewardAmount.isNullOrZero() -> RewardBlockType.NoRewards
else -> RewardBlockType.Rewards
}
val text = when (rewardBlockType) {
RewardBlockType.NoRewards -> resourceReference(R.string.staking_details_no_rewards_to_claim)
RewardBlockType.CardanoNoRewards -> resourceReference(R.string.staking_cardano_details_rewards_info_text)
RewardBlockType.RewardUnavailable.DefaultRewardUnavailable,
RewardBlockType.RewardUnavailable.SolanaRewardUnavailable,
-> return null
RewardBlockType.EthereumEarnedRewards -> {
val cryptoRewardAmount = (stakingBalance as? StakingBalance.Data.P2PEthPool)?.totalRewards
resourceReference(
R.string.staking_details_autocompound_rewards_earned,
wrappedList(
cryptoRewardAmount.format {
crypto(
symbol = status.currency.symbol,
decimals = status.currency.decimals,
)
},
),
)
}
RewardBlockType.RewardsRequirementsError,
RewardBlockType.Rewards,
-> resourceReference(
R.string.staking_details_rewards_to_claim,
wrappedList(
stakingRewardAmount.format { fiat(appCurrency.code, appCurrency.symbol) },
),
)
}
val isAccent = rewardBlockType == RewardBlockType.Rewards ||
rewardBlockType == RewardBlockType.RewardsRequirementsError ||
rewardBlockType == RewardBlockType.EthereumEarnedRewards
return EarnBlockUM.SubtitleUM.Text(
text = text,
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = if (isAccent) EarnBlockUM.SubtitleUM.Tone.Accent else EarnBlockUM.SubtitleUM.Tone.Disabled,
)
}
}
private fun StakingBalance.Data?.hasPendingBalances(): Boolean = when (this) {
is StakingBalance.Data.StakeKit -> balance.items.isNotEmpty()
is StakingBalance.Data.P2PEthPool -> !unstakingAmount.isNullOrZero()
null -> false
}
private fun StakingBalance.Data?.getPendingAmount(): BigDecimal = when (this) {
is StakingBalance.Data.StakeKit -> balance.items.sumOf { it.amount }
is StakingBalance.Data.P2PEthPool -> unstakingAmount
null -> BigDecimal.ZERO
}
private fun StakingBalance.Data?.getRewardAmount(): BigDecimal = when (this) {
is StakingBalance.Data.StakeKit -> getRewardStakingBalance()
is StakingBalance.Data.P2PEthPool -> totalRewards
null -> BigDecimal.ZERO
}
private val StakingOption.displayApy: BigDecimal?
get() = when (this) {
is StakingOption.StakeKit -> yield.preferredValidators
.mapNotNull { it.rewardInfo?.rate }
.maxOrNull()
is StakingOption.P2PEthPool -> apy
}

View file

@ -0,0 +1,51 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
internal class UpdateTopBarMenuTransformer(
private val userWallet: UserWallet,
private val hasDerivations: Boolean,
private val isXPubSupported: Boolean,
private val onGenerateExtendedKey: () -> Unit,
private val onHideClick: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy(
topAppBarUM = prevState.topAppBarUM.copy(menuItems = createMenuItems()),
)
private fun createMenuItems() = if (userWallet is UserWallet.Cold &&
userWallet.cardTypesResolver.isSingleWalletWithToken()
) {
persistentListOf()
} else {
buildList {
if (isXPubSupported && hasDerivations) {
add(
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_generate_xpub),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = onGenerateExtendedKey,
),
)
}
add(
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_hide_token),
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = onHideClick,
),
)
}.toImmutableList()
}
}

View file

@ -1,30 +1,279 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import com.tangem.common.ui.earn.EarnBlock
import com.tangem.common.ui.notifications.notifications
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.LocalRootBackgroundColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeTint
import kotlinx.collections.immutable.persistentListOf
private val TopBarHeight: Dp = 64.dp
private val MarketBlockHorizontalPadding: Dp = 14.dp
@Suppress("UnusedParameter")
@Composable
internal fun TokenDetailsScreen(
tokenDetailsUM: TokenDetailsUM,
tokenMarketBlockComponent: TokenMarketBlockComponent?,
yieldSupplyComponent: YieldSupplyComponent,
modifier: Modifier = Modifier,
) {
val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() }
val partialCollapsedHeight = TopBarHeight + statusBarHeight
val expandedHeight = TokenDetailsBalanceBlockHeight + partialCollapsedHeight
val behavior = rememberTangemExitUntilCollapsedScrollBehavior(
expandedHeight = expandedHeight,
partialCollapsedHeight = partialCollapsedHeight,
)
val rootBackground by LocalRootBackgroundColor.current
var marketBlockHeight by remember { mutableStateOf(0.dp) }
val notificationModifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens2.x4)
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "Token Details Redesign",
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
Box(
modifier = Modifier
.fillMaxSize()
.hazeSourceTangem(zIndex = -2f),
) {
TangemCollapsingTopBar(
state = behavior.state,
collapsingPart = {
TokenDetailsBalanceBlock(
balanceBlockUM = tokenDetailsUM.balanceBlockUM,
behavior = behavior,
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(top = TopBarHeight),
)
},
body = {
TokenDetailsBody(
tokenDetailsUM = tokenDetailsUM,
yieldSupplyComponent = yieldSupplyComponent,
rootBackground = rootBackground,
bottomContentPadding = marketBlockHeight,
modifier = Modifier
.fillMaxSize()
.nestedScroll(behavior.nestedScrollConnection),
itemModifier = notificationModifier,
)
},
)
}
TokenDetailsTopBarOverlay(
topAppBarUM = tokenDetailsUM.topAppBarUM,
collapsedFraction = behavior.state.collapsedFraction,
rootBackground = rootBackground,
)
if (tokenMarketBlockComponent != null) {
TokenDetailsMarketBlockOverlay(
component = tokenMarketBlockComponent,
rootBackground = rootBackground,
onHeightChange = { marketBlockHeight = it },
)
}
}
}
@Composable
private fun TokenDetailsTopBarOverlay(
topAppBarUM: TokenDetailsTopAppBarUM,
collapsedFraction: Float,
rootBackground: Color,
) {
val hazeIntensity by animateFloatAsState(
targetValue = (collapsedFraction * 2f).coerceIn(0f, 1f),
label = "TopBarHazeIntensity",
)
Box(
modifier = Modifier.hazeEffectTangem {
fallbackTint = HazeTint(rootBackground.copy(alpha = hazeIntensity / 2f))
progressive = HazeProgressive.verticalGradient(
startIntensity = hazeIntensity,
endIntensity = 0f,
preferPerformance = true,
)
},
) {
TokenDetailsTopBar(topAppBarUM = topAppBarUM)
}
}
@Composable
private fun BoxScope.TokenDetailsMarketBlockOverlay(
component: TokenMarketBlockComponent,
rootBackground: Color,
onHeightChange: (Dp) -> Unit,
) {
val density = LocalDensity.current
BottomFade(
gradientBrush = Brush.verticalGradient(
colors = listOf(
rootBackground.copy(alpha = 0f),
rootBackground,
),
),
modifier = Modifier.align(Alignment.BottomCenter),
)
component.Content(
modifier = Modifier
.align(Alignment.BottomCenter)
.onSizeChanged { size ->
onHeightChange(with(density) { size.height.toDp() })
}
.navigationBarsPadding()
.padding(
horizontal = MarketBlockHorizontalPadding,
vertical = TangemTheme.dimens2.x1_5,
),
)
}
@Composable
private fun TokenDetailsBody(
tokenDetailsUM: TokenDetailsUM,
yieldSupplyComponent: YieldSupplyComponent,
rootBackground: Color,
bottomContentPadding: Dp,
modifier: Modifier = Modifier,
itemModifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(bottom = bottomContentPadding),
) {
notifications(
notifications = tokenDetailsUM.notifications,
contentColor = rootBackground,
modifier = itemModifier,
)
tokenDetailsUM.earnBlockState?.let { earnBlock ->
item(key = "staking_block") {
EarnBlock(
state = earnBlock,
modifier = itemModifier,
)
}
}
item(key = "yield_supply_block") {
yieldSupplyComponent.Content(modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x2))
}
// TODO [REDACTED_TASK_KEY] Token Details Make Transaction History
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TokenDetailsScreen_Preview() {
TangemThemePreviewRedesign {
TokenDetailsScreen(
tokenMarketBlockComponent = null,
tokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.WithAccount(
tokenName = "Tether",
accountName = stringReference("Portfolio"),
accountIconUM = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
),
),
subtitle = stringReference("ERC-20 in Ethereum network"),
onBackClick = {},
menuItems = persistentListOf(
TangemDropdownMenuItem(
title = stringReference("Hide Token"),
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = {},
),
),
),
notifications = persistentListOf(),
earnBlockState = null,
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = "USDT"),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
onRefresh = {},
),
isBalanceHidden = false,
isMarketPriceAvailable = true,
),
yieldSupplyComponent = object : YieldSupplyComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
},
)
}
}
}
// endregion Preview

View file

@ -31,7 +31,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsTopAppBar
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenInfoBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.ExpressStatusBottomSheet
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlockLegacy
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.txhistory.component.TxHistoryComponent
import com.tangem.features.txhistory.entity.TxHistoryUM
@ -133,7 +133,7 @@ internal fun TokenDetailsScreenLegacy(
key = StakingBlockUM::class.java,
contentType = StakingBlockUM::class.java,
content = {
TokenStakingBlock(
TokenStakingBlockLegacy(
state = state.stakingBlocksState,
isBalanceHidden = state.isBalanceHidden,
modifier = itemModifier,

View file

@ -0,0 +1,478 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.statusBarsPadding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarActionContent
import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import kotlinx.collections.immutable.persistentListOf
import com.tangem.core.ui.R as CoreUiR
@Composable
internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: Modifier = Modifier) {
TangemTopBar(
modifier = modifier.statusBarsPadding(),
startContent = {
TangemTopBarActionContent(
actionUM = TangemTopBarActionUM(
iconRes = CoreUiR.drawable.ic_back_24,
onClick = topAppBarUM.onBackClick,
ghostModeProgress = 1f,
),
)
},
endContent = if (topAppBarUM.menuItems.isNotEmpty()) {
{
var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) }
Box {
TangemTopBarActionContent(
actionUM = TangemTopBarActionUM(
iconRes = CoreUiR.drawable.ic_more_default_24,
onClick = { isDropdownMenuShown = true },
ghostModeProgress = 1f,
),
)
TangemDropdownMenu(
expanded = isDropdownMenuShown,
modifier = Modifier.background(TangemTheme.colors.background.primary),
onDismissRequest = { isDropdownMenuShown = false },
content = {
topAppBarUM.menuItems.fastForEach { menuItem ->
TangemDropdownItem(
item = menuItem,
dismissParent = { isDropdownMenuShown = false },
)
}
},
)
}
}
} else {
null
},
content = {
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = TangemTheme.dimens2.x1),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5),
) {
TokenDetailsTitle(titleState = topAppBarUM.titleState)
Text(
text = topAppBarUM.subtitle.resolveAnnotatedReference(),
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.captionMedium12,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
)
}
@Composable
private fun TokenDetailsTitle(titleState: TitleState) {
val appearance = TitleAppearance(
style = TangemTheme.typography2.bodySemibold16,
iconSize = TangemTheme.dimens2.x5,
spacing = TangemTheme.dimens2.x1,
)
when (titleState) {
is TitleState.Simple -> {
Text(
text = titleState.tokenName,
color = TangemTheme.colors2.text.neutral.primary,
style = appearance.style,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = MIN_TITLE_FONT_SIZE,
maxFontSize = MAX_TITLE_FONT_SIZE,
),
)
}
is TitleState.WithWallet -> {
AdaptiveTokenWithSecondaryRow(
tokenName = titleState.tokenName,
secondaryName = titleState.walletName,
template = stringResourceSafe(
id = CoreUiR.string.token_details_toolbar_title_token_in_wallet,
titleState.tokenName,
titleState.walletName,
),
appearance = appearance,
icon = {
TangemDeviceIcon(
state = titleState.deviceIconUM,
modifier = Modifier.size(appearance.iconSize),
)
},
)
}
is TitleState.WithAccount -> {
val accountNameStr = titleState.accountName.resolveAnnotatedReference().toString()
AdaptiveTokenWithSecondaryRow(
tokenName = titleState.tokenName,
secondaryName = accountNameStr,
template = stringResourceSafe(
id = CoreUiR.string.token_details_toolbar_title_token_in_account,
titleState.tokenName,
accountNameStr,
),
appearance = appearance,
icon = {
AccountIcon(
name = titleState.accountName,
icon = titleState.accountIconUM,
size = AccountIconSize.ExtraSmall,
)
},
)
}
}
}
/**
* Adaptive title for [TitleState.WithAccount] / [TitleState.WithWallet].
*
* Phrase template carries the [IMAGE_PLACEHOLDER] marker translator decides where
* the icon sits (e.g. "Tether in [⭐] Portfolio" or "Tether in My Wallet [⭐]").
* RTL is handled by BiDi inside the single [Text].
*
* Width-driven cascade:
* 12. Full phrase as single [Text] with inline icon; [TextOverflow.Ellipsis]
* trims the secondary name tail when needed.
* 3. No meaningful tail left fall back to `[tokenName] [icon]` in a [Row];
* icon is a sibling so ellipsis trims only tokenName, never the icon.
* 45. tokenName itself doesn't fit [TextAutoSize] shrinks to
* [MIN_TITLE_FONT_SIZE], then [TextOverflow.Ellipsis] tails.
*
* #1/#2 vs #3 is decided here via [rememberTextMeasurer]; #4/#5 are delegated
* to [TextAutoSize] + [TextOverflow.Ellipsis] in the fallback branch.
*/
@Composable
private fun AdaptiveTokenWithSecondaryRow(
tokenName: String,
secondaryName: String,
template: String,
appearance: TitleAppearance,
icon: @Composable () -> Unit,
) {
val (beforeIcon, afterIcon) = remember(template) { splitTemplate(template) }
val inlineContent = rememberIconInlineContent(appearance.iconSize, icon)
BoxWithConstraints(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val isFullTextShown = rememberShouldShowFullText(
beforeIcon = beforeIcon,
afterIcon = afterIcon,
secondaryName = secondaryName,
appearance = appearance,
maxWidthPx = constraints.maxWidth,
)
if (isFullTextShown) {
FullPhraseTitle(
beforeIcon = beforeIcon,
afterIcon = afterIcon,
style = appearance.style,
inlineContent = inlineContent,
)
} else {
FallbackTokenWithIconTitle(
tokenName = tokenName,
appearance = appearance,
icon = icon,
)
}
}
}
private fun splitTemplate(template: String): Pair<String, String> {
val parts = template.split(IMAGE_PLACEHOLDER, limit = 2)
return if (parts.size == 2) parts[0] to parts[1] else template to ""
}
@Composable
private fun rememberIconInlineContent(iconSize: Dp, icon: @Composable () -> Unit): Map<String, InlineTextContent> {
val iconSizeSp = with(LocalDensity.current) { iconSize.toSp() }
val currentIcon by rememberUpdatedState(icon)
return remember(iconSizeSp) {
mapOf(
ICON_INLINE_ID to InlineTextContent(
placeholder = Placeholder(
width = iconSizeSp,
height = iconSizeSp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
children = {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
currentIcon()
}
},
),
)
}
}
@Composable
private fun rememberShouldShowFullText(
beforeIcon: String,
afterIcon: String,
secondaryName: String,
appearance: TitleAppearance,
maxWidthPx: Int,
): Boolean {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(beforeIcon, afterIcon, secondaryName, maxWidthPx, appearance, density) {
if (maxWidthPx <= 0) return@remember true
val fullTextWidthPx = measurer
.measure(text = beforeIcon + afterIcon, style = appearance.style, softWrap = false)
.size.width
val secondaryWidthPx = measurer
.measure(text = secondaryName, style = appearance.style, softWrap = false)
.size.width
val staticWidthPx = (fullTextWidthPx - secondaryWidthPx).coerceAtLeast(0)
val iconReservePx = with(density) {
(appearance.iconSize + appearance.spacing * 2).toPx()
}.toInt()
val minSecondaryPx = with(density) { MIN_SECONDARY_NAME_WIDTH.toPx() }.toInt()
staticWidthPx + iconReservePx + minSecondaryPx <= maxWidthPx
}
}
@Composable
private fun FullPhraseTitle(
beforeIcon: String,
afterIcon: String,
style: TextStyle,
inlineContent: Map<String, InlineTextContent>,
) {
val fullText = remember(beforeIcon, afterIcon) {
buildAnnotatedString {
append(beforeIcon)
appendInlineContent(ICON_INLINE_ID, IMAGE_PLACEHOLDER)
append(afterIcon)
}
}
Text(
text = fullText,
inlineContent = inlineContent,
color = TangemTheme.colors2.text.neutral.primary,
style = style,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun FallbackTokenWithIconTitle(tokenName: String, appearance: TitleAppearance, icon: @Composable () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(appearance.spacing, Alignment.CenterHorizontally),
) {
Text(
text = tokenName,
color = TangemTheme.colors2.text.neutral.primary,
style = appearance.style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = MIN_TITLE_FONT_SIZE,
maxFontSize = MAX_TITLE_FONT_SIZE,
),
modifier = Modifier.weight(weight = 1f, fill = false),
)
icon()
}
}
@Immutable
private data class TitleAppearance(
val style: TextStyle,
val iconSize: Dp,
val spacing: Dp,
)
private const val ICON_INLINE_ID = "account_icon"
private const val IMAGE_PLACEHOLDER = "%image%"
private val MIN_TITLE_FONT_SIZE = 12.sp
private val MAX_TITLE_FONT_SIZE = 16.sp
private val MIN_SECONDARY_NAME_WIDTH = 48.dp
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TokenDetailsTopBar_Preview(
@PreviewParameter(TokenDetailsTopBarPreviewProvider::class) titleState: TitleState,
) {
TangemThemePreviewRedesign {
TokenDetailsTopBar(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = titleState,
subtitle = stringReference("ERC-20 in Ethereum network"),
onBackClick = {},
menuItems = persistentListOf(
TangemDropdownMenuItem(
title = stringReference("Hide Token"),
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = {},
),
),
),
)
}
}
private class TokenDetailsTopBarPreviewProvider : PreviewParameterProvider<TitleState> {
override val values: Sequence<TitleState>
get() = sequenceOf(
// === Base title states ===
// Simple — 1 wallet, 1 account
TitleState.Simple(tokenName = "Tether"),
// WithWallet — N wallets, 1 account
TitleState.WithWallet(
tokenName = "Tether",
walletName = "Tangem wallet",
deviceIconUM = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
),
),
// WithAccount — 1 wallet, N accounts
TitleState.WithAccount(
tokenName = "Tether",
accountName = stringReference("Portfolio"),
accountIconUM = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
),
),
// === AdaptiveTokenTitleRow cascade cases ===
// Cascade #1 — full text fits as is (short token + short wallet)
TitleState.WithWallet(
tokenName = "BTC",
walletName = "Main",
deviceIconUM = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
),
),
// Cascade #2 — full text doesn't fit, ellipsized tail still meaningful
TitleState.WithWallet(
tokenName = "Tether",
walletName = "My Long Tangem Hardware Wallet",
deviceIconUM = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
),
),
// Cascade #3 — secondary part too small to be meaningful, drop to token-only + icon
TitleState.WithWallet(
tokenName = "USDCoinWrapped",
walletName = "Super Extra Long Wallet Name That Definitely Wont Fit",
deviceIconUM = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
),
),
// Cascade #4 — even tokenName alone doesn't fit at full font size: TextAutoSize shrinks it
TitleState.WithAccount(
tokenName = "VeryLongTokenNameThatOverflowsVeryLongTokenNameThatOverflows",
accountName = stringReference("Portfolio"),
accountIconUM = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
),
),
// Cascade #5 — even at MIN_TITLE_FONT_SIZE doesn't fit: TextOverflow.Ellipsis tails it
TitleState.Simple(
tokenName = "ExtremelyLongTokenNameThatCannotPossiblyFitEvenAtMinFontSize",
),
// Account variant — long account name triggers ellipsized tail (#2)
TitleState.WithAccount(
tokenName = "Tether",
accountName = stringReference("My Personal Long Account Name"),
accountIconUM = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
),
),
// Account variant — drop secondary, icon-only fallback (#3)
TitleState.WithAccount(
tokenName = "USDCoinWrapped",
accountName = stringReference("Super Extra Long Account Name That Wont Fit Anywhere"),
accountIconUM = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
),
),
)
}
// endregion Preview

View file

@ -0,0 +1,34 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.model.DynamicAddressesDelegate
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheet
internal class DynamicAddressesBottomSheetComponent(
private val dynamicAddressesDelegate: DynamicAddressesDelegate,
private val onDismiss: () -> Unit,
) : ComposableBottomSheetComponent {
override fun dismiss() {
onDismiss()
}
@Composable
override fun BottomSheet() {
val content by dynamicAddressesDelegate.bottomSheetConfig.collectAsStateWithLifecycle()
val config = remember(content) {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = content,
)
}
DynamicAddressesBottomSheet(config = config)
}
}

View file

@ -0,0 +1,258 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.button.action.ActionButtons
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalRootBackgroundColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.features.tokendetails.impl.R
import kotlinx.collections.immutable.persistentListOf
private val CurrencyIconSize: Dp = 70.dp
private val NetworkBadgeSize: Dp = 24.dp
internal val TokenDetailsBalanceBlockHeight: Dp = 404.dp
private const val MIN_SCALE = 0.75f
private const val MAX_SCALE = 1f
@Composable
internal fun TokenDetailsBalanceBlock(
balanceBlockUM: TokenDetailsBalanceBlockUM,
behavior: TangemCollapsingAppBarBehavior,
modifier: Modifier = Modifier,
) {
val rootBackground by LocalRootBackgroundColor.current
val collapsedFraction = behavior.state.collapsedFraction
val alpha = 1f - collapsedFraction
val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.alpha(alpha)
.scale(scale)
.snapToExitUntilCollapsed(behavior)
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x10),
) {
CurrencyIcon(
state = balanceBlockUM.currencyIconState,
shouldDisplayNetwork = true,
iconSize = CurrencyIconSize,
networkBadgeSize = NetworkBadgeSize,
networkBadgeBackground = rootBackground,
)
SpacerH(TangemTheme.dimens2.x3)
when (balanceBlockUM) {
is TokenDetailsBalanceBlockUM.Content -> ContentBody(state = balanceBlockUM)
is TokenDetailsBalanceBlockUM.Loading -> LoadingBody()
is TokenDetailsBalanceBlockUM.Error -> ErrorBody()
}
SpacerH(TangemTheme.dimens2.x10)
ActionButtons(buttons = balanceBlockUM.actionButtons)
}
}
@Composable
private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content) {
AnimatedContent(
targetState = state.tokenBalanceTypeUM.type,
label = "Token balance type",
) { currentType ->
val tokenBalanceTypeUM = state.tokenBalanceTypeUM
when (tokenBalanceTypeUM) {
is TokenBalanceTypeUM.Multiple -> Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
modifier = Modifier.clickable(onClick = tokenBalanceTypeUM.onSelect),
) {
Text(
text = currentType.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_sort_24),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.secondary,
modifier = Modifier.size(TangemTheme.dimens2.x4),
)
}
TokenBalanceTypeUM.Single -> Text(
text = currentType.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
}
SpacerH(TangemTheme.dimens2.x2)
Text(
text = state.displayFiatBalance.resolveAnnotatedReference(),
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
)
SpacerH(TangemTheme.dimens2.x2_5)
Text(
text = state.displayCryptoBalance.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
@Composable
private fun LoadingBody() {
Text(
text = TokenBalanceTypeUM.Type.ALL.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
)
SpacerH(TangemTheme.dimens2.x2)
TextShimmer(
style = TangemTheme.typography2.titleRegular44,
text = "$1234567890",
radius = TangemTheme.dimens2.x6,
)
SpacerH(TangemTheme.dimens2.x2)
TextShimmer(
style = TangemTheme.typography2.bodySemibold16,
text = "12345.67",
radius = TangemTheme.dimens2.x4,
)
}
@Composable
private fun ErrorBody() {
Text(
text = "",
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
)
SpacerH(TangemTheme.dimens2.x2_5)
Text(
text = "",
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TokenDetailsBalanceBlock_Preview(
@PreviewParameter(PreviewProvider::class) params: TokenDetailsBalanceBlockUM,
) {
TangemThemePreviewRedesign {
TokenDetailsBalanceBlock(
balanceBlockUM = params,
behavior = rememberTangemExitUntilCollapsedScrollBehavior(),
modifier = Modifier.background(TangemTheme.colors2.surface.level2),
)
}
}
private class PreviewProvider : PreviewParameterProvider<TokenDetailsBalanceBlockUM> {
private val previewActionButtons = persistentListOf(
TangemButtonUM(
text = stringReference("Add funds"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_arrow_down_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
TangemButtonUM(
text = stringReference("Transfer"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_arrow_up_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
)
override val values: Sequence<TokenDetailsBalanceBlockUM>
get() = sequenceOf(
TokenDetailsBalanceBlockUM.Content(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = TokenBalanceTypeUM.Type.ALL,
availableTypes = persistentListOf(
TokenBalanceTypeUM.Type.ALL,
TokenBalanceTypeUM.Type.AVAILABLE,
),
onSelect = { },
),
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("0.0613884 BTC"),
displayFiatBalanceAll = stringReference("$12,380.94"),
displayCryptoBalanceAvailable = stringReference("0.05 BTC"),
displayFiatBalanceAvailable = stringReference("$10,000.00"),
isBalanceFlickering = false,
),
TokenDetailsBalanceBlockUM.Content(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("123.456 USDT"),
displayFiatBalanceAll = stringReference("$123.45"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
),
TokenDetailsBalanceBlockUM.Loading(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
TokenDetailsBalanceBlockUM.Error(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
)
}
// endregion

View file

@ -0,0 +1,38 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
import androidx.compose.runtime.Composable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.R as CoreR
@Composable
internal fun DynamicAddressesBottomSheet(config: TangemBottomSheetConfig) {
TangemModalBottomSheet<DynamicAddressesBottomSheetConfig>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
title = {
TangemModalBottomSheetTitle(
endIconRes = CoreR.drawable.ic_close_24,
onEndClick = config.onDismissRequest,
)
},
) { content ->
when (content) {
is DynamicAddressesBottomSheetConfig.Enable -> DynamicAddressesEnableContent(content = content)
is DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation -> {
DynamicAddressesDisableWithoutConsolidationContent(content = content)
}
is DynamicAddressesBottomSheetConfig.DisableWithConsolidation -> {
DynamicAddressesDisableWithConsolidationContent(content = content)
}
is DynamicAddressesBottomSheetConfig.ConflictingCustomTokens -> {
DynamicAddressesConflictingCustomTokensContent(content = content)
}
is DynamicAddressesBottomSheetConfig.ServiceUnavailable -> {
DynamicAddressesServiceUnavailableContent(content = content)
}
}
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
@Immutable
internal sealed class DynamicAddressesBottomSheetConfig : TangemBottomSheetConfigContent {
data class Enable(
val isCardScanRequired: Boolean,
val isLoading: Boolean = false,
val onEnableClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
data class DisableWithoutConsolidation(
val onDisableClick: () -> Unit,
val onReadMoreClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
data class DisableWithConsolidation(
val feeState: DisableFeeState = DisableFeeState.Loading,
val isSending: Boolean = false,
val onDisableClick: () -> Unit,
val onRefreshFee: () -> Unit,
val onReadMoreClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
sealed interface DisableFeeState {
data object Loading : DisableFeeState
data class Content(
val feeSymbol: String,
val fiatFormatted: String,
) : DisableFeeState
data object Error : DisableFeeState
}
data class ConflictingCustomTokens(
val onDismissClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
data class ServiceUnavailable(
val onDismissClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
}

View file

@ -0,0 +1,529 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.res.painterResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.audits.AuditLabel
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.res.R
import com.tangem.core.ui.R as CoreR
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig.DisableFeeState
@Composable
internal fun DynamicAddressesEnableContent(content: DynamicAddressesBottomSheetConfig.Enable) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_top),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size44),
tint = TangemTheme.colors.icon.accent,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
Text(
text = stringResourceSafe(id = R.string.dynamic_addresses),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
Text(
text = stringResourceSafe(id = R.string.dynamic_addresses_enter_subtitle),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
FeatureItem(
iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_flash_24,
title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_title),
description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_description),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
FeatureItem(
iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_check_24,
title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_title),
description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_description),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
PrimaryButtonIconEnd(
text = stringResourceSafe(id = R.string.dynamic_addresses_enter_main_button_title),
iconResId = if (content.isCardScanRequired) CoreR.drawable.ic_tangem_24 else null,
onClick = content.onEnableClick,
modifier = Modifier.fillMaxWidth(),
showProgress = content.isLoading,
enabled = !content.isLoading,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
}
}
@Composable
internal fun DynamicAddressesDisableWithoutConsolidationContent(
content: DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
DisableHeader()
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
PrimaryButtonIconEnd(
text = stringResourceSafe(id = R.string.dynamic_addresses_disable_main_button_title),
iconResId = null,
onClick = content.onDisableClick,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
}
}
@Composable
internal fun DynamicAddressesDisableWithConsolidationContent(
content: DynamicAddressesBottomSheetConfig.DisableWithConsolidation,
) {
val isConfirmEnabled = content.feeState is DisableFeeState.Content && !content.isSending
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
DisableHeader()
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
DisableFeeBlock(
feeState = content.feeState,
onReadMoreClick = content.onReadMoreClick,
)
if (content.feeState is DisableFeeState.Error) {
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
Notification(
config = NotificationConfig(
title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(R.string.send_fee_unreachable_error_text),
iconResId = CoreR.drawable.ic_alert_24,
iconTint = NotificationConfig.IconTint.Warning,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = content.onRefreshFee,
),
),
containerColor = TangemTheme.colors.background.action,
)
}
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
PrimaryButtonIconEnd(
text = stringResourceSafe(id = R.string.dynamic_addresses_disable_main_button_title),
iconResId = CoreR.drawable.ic_tangem_24,
onClick = content.onDisableClick,
modifier = Modifier.fillMaxWidth(),
showProgress = content.isSending,
enabled = isConfirmEnabled,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
}
}
@Composable
private fun DisableHeader() {
Icon(
painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size44),
tint = TangemTheme.colors.icon.attention,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
Text(
text = stringResourceSafe(id = R.string.dynamic_addresses_disable_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
Text(
text = stringResourceSafe(id = R.string.dynamic_addresses_disable_description),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
@Composable
private fun DisableFeeBlock(feeState: DisableFeeState, onReadMoreClick: () -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(TangemTheme.dimens.spacing12),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResourceSafe(id = R.string.common_network_fee_title),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
when (feeState) {
is DisableFeeState.Loading -> TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier.size(
width = TangemTheme.dimens.size80,
height = TangemTheme.dimens.spacing16,
),
)
is DisableFeeState.Content -> Row(
verticalAlignment = Alignment.CenterVertically,
) {
AuditLabel(
state = AuditLabelUM(
text = stringReference(feeState.feeSymbol),
type = AuditLabelUM.Type.General,
),
)
Text(
text = feeState.fiatFormatted,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
)
}
is DisableFeeState.Error -> Text(
text = "\u2014",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
}
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
DisableFeeDescription(onReadMoreClick = onReadMoreClick)
}
}
@Composable
private fun DisableFeeDescription(onReadMoreClick: () -> Unit) {
val readMoreText = stringResourceSafe(id = R.string.common_read_more)
val fullText = stringResourceSafe(id = R.string.dynamic_addresses_disable_fee_description)
val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(fullText)
append(" ")
}
withLink(
link = LinkAnnotation.Clickable(
tag = "read_more",
linkInteractionListener = { onReadMoreClick() },
),
) {
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
append(readMoreText)
}
}
}
Text(
text = annotatedString,
style = TangemTheme.typography.caption2,
)
}
@Composable
internal fun DynamicAddressesConflictingCustomTokensContent(
content: DynamicAddressesBottomSheetConfig.ConflictingCustomTokens,
) {
ErrorContent(
titleRes = R.string.dynamic_addresses_error_has_custom_token_title,
descriptionRes = R.string.dynamic_addresses_error_has_custom_token_description,
buttonTextRes = R.string.common_got_it,
onButtonClick = content.onDismissClick,
)
}
@Composable
internal fun DynamicAddressesServiceUnavailableContent(content: DynamicAddressesBottomSheetConfig.ServiceUnavailable) {
ErrorContent(
titleRes = R.string.dynamic_addresses_error_service_unavailable_title,
descriptionRes = R.string.dynamic_addresses_error_service_unavailable_description,
buttonTextRes = R.string.common_got_it,
onButtonClick = content.onDismissClick,
)
}
@Composable
private fun ErrorContent(titleRes: Int, descriptionRes: Int, buttonTextRes: Int, onButtonClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size44),
tint = TangemTheme.colors.icon.attention,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
Text(
text = stringResourceSafe(id = titleRes),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
Text(
text = stringResourceSafe(id = descriptionRes),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
PrimaryButton(
text = stringResourceSafe(id = buttonTextRes),
onClick = onButtonClick,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
}
}
@Composable
private fun FeatureItem(iconRes: Int, title: String, description: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size24),
tint = TangemTheme.colors.icon.accent,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing4))
Text(
text = description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
// region Previews
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_Enable() {
TangemThemePreview {
DynamicAddressesEnableContent(
content = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = false,
onEnableClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_EnableWithCardScan() {
TangemThemePreview {
DynamicAddressesEnableContent(
content = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = true,
onEnableClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DisableWithoutConsolidation() {
TangemThemePreview {
DynamicAddressesDisableWithoutConsolidationContent(
content = DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation(
onDisableClick = {},
onReadMoreClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DisableFeeLoading() {
TangemThemePreview {
DynamicAddressesDisableWithConsolidationContent(
content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DisableFeeState.Loading,
onDisableClick = {},
onRefreshFee = {},
onReadMoreClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DisableFeeLoaded() {
TangemThemePreview {
DynamicAddressesDisableWithConsolidationContent(
content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DisableFeeState.Content(
feeSymbol = "BTC",
fiatFormatted = "~$0.12",
),
onDisableClick = {},
onRefreshFee = {},
onReadMoreClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DisableFeeError() {
TangemThemePreview {
DynamicAddressesDisableWithConsolidationContent(
content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DisableFeeState.Error,
onDisableClick = {},
onRefreshFee = {},
onReadMoreClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DisableSending() {
TangemThemePreview {
DynamicAddressesDisableWithConsolidationContent(
content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DisableFeeState.Content(
feeSymbol = "BTC",
fiatFormatted = "~$0.12",
),
isSending = true,
onDisableClick = {},
onRefreshFee = {},
onReadMoreClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ConflictingCustomTokens() {
TangemThemePreview {
DynamicAddressesConflictingCustomTokensContent(
content = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(onDismissClick = {}),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ServiceUnavailable() {
TangemThemePreview {
DynamicAddressesServiceUnavailableContent(
content = DynamicAddressesBottomSheetConfig.ServiceUnavailable(onDismissClick = {}),
)
}
}
// endregion

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrency.ID
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.SwapProvider
@ -23,6 +24,7 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider<ExpressSt
get() = sequenceOf(
ExpressStatusBottomSheetConfig(
ExchangeUM(
fromUserWalletId = UserWalletId("AnyUserWalletID"),
info = ExpressTransactionStateInfoUM(
title = TextReference.Str("Transaction Status"),
status = ExpressStatusUM(
@ -104,7 +106,6 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider<ExpressSt
name = "Network One",
isTestnet = false,
standardType = Network.StandardType.ERC20,
backendId = "network1",
currencySymbol = "ETH",
derivationPath = Network.DerivationPath.None,
hasFiatFeeRate = true,

View file

@ -42,7 +42,7 @@ import com.tangem.features.tokendetails.impl.R
* @param modifier modifier
*/
@Composable
internal fun TokenStakingBlock(state: StakingBlockUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
internal fun TokenStakingBlockLegacy(state: StakingBlockUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
@ -60,16 +60,16 @@ internal fun TokenStakingBlock(state: StakingBlockUM, isBalanceHidden: Boolean,
targetState = state,
contentAlignment = Alignment.CenterStart,
label = "Staking block animation",
) {
when (it) {
) { stakingBlock ->
when (stakingBlock) {
is StakingBlockUM.TemporaryUnavailable -> StakingTemporaryUnavailableBlock()
is StakingBlockUM.Loading -> StakingLoading()
is StakingBlockUM.Staked -> StakingBalanceBlock(
state = it,
state = stakingBlock,
isBalanceHidden = isBalanceHidden,
)
is StakingBlockUM.StakeAvailable -> StakingAvailableContent(
state = it,
state = stakingBlock,
)
}
}
@ -165,7 +165,7 @@ private fun Preview_TokenStakingBlock(
state: StakingBlockUM,
) {
TangemThemePreview {
TokenStakingBlock(
TokenStakingBlockLegacy(
state = state,
isBalanceHidden = false,
)

View file

@ -0,0 +1,491 @@
package com.tangem.feature.tokendetails.deeplink
import arrow.core.Either
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.DERIVATION_PATH_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.error.SelectWalletError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.SelectWalletUseCase
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger
import com.tangem.utils.logging.TangemLogger
import io.mockk.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultTokenDetailsDeepLinkHandlerTest {
private val appRouter: AppRouter = mockk()
private val selectWalletUseCase: SelectWalletUseCase = mockk()
private val getSelectedWalletSync: GetSelectedWalletSyncUseCase = mockk()
private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk()
private val tokenDetailsDeepLinkActionTrigger: TokenDetailsDeepLinkActionTrigger = mockk()
private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger = mockk()
private val analyticsEventHandler: AnalyticsEventHandler = mockk()
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
private val walletBalanceFetcher: WalletBalanceFetcher = mockk()
private val tangemPayFeatureToggles: TangemPayFeatureToggles = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
mockkObject(TangemLogger)
every { analyticsEventHandler.send(any()) } just Runs
every { appRouter.push(any(), any()) } just Runs
val userWallet: UserWallet = mockk()
every { userWallet.walletId } returns mockk()
every { getSelectedWalletSync() } returns Either.Right(
value = userWallet
)
}
@Test
fun `GIVEN error instead of user wallet WHEN handle deeplink THEN get error`() = runTest {
val queryParams = mapOf(WALLET_ID_KEY to "011")
every {
getUserWalletUseCase.invoke(
userWalletId = UserWalletId(
"011"
)
)
} returns Either.Left(
value = GetUserWalletError.UserWalletNotFound
)
every { TangemLogger.e("Error on getting user wallet") } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify { TangemLogger.e("Error on getting user wallet") }
}
@Test
fun `GIVEN locked user wallet WHEN handle deeplink THEN get error`() = runTest {
val queryParams = mapOf(WALLET_ID_KEY to "011")
every {
getUserWalletUseCase.invoke(
userWalletId = UserWalletId(
"011"
)
)
} returns Either.Right(
value = mockk { every { isLocked } returns true }
)
every { TangemLogger.e("Error on getting user wallet") } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify { TangemLogger.e("Error on getting user wallet") }
}
@Test
fun `GIVEN error instead select wallet WHEN handle deeplink THEN get error`() = runTest {
val queryParams = mapOf(WALLET_ID_KEY to "011")
val userWalletId = UserWalletId("011")
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk { every { isLocked } returns false }
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Left(
value = SelectWalletError.UnableToSelectUserWallet
)
every { TangemLogger.e("Error on selecting user wallet") } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify { TangemLogger.e("Error on selecting user wallet") }
}
@Test
fun `GIVEN no crypto by wallet WHEN handle deeplink THEN get error`() = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
)
val userWalletId = UserWalletId("011")
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null
val expectedErrorText = """
Could not get crypto currency for
|- $NETWORK_ID_KEY: 123
|- $TOKEN_ID_KEY: 321
""".trimIndent()
every { TangemLogger.e(messageString = expectedErrorText) } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify { TangemLogger.e(messageString = expectedErrorText) }
}
@Test
fun `GIVEN multicurrency wallet WHEN handle deeplink THEN push new route`() = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777"
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
val expectedRoute = AppRoute.CurrencyDetails(
userWalletId = userWalletId,
currency = expectedCryptoCurrency,
)
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns true
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify {
appRouter.push(
route = expectedRoute,
onComplete = any(),
)
}
}
@Test
fun `GIVEN single currency wallet WHEN handle deeplink THEN push new route`() = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777"
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify {
walletDeepLinkActionTrigger.selectWallet(userWalletId)
}
}
@ParameterizedTest
@ValueSource(strings = ["swap_status_update", "onramp_status_update"])
fun `GIVEN type WHEN handle deeplink THEN token details deeplink triggered`(type: String) = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777",
TRANSACTION_ID_KEY to "000",
TYPE_KEY to type,
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs
coEvery { tokenDetailsDeepLinkActionTrigger.trigger("000") } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
coVerify {
tokenDetailsDeepLinkActionTrigger.trigger("000")
}
}
@ParameterizedTest
@ValueSource(strings = ["income_transaction", "promo", "unknown"])
fun `GIVEN type WHEN handle deeplink THEN token details deeplink not triggered`(type: String) = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777",
TRANSACTION_ID_KEY to "000",
TYPE_KEY to type,
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs
coEvery { tokenDetailsDeepLinkActionTrigger.trigger("000") } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
coVerify(exactly = 0) {
tokenDetailsDeepLinkActionTrigger.trigger("000")
}
}
@Test
fun `GIVEN multicurrency wallet AND isFromOnNewIntent WHEN handle deeplink THEN fetch currency`() = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777"
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns true
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
every {
cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = expectedCryptoCurrency)
} just Runs
createHandler(scope = this, queryParams, isFromOnNewIntent = true)
advanceUntilIdle()
verify { cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = expectedCryptoCurrency) }
}
@Test
fun `GIVEN single currency wallet AND isFromOnNewIntent WHEN handle deeplink THEN fetch currency`() = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777"
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
every { tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled } returns true
coEvery {
walletBalanceFetcher.invoke(
WalletBalanceFetcher.Params(
userWalletId = userWalletId,
isPaymentAccountRefactorEnabled = true
)
)
} returns mockk()
every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs
createHandler(scope = this, queryParams, isFromOnNewIntent = true)
advanceUntilIdle()
coEvery {
walletBalanceFetcher.invoke(
WalletBalanceFetcher.Params(
userWalletId = userWalletId,
isPaymentAccountRefactorEnabled = true
)
)
}
}
private fun createHandler(
scope: CoroutineScope,
queryParams: Map<String, String>,
isFromOnNewIntent: Boolean = false,
) {
DefaultTokenDetailsDeepLinkHandler(
scope = scope,
queryParams = queryParams,
isFromOnNewIntent = isFromOnNewIntent,
appRouter = appRouter,
selectWalletUseCase = selectWalletUseCase,
cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher,
tokenDetailsDeepLinkActionTrigger = tokenDetailsDeepLinkActionTrigger,
walletDeepLinkActionTrigger = walletDeepLinkActionTrigger,
analyticsEventHandler = analyticsEventHandler,
getUserWalletUseCase = getUserWalletUseCase,
walletBalanceFetcher = walletBalanceFetcher,
tangemPayFeatureToggles = tangemPayFeatureToggles,
singleAccountListSupplier = singleAccountListSupplier,
getSelectedWalletSyncUseCase = getSelectedWalletSync,
)
}
}

View file

@ -0,0 +1,146 @@
package com.tangem.feature.tokendetails.domain
import arrow.core.none
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class GetCurrencyWarningsUseCaseTest {
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
private val currenciesRepository: CurrenciesRepository = mockk(relaxed = true)
private val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true)
private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true)
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true)
private val dynamicAddressesRepository: DynamicAddressesRepository = mockk(relaxed = true)
private val dispatchers: CoroutineDispatcherProvider = TestDispatchers(Dispatchers.Unconfined)
private val userWalletId: UserWalletId = mockk(relaxed = true)
private val network: Network = mockk(relaxed = true)
private val derivationPath: Network.DerivationPath = mockk(relaxed = true)
private val accountStatusList: AccountStatusList = mockk(relaxed = true)
private val useCase = GetCurrencyWarningsUseCase(
walletManagersFacade = walletManagersFacade,
currenciesRepository = currenciesRepository,
dispatchers = dispatchers,
currencyChecksRepository = currencyChecksRepository,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
dynamicAddressesRepository = dynamicAddressesRepository,
)
@BeforeEach
fun setUp() {
// Bypass coin-related warnings flow: force both coin and token lookups to None so
// the use case falls through to `SomeNetworksUnreachable` without needing real data.
mockkObject(CryptoCurrencyStatusOperations)
with(CryptoCurrencyStatusOperations) {
every { accountStatusList.getCoinStatus(any<CryptoCurrency>()) } returns none()
every { accountStatusList.getCryptoCurrencyStatus(any<CryptoCurrency>()) } returns none()
}
every { singleAccountStatusListSupplier(any<UserWalletId>()) } returns flowOf(accountStatusList)
coEvery { currencyChecksRepository.getRentInfoWarning(any(), any()) } returns null
coEvery { currencyChecksRepository.getExistentialDeposit(any(), any()) } returns null
coEvery { currencyChecksRepository.getFeeResourceAmount(any(), any()) } returns null
coEvery { walletManagersFacade.getAssetRequirements(any(), any()) } returns null
}
@AfterEach
fun tearDown() {
unmockkObject(CryptoCurrencyStatusOperations)
}
@Test
fun `GIVEN token currency WHEN invoke THEN FundsFound is absent and probe flow is not queried`() = runTest {
// GIVEN
val token: CryptoCurrency.Token = mockk(relaxed = true) {
every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network
}
val currencyStatus = statusFor(token)
// WHEN
val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first()
// THEN
assertThat(result).doesNotContain(DynamicAddressesWarnings.FundsFound)
verify(exactly = 0) {
dynamicAddressesRepository.hasFundsOnAdditionalAddresses(any(), any())
}
}
@Test
fun `GIVEN coin currency AND probe emits true WHEN invoke THEN FundsFound is present`() = runTest {
// GIVEN
val coin: CryptoCurrency.Coin = mockk(relaxed = true) {
every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network
}
every { dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, network) } returns flowOf(true)
val currencyStatus = statusFor(coin)
// WHEN
val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first()
// THEN
assertThat(result).contains(DynamicAddressesWarnings.FundsFound)
}
@Test
fun `GIVEN coin currency AND probe emits false WHEN invoke THEN FundsFound is absent`() = runTest {
// GIVEN
val coin: CryptoCurrency.Coin = mockk(relaxed = true) {
every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network
}
every { dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, network) } returns flowOf(false)
val currencyStatus = statusFor(coin)
// WHEN
val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first()
// THEN
assertThat(result).doesNotContain(DynamicAddressesWarnings.FundsFound)
}
private fun statusFor(currency: CryptoCurrency): CryptoCurrencyStatus {
return mockk(relaxed = true) {
every { this@mockk.currency } returns currency
}
}
private class TestDispatchers(dispatcher: CoroutineDispatcher) : CoroutineDispatcherProvider {
override val main: CoroutineDispatcher = dispatcher
override val mainImmediate: CoroutineDispatcher = dispatcher
override val io: CoroutineDispatcher = dispatcher
override val default: CoroutineDispatcher = dispatcher
override val single: CoroutineDispatcher = dispatcher
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
class TokenDetailsNotificationConverterTest {
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
private val userWalletId: UserWalletId = mockk(relaxed = true)
private val converter = TokenDetailsNotificationConverter(
userWalletId = userWalletId,
getUserWalletUseCase = getUserWalletUseCase,
clickIntents = clickIntents,
)
@Test
fun `GIVEN FundsFound warning WHEN convert THEN DynamicAddressesFundsFound notification is produced`() {
// WHEN
val result = converter.convert(setOf(DynamicAddressesWarnings.FundsFound))
// THEN
assertThat(result).hasSize(1)
assertThat(result.first()).isInstanceOf(TokenDetailsNotification.DynamicAddressesFundsFound::class.java)
}
@Test
fun `GIVEN FundsFound warning WHEN learn more button clicked THEN click intent is invoked`() {
// GIVEN
val notification = converter.convert(setOf(DynamicAddressesWarnings.FundsFound)).first()
val button = notification.config.buttonsState as NotificationConfig.ButtonsState.SecondaryButtonConfig
// WHEN
button.onClick()
// THEN
verify(exactly = 1) { clickIntents.onDynamicAddressesFundsFoundLearnMoreClick() }
}
}

View file

@ -0,0 +1,135 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class InitializeWithCryptoCurrencyTransformerTest {
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) {
every { name } returns TOKEN_NAME
every { symbol } returns TOKEN_SYMBOL
}
private val onBackClick: () -> Unit = mockk(relaxed = true)
@Test
fun `GIVEN crypto currency WHEN transform THEN top bar title is Simple with token name`() {
// GIVEN
val transformer = InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = onBackClick,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = TOKEN_NAME))
}
@Test
fun `GIVEN crypto currency WHEN transform THEN subtitle is token symbol`() {
// GIVEN
val transformer = InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = onBackClick,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.subtitle).isEqualTo(stringReference(TOKEN_SYMBOL))
}
@Test
fun `GIVEN onBackClick callback WHEN top bar onBackClick invoked THEN callback is dispatched`() {
// GIVEN
val transformer = InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = onBackClick,
)
// WHEN
val result = transformer.transform(initialState())
result.topAppBarUM.onBackClick()
// THEN
verify(exactly = 1) { onBackClick.invoke() }
}
@Test
fun `GIVEN crypto currency WHEN transform THEN market price loading carries currency symbol`() {
// GIVEN
val transformer = InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = onBackClick,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.marketPriceBlockState)
.isEqualTo(MarketPriceBlockState.Loading(currencySymbol = TOKEN_SYMBOL))
}
@Test
fun `GIVEN any state WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = initialState()
val transformer = InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = onBackClick,
)
// WHEN
val result = transformer.transform(state)
// THEN — only top bar title/subtitle/onBackClick and marketPriceBlockState are touched
assertThat(result.topAppBarUM.menuItems).isEqualTo(state.topAppBarUM.menuItems)
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(state.balanceBlockUM.actionButtons)
assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(state.balanceBlockUM.tokenBalanceTypeUM)
assertThat(result.earnBlockState).isEqualTo(state.earnBlockState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = mockk(relaxed = true),
),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
private companion object {
const val TOKEN_NAME = "Tether"
const val TOKEN_SYMBOL = "USDT"
}
}

View file

@ -0,0 +1,144 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class SetBalanceLoadingTransformerTest {
private val currencyIconState: CurrencyIconState = mockk(relaxed = true)
@Test
fun `GIVEN any state WHEN transform THEN balance block is Loading`() {
// GIVEN
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java)
}
@Test
fun `GIVEN currency icon state WHEN transform THEN Loading block carries that icon state`() {
// GIVEN
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.currencyIconState).isSameInstanceAs(currencyIconState)
}
@Test
fun `GIVEN state with action buttons WHEN transform THEN action buttons are preserved`() {
// GIVEN
val buttons = persistentListOf(
TangemButtonUM(
text = stringReference("Test"),
onClick = {},
isEnabled = true,
type = TangemButtonType.Secondary,
),
)
val state = initialState(actionButtons = buttons)
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(buttons)
}
@Test
fun `GIVEN any state WHEN transform THEN balance type is Single`() {
// GIVEN
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(TokenBalanceTypeUM.Single)
}
@Test
fun `GIVEN Content balance block WHEN transform THEN switches to Loading`() {
// GIVEN
val contentState = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("1.0 BTC"),
displayFiatBalanceAll = stringReference("$50,000"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
),
)
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(contentState)
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java)
}
@Test
fun `GIVEN any state WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = initialState()
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
assertThat(result.earnBlockState).isEqualTo(state.earnBlockState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
private fun initialState(
actionButtons: ImmutableList<TangemButtonUM> = persistentListOf(),
): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = actionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,482 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.common.getTotalWithRewardsStakingBalance
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class SetBalanceTransformerTest {
private val onToggleBalanceType: () -> Unit = mockk(relaxed = true)
private val appCurrency: AppCurrency = AppCurrency.Default
private val network: Network = mockk(relaxed = true) {
every { rawId } returns "ethereum"
}
private val currency: CryptoCurrency = mockk(relaxed = true) {
every { this@mockk.network } returns this@SetBalanceTransformerTest.network
every { symbol } returns "ETH"
}
@BeforeEach
fun setup() {
mockkStatic(StakingBalance.Data::getTotalWithRewardsStakingBalance)
}
@AfterEach
fun teardown() {
unmockkStatic(StakingBalance.Data::getTotalWithRewardsStakingBalance)
}
// region Status type → BalanceBlock type mapping
@Test
fun `GIVEN Loading status WHEN transform THEN balance block is Loading`() {
// GIVEN
val status = createStatus(CryptoCurrencyStatus.Loading)
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java)
}
@Test
fun `GIVEN Loaded status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN NoQuote status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(noQuoteValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN NoAccount status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(noAccountValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN Custom status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(customValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN Unreachable status WHEN transform THEN balance block is Error`() {
// GIVEN
val status = createStatus(
CryptoCurrencyStatus.Unreachable(priceChange = null, fiatRate = null, networkAddress = null),
)
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java)
}
@Test
fun `GIVEN NoAmount status WHEN transform THEN balance block is Error`() {
// GIVEN
val status = createStatus(CryptoCurrencyStatus.NoAmount(priceChange = null, fiatRate = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java)
}
@Test
fun `GIVEN MissedDerivation status WHEN transform THEN balance block is Error`() {
// GIVEN
val status = createStatus(CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java)
}
// endregion
// region Action buttons & icon preservation
@Test
fun `GIVEN any loaded status WHEN transform THEN action buttons are preserved`() {
// GIVEN
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(initialState().balanceBlockUM.actionButtons)
}
@Test
fun `GIVEN any loaded status WHEN transform THEN currency icon state is preserved`() {
// GIVEN
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.currencyIconState)
.isEqualTo(initialState().balanceBlockUM.currencyIconState)
}
// endregion
// region Staking / balance type
@Test
fun `GIVEN loaded status without staking WHEN transform THEN balance type is Single`() {
// GIVEN
val status = createStatus(loadedValue(stakingBalance = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.tokenBalanceTypeUM).isEqualTo(TokenBalanceTypeUM.Single)
}
@Test
fun `GIVEN loaded status with staking WHEN transform THEN balance type is Multiple`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.tokenBalanceTypeUM).isInstanceOf(TokenBalanceTypeUM.Multiple::class.java)
}
@Test
fun `GIVEN loaded status with staking WHEN transform THEN available balance types include ALL and AVAILABLE`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.availableTypes).containsExactly(
TokenBalanceTypeUM.Type.ALL,
TokenBalanceTypeUM.Type.AVAILABLE,
)
}
@Test
fun `GIVEN staking balance WHEN Multiple onSelect invoked THEN onToggleBalanceType is called`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
multiple.onSelect()
// THEN
verify(exactly = 1) { onToggleBalanceType.invoke() }
}
@Test
fun `GIVEN staking and previous Multiple type AVAILABLE WHEN transform THEN selected type is preserved`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
val prevContent = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = TokenBalanceTypeUM.Type.AVAILABLE,
availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE),
onSelect = {},
),
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference(""),
displayFiatBalanceAll = stringReference(""),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
)
val state = initialState().copy(balanceBlockUM = prevContent)
// WHEN
val result = transformer.transform(state)
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.AVAILABLE)
}
// endregion
// region Balance flickering
@Test
fun `GIVEN CACHE source WHEN transform THEN isBalanceFlickering is true`() {
// GIVEN
val sources = CryptoCurrencyStatus.Sources(
networkSource = StatusSource.CACHE,
quoteSource = StatusSource.CACHE,
stakingBalanceSource = StatusSource.CACHE,
)
val status = createStatus(loadedValue(sources = sources))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.isBalanceFlickering).isTrue()
}
@Test
fun `GIVEN ACTUAL source WHEN transform THEN isBalanceFlickering is false`() {
// GIVEN
val sources = CryptoCurrencyStatus.Sources(
networkSource = StatusSource.ACTUAL,
quoteSource = StatusSource.ACTUAL,
stakingBalanceSource = StatusSource.ACTUAL,
)
val status = createStatus(loadedValue(sources = sources))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.isBalanceFlickering).isFalse()
}
// endregion
// region No staking → available balances
@Test
fun `GIVEN loaded without staking WHEN transform THEN available balances are null`() {
// GIVEN
val status = createStatus(loadedValue(stakingBalance = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayCryptoBalanceAvailable).isNull()
assertThat(content.displayFiatBalanceAvailable).isNull()
}
@Test
fun `GIVEN staking balance WHEN transform THEN available balances are not null`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayCryptoBalanceAvailable).isNotNull()
assertThat(content.displayFiatBalanceAvailable).isNotNull()
}
// endregion
// region Unrelated fields preserved
@Test
fun `GIVEN any status WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = initialState()
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
assertThat(result.earnBlockState).isEqualTo(state.earnBlockState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
// endregion
private fun createTransformer(status: CryptoCurrencyStatus) = SetBalanceTransformer(
status = status,
appCurrency = appCurrency,
onToggleBalanceType = onToggleBalanceType,
)
private fun createStatus(value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(
amount: BigDecimal = BigDecimal("10.5"),
fiatAmount: BigDecimal = BigDecimal("21000"),
fiatRate: BigDecimal = BigDecimal("2000"),
stakingBalance: StakingBalance? = null,
sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(),
): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = fiatAmount,
fiatRate = fiatRate,
priceChange = BigDecimal("2.5"),
stakingBalance = stakingBalance,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = sources,
)
private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote(
amount = BigDecimal("5.0"),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
private fun noAccountValue(): CryptoCurrencyStatus.NoAccount = CryptoCurrencyStatus.NoAccount(
amountToCreateAccount = BigDecimal("0.01"),
fiatAmount = BigDecimal.ZERO,
priceChange = null,
fiatRate = BigDecimal("2000"),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
private fun customValue(): CryptoCurrencyStatus.Custom = CryptoCurrencyStatus.Custom(
amount = BigDecimal("100"),
fiatAmount = null,
fiatRate = null,
priceChange = null,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,205 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import androidx.compose.ui.graphics.Color
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class SetTopBarTitleTransformerTest {
private val tokenName = "Tether"
private val walletName = "My Wallet"
private val deviceIconUM: DeviceIconUM = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
)
private val cryptoCurrency: CryptoCurrency.Coin = mockk(relaxed = true) {
every { name } returns tokenName
}
@Test
fun `GIVEN single wallet single account WHEN transform THEN Simple title`() {
// GIVEN
val transformer = createTransformer(
hasMultipleWallets = false,
hasMultipleAccounts = false,
account = null,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = tokenName))
}
@Test
fun `GIVEN multiple wallets and single account WHEN transform THEN WithWallet title`() {
// GIVEN
val transformer = createTransformer(
hasMultipleWallets = true,
hasMultipleAccounts = false,
account = null,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val expected = TitleState.WithWallet(
tokenName = tokenName,
walletName = walletName,
deviceIconUM = deviceIconUM,
)
assertThat(result.topAppBarUM.titleState).isEqualTo(expected)
}
@Test
fun `GIVEN single wallet and multiple accounts WHEN transform THEN WithAccount title`() {
// GIVEN
val transformer = createTransformer(
hasMultipleWallets = false,
hasMultipleAccounts = true,
account = stubAccount(),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val title = result.topAppBarUM.titleState
assertThat(title).isInstanceOf(TitleState.WithAccount::class.java)
assertThat((title as TitleState.WithAccount).tokenName).isEqualTo(tokenName)
}
@Test
fun `GIVEN multiple wallets AND multiple accounts WHEN transform THEN WithAccount wins`() {
// GIVEN — design priority: account branch wins over wallet branch
val transformer = createTransformer(
hasMultipleWallets = true,
hasMultipleAccounts = true,
account = stubAccount(),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.titleState).isInstanceOf(TitleState.WithAccount::class.java)
}
@Test
fun `GIVEN multiple accounts but null account WHEN transform THEN falls back to wallet branch`() {
// GIVEN — race protection: hasMultipleAccounts=true but account not loaded yet
val transformer = createTransformer(
hasMultipleWallets = true,
hasMultipleAccounts = true,
account = null,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.titleState).isInstanceOf(TitleState.WithWallet::class.java)
}
@Test
fun `GIVEN multiple accounts but null account AND single wallet WHEN transform THEN falls back to Simple`() {
// GIVEN
val transformer = createTransformer(
hasMultipleWallets = false,
hasMultipleAccounts = true,
account = null,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = tokenName))
}
@Test
fun `GIVEN account with custom icon WHEN transform THEN icon is propagated`() {
// GIVEN
val account = stubAccount(
iconValue = CryptoPortfolioIcon.Icon.Star,
iconColor = CryptoPortfolioIcon.Color.Azure,
)
val transformer = createTransformer(
hasMultipleWallets = false,
hasMultipleAccounts = true,
account = account,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val title = result.topAppBarUM.titleState as TitleState.WithAccount
val expectedIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
)
assertThat(title.accountIconUM).isEqualTo(expectedIcon)
}
private fun createTransformer(
hasMultipleWallets: Boolean,
hasMultipleAccounts: Boolean,
account: Account.CryptoPortfolio?,
) = SetTopBarTitleTransformer(
cryptoCurrency = cryptoCurrency,
hasMultipleWallets = hasMultipleWallets,
hasMultipleAccounts = hasMultipleAccounts,
walletName = walletName,
deviceIconUM = deviceIconUM,
account = account,
)
private fun stubAccount(
iconValue: CryptoPortfolioIcon.Icon = CryptoPortfolioIcon.Icon.Star,
iconColor: CryptoPortfolioIcon.Color = CryptoPortfolioIcon.Color.Azure,
): Account.CryptoPortfolio {
val icon: CryptoPortfolioIcon = mockk {
every { value } returns iconValue
every { color } returns iconColor
}
return mockk {
every { accountName } returns AccountName.DefaultMain
every { this@mockk.icon } returns icon
}
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,204 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class ToggleBalanceTypeTransformerTest {
private val transformer = ToggleBalanceTypeTransformer()
// region Toggle logic
@Test
fun `GIVEN Multiple with ALL WHEN transform THEN type switches to AVAILABLE`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
// WHEN
val result = transformer.transform(state)
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.AVAILABLE)
}
@Test
fun `GIVEN Multiple with AVAILABLE WHEN transform THEN type switches to ALL`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.AVAILABLE)
// WHEN
val result = transformer.transform(state)
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.ALL)
}
@Test
fun `GIVEN Multiple with ALL WHEN transform twice THEN type returns to ALL`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
// WHEN
val result = transformer.transform(transformer.transform(state))
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.ALL)
}
// endregion
// region No-op cases
@Test
fun `GIVEN Loading balance block WHEN transform THEN state is unchanged`() {
// GIVEN
val state = initialState()
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
@Test
fun `GIVEN Error balance block WHEN transform THEN state is unchanged`() {
// GIVEN
val state = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Error(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
@Test
fun `GIVEN Content with Single balance type WHEN transform THEN state is unchanged`() {
// GIVEN
val state = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("1.0 ETH"),
displayFiatBalanceAll = stringReference("$2,000"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
// endregion
// region Unrelated fields preserved
@Test
fun `GIVEN any togglable state WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
assertThat(result.earnBlockState).isEqualTo(state.earnBlockState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
@Test
fun `GIVEN togglable state WHEN transform THEN balance content fields besides type are preserved`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
val originalContent = state.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
// WHEN
val result = transformer.transform(state)
// THEN
val resultContent = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(resultContent.actionButtons).isEqualTo(originalContent.actionButtons)
assertThat(resultContent.currencyIconState).isEqualTo(originalContent.currencyIconState)
assertThat(resultContent.displayCryptoBalanceAll).isEqualTo(originalContent.displayCryptoBalanceAll)
assertThat(resultContent.displayFiatBalanceAll).isEqualTo(originalContent.displayFiatBalanceAll)
assertThat(resultContent.isBalanceFlickering).isEqualTo(originalContent.isBalanceFlickering)
}
// endregion
private fun stateWithContent(type: TokenBalanceTypeUM.Type): TokenDetailsUM {
return initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = type,
availableTypes = persistentListOf(
TokenBalanceTypeUM.Type.ALL,
TokenBalanceTypeUM.Type.AVAILABLE,
),
onSelect = {},
),
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("10.5 ETH"),
displayFiatBalanceAll = stringReference("$21,000"),
displayCryptoBalanceAvailable = stringReference("9.0 ETH"),
displayFiatBalanceAvailable = stringReference("$18,000"),
isBalanceFlickering = false,
),
)
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,572 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings
import com.tangem.domain.tokens.model.warnings.HederaWarnings
import com.tangem.domain.tokens.model.warnings.KaspaWarnings
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class UpdateNotificationsTransformerTest {
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
// region Mapping warnings to notifications
@Test
fun `GIVEN empty warnings WHEN transform THEN notifications are empty`() {
// GIVEN
val transformer = createTransformer(warnings = emptySet())
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN SomeNetworksUnreachable WHEN transform THEN notification with id networks_unreachable is created`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(CryptoCurrencyWarning.SomeNetworksUnreachable),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("networks_unreachable")
}
@Test
fun `GIVEN BalanceNotEnoughForFee WHEN transform THEN notification with id balance_not_enough_for_fee is created`() {
// GIVEN
val tokenCurrency: CryptoCurrency = mockk(relaxed = true)
val coinCurrency: CryptoCurrency = mockk(relaxed = true) {
io.mockk.every { network } returns mockk(relaxed = true) {
io.mockk.every { name } returns "Ethereum"
}
io.mockk.every { name } returns "Ethereum"
io.mockk.every { symbol } returns "ETH"
}
val transformer = createTransformer(
warnings = setOf(
CryptoCurrencyWarning.BalanceNotEnoughForFee(
tokenCurrency = tokenCurrency,
coinCurrency = coinCurrency,
),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("balance_not_enough_for_fee")
assertThat(result.notifications.first().buttonsUM).hasSize(1)
}
@Test
fun `GIVEN CustomTokenNotEnoughForFee with null feeCurrency WHEN transform THEN notification has no buttons`() {
// GIVEN
val currency: CryptoCurrency = mockk(relaxed = true)
val transformer = createTransformer(
warnings = setOf(
CryptoCurrencyWarning.CustomTokenNotEnoughForFee(
currency = currency,
feeCurrency = null,
networkName = "Ethereum",
feeCurrencyName = "Ethereum",
feeCurrencySymbol = "ETH",
),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("custom_token_not_enough_for_fee")
assertThat(result.notifications.first().buttonsUM).isEmpty()
}
@Test
fun `GIVEN BeaconChainShutdown WHEN transform THEN notification with id beacon_chain_shutdown is created`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(CryptoCurrencyWarning.BeaconChainShutdown),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("beacon_chain_shutdown")
}
@Test
fun `GIVEN MigrationMaticToPol WHEN transform THEN notification with id migration_matic_pol is created`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(CryptoCurrencyWarning.MigrationMaticToPol),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("migration_matic_pol")
}
@Test
fun `GIVEN MigrationClore WHEN transform THEN notification has button and id migration_clore`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(CryptoCurrencyWarning.MigrationClore),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("migration_clore")
assertThat(result.notifications.first().buttonsUM).hasSize(1)
}
@Test
fun `GIVEN HederaAssociateWarning WHEN transform THEN notification with button is created`() {
// GIVEN
val currency: CryptoCurrency = mockk(relaxed = true)
val transformer = createTransformer(
warnings = setOf(HederaWarnings.AssociateWarning(currency = currency)),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("hedera_associate")
assertThat(result.notifications.first().buttonsUM).hasSize(1)
}
@Test
fun `GIVEN HederaAssociateWarningWithFee WHEN transform THEN notification with button is created`() {
// GIVEN
val currency: CryptoCurrency = mockk(relaxed = true)
val transformer = createTransformer(
warnings = setOf(
HederaWarnings.AssociateWarningWithFee(
currency = currency,
fee = BigDecimal("0.05"),
feeCurrencySymbol = "HBAR",
feeCurrencyDecimals = 8,
),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("hedera_associate_fee")
assertThat(result.notifications.first().buttonsUM).hasSize(1)
}
@Test
fun `GIVEN RequiredTrustline WHEN transform THEN notification with button is created`() {
// GIVEN
val currency: CryptoCurrency = mockk(relaxed = true)
val transformer = createTransformer(
warnings = setOf(
CryptoCurrencyWarning.RequiredTrustline(
currency = currency,
currencySymbol = "XLM",
requiredAmount = BigDecimal("10"),
currencyDecimals = 7,
),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("required_trustline")
assertThat(result.notifications.first().buttonsUM).hasSize(1)
}
@Test
fun `GIVEN KaspaIncompleteTransaction WHEN transform THEN notification with button and close is created`() {
// GIVEN
val currency: CryptoCurrency = mockk(relaxed = true)
val transformer = createTransformer(
warnings = setOf(
KaspaWarnings.IncompleteTransaction(
currency = currency,
amount = BigDecimal("100"),
currencySymbol = "KAS",
currencyDecimals = 8,
),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("kaspa_incomplete")
assertThat(result.notifications.first().buttonsUM).hasSize(1)
assertThat(result.notifications.first().onCloseClick).isNotNull()
}
// endregion
// region Skipped warnings
@Test
fun `GIVEN ExistentialDeposit WHEN transform THEN notification is skipped`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(
CryptoCurrencyWarning.ExistentialDeposit(
currencyName = "Polkadot",
edStringValueWithSymbol = "1 DOT",
),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN Rent WHEN transform THEN notification is skipped`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(
CryptoCurrencyWarning.Rent(
rent = BigDecimal("0.00001"),
exemptionAmount = BigDecimal("0.01"),
cryptoCurrency = mockk(relaxed = true),
),
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN UsedOutdatedDataWarning WHEN transform THEN notification is skipped`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(CryptoCurrencyWarning.UsedOutdatedDataWarning),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).isEmpty()
}
// endregion
// region Message effect
@Test
fun `GIVEN any mapped warning WHEN transform THEN messageEffect is None`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(CryptoCurrencyWarning.SomeNetworksUnreachable),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications.first().messageEffect).isEqualTo(TangemMessageEffect.None)
}
// endregion
// region Icon
@Test
fun `GIVEN any mapped warning WHEN transform THEN iconUM is not null`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(CryptoCurrencyWarning.BeaconChainShutdown),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications.first().iconUM).isNotNull()
}
// endregion
// region Multiple warnings
@Test
fun `GIVEN multiple warnings with some skipped WHEN transform THEN only mapped warnings are in notifications`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(
CryptoCurrencyWarning.SomeNetworksUnreachable,
CryptoCurrencyWarning.BeaconChainShutdown,
CryptoCurrencyWarning.UsedOutdatedDataWarning,
CryptoCurrencyWarning.TopUpWithoutReserve,
),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(2)
assertThat(result.notifications.map { it.id }).containsExactly(
"networks_unreachable",
"beacon_chain_shutdown",
)
}
// endregion
// region Click callbacks
@Test
fun `GIVEN HederaAssociateWarning WHEN button clicked THEN onAssociateClick is called`() {
// GIVEN
val currency: CryptoCurrency = mockk(relaxed = true)
val transformer = createTransformer(
warnings = setOf(HederaWarnings.AssociateWarning(currency = currency)),
)
// WHEN
val result = transformer.transform(initialState())
result.notifications.first().buttonsUM.first().onClick()
// THEN
verify(exactly = 1) { clickIntents.onAssociateClick() }
}
@Test
fun `GIVEN RequiredTrustline WHEN button clicked THEN onOpenTrustlineClick is called`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(
CryptoCurrencyWarning.RequiredTrustline(
currency = mockk(relaxed = true),
currencySymbol = "XLM",
requiredAmount = BigDecimal("10"),
currencyDecimals = 7,
),
),
)
// WHEN
val result = transformer.transform(initialState())
result.notifications.first().buttonsUM.first().onClick()
// THEN
verify(exactly = 1) { clickIntents.onOpenTrustlineClick() }
}
@Test
fun `GIVEN KaspaIncompleteTransaction WHEN retry clicked THEN onRetryIncompleteTransactionClick is called`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(
KaspaWarnings.IncompleteTransaction(
currency = mockk(relaxed = true),
amount = BigDecimal("100"),
currencySymbol = "KAS",
currencyDecimals = 8,
),
),
)
// WHEN
val result = transformer.transform(initialState())
result.notifications.first().buttonsUM.first().onClick()
// THEN
verify(exactly = 1) { clickIntents.onRetryIncompleteTransactionClick() }
}
@Test
fun `GIVEN KaspaIncompleteTransaction WHEN close clicked THEN onDismissIncompleteTransactionClick is called`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(
KaspaWarnings.IncompleteTransaction(
currency = mockk(relaxed = true),
amount = BigDecimal("100"),
currencySymbol = "KAS",
currencyDecimals = 8,
),
),
)
// WHEN
val result = transformer.transform(initialState())
result.notifications.first().onCloseClick!!.invoke()
// THEN
verify(exactly = 1) { clickIntents.onDismissIncompleteTransactionClick() }
}
@Test
fun `GIVEN DynamicAddressesFundsFound WHEN transform THEN notification with learn more button is created`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(DynamicAddressesWarnings.FundsFound),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications.first().id).isEqualTo("dynamic_addresses_funds_found")
assertThat(result.notifications.first().buttonsUM).hasSize(1)
}
@Test
fun `GIVEN DynamicAddressesFundsFound WHEN button clicked THEN onDynamicAddressesFundsFoundLearnMoreClick is called`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(DynamicAddressesWarnings.FundsFound),
)
// WHEN
val result = transformer.transform(initialState())
result.notifications.first().buttonsUM.first().onClick()
// THEN
verify(exactly = 1) { clickIntents.onDynamicAddressesFundsFoundLearnMoreClick() }
}
@Test
fun `GIVEN MigrationClore WHEN button clicked THEN onCloreMigrationClick is called`() {
// GIVEN
val transformer = createTransformer(
warnings = setOf(CryptoCurrencyWarning.MigrationClore),
)
// WHEN
val result = transformer.transform(initialState())
result.notifications.first().buttonsUM.first().onClick()
// THEN
verify(exactly = 1) { clickIntents.onCloreMigrationClick() }
}
@Test
fun `GIVEN BalanceNotEnoughForFee WHEN buy button clicked THEN onBuyCoinClick is called`() {
// GIVEN
val coinCurrency: CryptoCurrency = mockk(relaxed = true) {
io.mockk.every { network } returns mockk(relaxed = true) {
io.mockk.every { name } returns "Ethereum"
}
io.mockk.every { name } returns "Ethereum"
io.mockk.every { symbol } returns "ETH"
}
val transformer = createTransformer(
warnings = setOf(
CryptoCurrencyWarning.BalanceNotEnoughForFee(
tokenCurrency = mockk(relaxed = true),
coinCurrency = coinCurrency,
),
),
)
// WHEN
val result = transformer.transform(initialState())
result.notifications.first().buttonsUM.first().onClick()
// THEN
verify(exactly = 1) { clickIntents.onBuyCoinClick(coinCurrency) }
}
// endregion
// region State preservation
@Test
fun `GIVEN any warnings WHEN transform THEN unrelated state fields are preserved`() {
// GIVEN
val state = initialState()
val transformer = createTransformer(
warnings = setOf(CryptoCurrencyWarning.SomeNetworksUnreachable),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
// endregion
private fun createTransformer(warnings: Set<CryptoCurrencyWarning>) = UpdateNotificationsTransformer(
warnings = warnings,
clickIntents = clickIntents,
)
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Tether"),
subtitle = stringReference("ERC-20 in Ethereum network"),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,137 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.StakingOption
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class UpdateStakingNotificationTransformerTest {
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
@Test
fun `GIVEN Unavailable WHEN transform THEN earnBlockState is null`() {
val transformer = createTransformer(
availability = StakingAvailability.Unavailable,
entryInfo = null,
)
val result = transformer.transform(initialState())
assertThat(result.earnBlockState).isNull()
}
@Test
fun `GIVEN TemporaryUnavailable WHEN transform THEN TemporaryUnavailable`() {
val transformer = createTransformer(
availability = StakingAvailability.TemporaryUnavailable,
entryInfo = null,
)
val result = transformer.transform(initialState())
assertThat(result.earnBlockState).isInstanceOf(EarnBlockUM.Content::class.java)
val content = result.earnBlockState as EarnBlockUM.Content
assertThat(content.iconUM).isInstanceOf(EarnBlockUM.IconUM.Plain::class.java)
assertThat(content.trailingUM).isNull()
}
@Test
fun `GIVEN Available without entryInfo AND no staked WHEN transform THEN null`() {
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = null,
)
val result = transformer.transform(initialState())
assertThat(result.earnBlockState).isNull()
}
@Test
fun `GIVEN Available with entryInfo AND no staked WHEN transform THEN Available`() {
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = StakingEntryInfo(tokenSymbol = "SOL"),
)
val result = transformer.transform(initialState())
assertThat(result.earnBlockState).isInstanceOf(EarnBlockUM.Content::class.java)
val content = result.earnBlockState as EarnBlockUM.Content
assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java)
}
private fun createTransformer(
availability: StakingAvailability,
entryInfo: StakingEntryInfo?,
) = UpdateStakingNotificationTransformer(
cryptoCurrencyStatus = buildStatus(),
stakingAvailability = availability,
stakingEntryInfo = entryInfo,
appCurrency = AppCurrency.Default,
clickIntents = clickIntents,
)
private fun buildStatus(): CryptoCurrencyStatus {
val network = mockk<Network>(relaxed = true) {
every { rawId } returns "solana"
every { isTestnet } returns false
}
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { symbol } returns "SOL"
every { decimals } returns 9
every { this@mockk.network } returns network
every { id.isCoin } returns true
}
val stakingBalance = mockk<StakingBalance>(relaxed = true)
val value = mockk<CryptoCurrencyStatus.Value>(relaxed = true) {
every { this@mockk.stakingBalance } returns stakingBalance
every { fiatRate } returns BigDecimal.ONE
every { yieldSupplyStatus } returns null
}
return CryptoCurrencyStatus(currency = currency, value = value)
}
private fun availableOption(apy: BigDecimal): StakingAvailability.Available {
val option = mockk<StakingOption>(relaxed = true) {
every { this@mockk.apy } returns apy
}
return StakingAvailability.Available(option = option)
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Solana"),
subtitle = stringReference("Solana network"),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
notifications = persistentListOf(),
earnBlockState = null,
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,215 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class UpdateTopBarMenuTransformerTest {
private val coldWallet: UserWallet.Cold = mockk(relaxed = true)
private val hotWallet: UserWallet.Hot = mockk(relaxed = true)
private val cardTypesResolver: CardTypesResolver = mockk(relaxed = true)
private val onGenerateExtendedKey: () -> Unit = mockk(relaxed = true)
private val onHideClick: () -> Unit = mockk(relaxed = true)
@BeforeEach
fun setUp() {
mockkStatic(UserWallet.Cold::cardTypesResolver)
every { coldWallet.cardTypesResolver } returns cardTypesResolver
}
@AfterEach
fun tearDown() {
unmockkStatic(UserWallet.Cold::cardTypesResolver)
}
@Test
fun `GIVEN cold wallet AND single wallet with token WHEN transform THEN menu is empty`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns true
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = true,
isXPubSupported = true,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.menuItems).isEmpty()
}
@Test
fun `GIVEN cold wallet AND multi-wallet WHEN transform THEN Hide item is the only one`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = false,
isXPubSupported = false,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.menuItems).hasSize(1)
result.topAppBarUM.menuItems.single().onClick()
verify(exactly = 1) { onHideClick.invoke() }
verify(exactly = 0) { onGenerateExtendedKey.invoke() }
}
@Test
fun `GIVEN hot wallet WHEN transform THEN Hide item is shown regardless of single-wallet flag`() {
// GIVEN — flag is read only for cold wallets, so no stub needed for hotWallet
val transformer = createTransformer(
userWallet = hotWallet,
hasDerivations = false,
isXPubSupported = false,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.menuItems).hasSize(1)
}
@Test
fun `GIVEN xPub supported AND derivations exist WHEN transform THEN both items are shown`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = true,
isXPubSupported = true,
)
// WHEN
val result = transformer.transform(initialState())
// THEN — Generate xPub first, Hide token second
assertThat(result.topAppBarUM.menuItems).hasSize(2)
}
@Test
fun `GIVEN xPub supported but no derivations WHEN transform THEN xPub item is hidden`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = false,
isXPubSupported = true,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.menuItems).hasSize(1)
}
@Test
fun `GIVEN derivations exist but xPub unsupported WHEN transform THEN xPub item is hidden`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = true,
isXPubSupported = false,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.menuItems).hasSize(1)
}
@Test
fun `GIVEN any state WHEN transform THEN unrelated state fields are preserved`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val state = initialState()
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = false,
isXPubSupported = false,
)
// WHEN
val result = transformer.transform(state)
// THEN — only menuItems is touched
assertThat(result.topAppBarUM.titleState).isEqualTo(state.topAppBarUM.titleState)
assertThat(result.topAppBarUM.subtitle).isEqualTo(state.topAppBarUM.subtitle)
assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
}
@Test
fun `GIVEN callbacks WHEN menu items invoked THEN callbacks are dispatched`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = true,
isXPubSupported = true,
)
// WHEN
val result = transformer.transform(initialState())
result.topAppBarUM.menuItems.forEach { it.onClick() }
// THEN
verify(exactly = 1) { onGenerateExtendedKey.invoke() }
verify(exactly = 1) { onHideClick.invoke() }
}
private fun createTransformer(
userWallet: UserWallet,
hasDerivations: Boolean,
isXPubSupported: Boolean,
) = UpdateTopBarMenuTransformer(
userWallet = userWallet,
hasDerivations = hasDerivations,
isXPubSupported = isXPubSupported,
onGenerateExtendedKey = onGenerateExtendedKey,
onHideClick = onHideClick,
)
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Tether"),
subtitle = stringReference("ERC-20 in Ethereum network"),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
notifications = persistentListOf(),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
earnBlockState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}