Updated on 2026-08-14
This commit is contained in:
commit
0470e1f010
1144 changed files with 48463 additions and 11166 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
|
|||
is TokenDetailsNotification.MigrationClore,
|
||||
is TokenDetailsNotification.UsedOutdatedData,
|
||||
-> null
|
||||
is TokenDetailsNotification.DynamicAddressesFundsFound -> null // TODO: [REDACTED_TASK_KEY] analytics event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -27,4 +27,7 @@ sealed class TokenDetailsBottomSheetConfig : Route {
|
|||
|
||||
@Serializable
|
||||
data object CloreMigration : TokenDetailsBottomSheetConfig()
|
||||
|
||||
@Serializable
|
||||
data object DynamicAddresses : TokenDetailsBottomSheetConfig()
|
||||
}
|
||||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
* 1–2. 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.
|
||||
* 4–5. 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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue