Updated on 2026-08-14

This commit is contained in:
Tangem 2023-04-26 22:04:20 +08:00
parent 9da0305538
commit 551b2bb897
5 changed files with 562 additions and 5 deletions

View file

@ -1,6 +1,17 @@
package com.tangem.tap.features.customtoken.impl.presentation
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.transition.TransitionInflater
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.customtoken.impl.presentation.ui.AddCustomTokenScreen
import com.tangem.tap.features.customtoken.impl.presentation.viewmodels.AddCustomTokenViewModel
import com.tangem.wallet.R
import dagger.hilt.android.AndroidEntryPoint
/**
@ -9,4 +20,24 @@ import dagger.hilt.android.AndroidEntryPoint
[REDACTED_AUTHOR]
*/
@AndroidEntryPoint
internal class AddCustomTokenFragment : Fragment()
internal class AddCustomTokenFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
with(TransitionInflater.from(requireContext())) {
enterTransition = inflateTransition(R.transition.fade)
exitTransition = inflateTransition(R.transition.fade)
}
return ComposeView(inflater.context).apply {
setContent {
isTransitionGroup = true
val viewModel = hiltViewModel<AddCustomTokenViewModel>()
TangemTheme {
AddCustomTokenScreen(stateHolder = viewModel.uiState)
}
}
}
}
}

View file

@ -0,0 +1,42 @@
package com.tangem.tap.features.customtoken.impl.presentation.validators
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.AddressService
import com.tangem.common.card.EllipticCurve
import com.tangem.domain.AddCustomTokenError
/**
* Validator of contract address
*
[REDACTED_AUTHOR]
*/
object ContactAddressValidator {
/** Validate a [address] using [blockchain] */
fun validate(address: String, blockchain: Blockchain): ContractAddressValidatorResult {
return when {
address.isEmpty() -> ContractAddressValidatorResult.Error(type = AddCustomTokenError.FieldIsEmpty)
validateAddress(blockchain, address) -> ContractAddressValidatorResult.Success
else -> ContractAddressValidatorResult.Error(type = AddCustomTokenError.InvalidContractAddress)
}
}
private fun validateAddress(blockchain: Blockchain, address: String): Boolean {
return when (blockchain) {
Blockchain.Unknown, Blockchain.Binance, Blockchain.BinanceTestnet -> {
SuccessAddressValidator.validate(address)
}
else -> {
blockchain.validateAddress(address)
}
}
}
private object SuccessAddressValidator : AddressService() {
override fun makeAddress(walletPublicKey: ByteArray, curve: EllipticCurve?): String {
throw UnsupportedOperationException()
}
override fun validate(address: String): Boolean = true
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.tap.features.customtoken.impl.presentation.validators
import com.tangem.domain.AddCustomTokenError
/**
* Result of validation contract address
*
[REDACTED_AUTHOR]
*/
sealed interface ContractAddressValidatorResult {
/** Success */
object Success : ContractAddressValidatorResult
/**
* Error
*
* @property type type of error
*/
data class Error(val type: AddCustomTokenError) : ContractAddressValidatorResult
}

View file

@ -0,0 +1,466 @@
package com.tangem.tap.features.customtoken.impl.presentation.viewmodels
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationStyle
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.extensions.isSupportedInApp
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet.TestTokenItem
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet.TokensCategoryBlock
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenFloatingButton
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenForm
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenInputField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenTestBlock
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
import com.tangem.tap.features.customtoken.impl.presentation.routers.CustomTokenRouter
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
import com.tangem.tap.features.customtoken.impl.presentation.validators.ContactAddressValidator
import com.tangem.tap.features.customtoken.impl.presentation.validators.ContractAddressValidatorResult
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.wallet.BuildConfig
import com.tangem.wallet.R
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject
/**
* ViewModel for add custom token screen
*
* @param featureRouter feature router
* @property reduxStateHolder redux state holder
*
[REDACTED_AUTHOR]
*/
@HiltViewModel
internal class AddCustomTokenViewModel @Inject constructor(
featureRouter: CustomTokenRouter,
private val reduxStateHolder: AppStateHolder,
) : ViewModel() {
private val actionsHandler = ActionsHandler(featureRouter)
private val testActionsHandler = TestActionsHandler()
/** Screen state */
var uiState by mutableStateOf(getInitialUiState())
private set
private fun getInitialUiState(): AddCustomTokenStateHolder {
return if (BuildConfig.TEST_ACTION_ENABLED) {
AddCustomTokenStateHolder.TestContent(
onBackButtonClick = actionsHandler::onBackButtonClick,
toolbar = createToolbar(),
form = createForm(),
warnings = listOf(),
floatingButton = createFloatingButton(),
testBlock = AddCustomTokenTestBlock(
chooseTokenButtonText = "Choose token",
clearButtonText = "Clear address",
resetButtonText = "Reset",
onClearAddressButtonClick = testActionsHandler::onClearAddressButtonClick,
onResetButtonClick = testActionsHandler::onResetButtonClick,
),
bottomSheet = AddCustomTokenChooseTokenBottomSheet(
categoriesBlocks = listOf(
TokensCategoryBlock(name = "Common", items = COMMON_TOKENS),
TokensCategoryBlock(name = "Solana", items = SOLANA_TOKENS),
),
onTestTokenClick = testActionsHandler::onTestTokenClick,
),
)
} else {
AddCustomTokenStateHolder.Content(
onBackButtonClick = actionsHandler::onBackButtonClick,
toolbar = createToolbar(),
form = createForm(),
warnings = listOf(),
floatingButton = createFloatingButton(),
)
}
}
private fun createToolbar(): AddCustomTokensToolbar {
return AddCustomTokensToolbar(
title = TextReference.Res(R.string.add_custom_token_title),
onBackButtonClick = actionsHandler::onBackButtonClick,
)
}
private fun createForm(): AddCustomTokenForm {
return AddCustomTokenForm(
contractAddressInputField = createContractAddressInputField(),
networkSelectorField = createNetworkSelectorField(),
tokenNameInputField = createTokenNameInputField(),
tokenSymbolInputField = createTokenSymbolInputField(),
decimalsInputField = createDecimalsInputField(),
derivationPathSelectorField = createDerivationPathsSelectorField(),
)
}
private fun createContractAddressInputField(): AddCustomTokenInputField.ContactAddress {
return AddCustomTokenInputField.ContactAddress(
value = "",
onValueChange = actionsHandler::onContactAddressValueChange,
isError = false,
isLoading = false,
)
}
private fun createNetworkSelectorField(): AddCustomTokenSelectorField.Network {
val selectorItems = getNetworkSelectorItems()
return AddCustomTokenSelectorField.Network(
selectedItem = requireNotNull(selectorItems.firstOrNull()),
items = selectorItems,
onMenuItemClick = {
actionsHandler.onNetworkSelectorItemClick(
selectedItem = requireNotNull(selectorItems.getOrNull(it)),
)
},
)
}
private fun getNetworkSelectorItems(): List<AddCustomTokenSelectorField.SelectorItem.Title> {
val card = reduxStateHolder.scanResponse?.card
val evmBlockchains = Blockchain.values().filter { card?.isTestCard == it.isTestnet() && it.isEvm() }
val additionalBlockchains = listOf(
Blockchain.Binance,
Blockchain.BinanceTestnet,
Blockchain.Solana,
Blockchain.SolanaTestnet,
Blockchain.Tron,
Blockchain.TronTestnet,
)
return (evmBlockchains + additionalBlockchains)
.filter { card?.supportedBlockchains()?.contains(it) == true }
.map(::createNetworkSelectorItem)
.toMutableList()
.apply {
add(index = 0, element = createNetworkSelectorItem(blockchain = Blockchain.Unknown))
}
}
private fun createNetworkSelectorItem(blockchain: Blockchain): AddCustomTokenSelectorField.SelectorItem.Title {
return when (blockchain) {
Blockchain.Unknown -> {
AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Res(R.string.custom_token_network_input_not_selected),
blockchain = Blockchain.Unknown,
)
}
else -> {
AddCustomTokenSelectorField.SelectorItem.Title(
title = TextReference.Str(blockchain.fullName),
blockchain = blockchain,
)
}
}
}
private fun createTokenNameInputField(): AddCustomTokenInputField.TokenName {
return AddCustomTokenInputField.TokenName(
value = "",
onValueChange = actionsHandler::onTokenNameValueChange,
isEnabled = false,
isError = false,
)
}
private fun createTokenSymbolInputField(): AddCustomTokenInputField.TokenSymbol {
return AddCustomTokenInputField.TokenSymbol(
value = "",
onValueChange = actionsHandler::onTokenSymbolValueChange,
isEnabled = false,
isError = false,
)
}
private fun createDecimalsInputField(): AddCustomTokenInputField.Decimals {
return AddCustomTokenInputField.Decimals(
value = "",
onValueChange = actionsHandler::onDecimalsValueChange,
isEnabled = false,
isError = false,
)
}
private fun createDerivationPathsSelectorField(): AddCustomTokenSelectorField.DerivationPath? {
if (reduxStateHolder.scanResponse?.card?.settings?.isHDWalletAllowed == false) return null
val selectorItems = getDerivationPathsSelectorItems()
return AddCustomTokenSelectorField.DerivationPath(
isEnabled = true,
selectedItem = requireNotNull(selectorItems.firstOrNull()),
items = selectorItems,
onMenuItemClick = {
val field = requireNotNull(uiState.form.derivationPathSelectorField)
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = field.copy(
selectedItem = requireNotNull(selectorItems.getOrNull(it)),
),
),
)
},
)
}
private fun getDerivationPathsSelectorItems(): List<AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle> {
val evmBlockchains = Blockchain.values().filter {
reduxStateHolder.scanResponse?.card?.isTestCard == it.isTestnet() && it.isEvm() && it.isSupportedInApp()
}
return evmBlockchains
.sortedBy(Blockchain::fullName)
.map(::createDerivationPathSelectorItem)
.toMutableList()
.apply {
add(index = 0, element = createDerivationPathSelectorItem(Blockchain.Unknown))
}
}
private fun createDerivationPathSelectorItem(
blockchain: Blockchain,
): AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle {
return when (blockchain) {
Blockchain.Unknown -> {
AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle(
title = TextReference.Res(R.string.custom_token_derivation_path_default),
subtitle = TextReference.Res(R.string.custom_token_derivation_path_default),
blockchain = Blockchain.Unknown,
)
}
else -> {
AddCustomTokenSelectorField.SelectorItem.TitleWithSubtitle(
title = blockchain.derivationPath(DerivationStyle.LEGACY)?.rawPath?.let(TextReference::Str)
?: TextReference.Res(R.string.custom_token_derivation_path_default),
subtitle = TextReference.Str(blockchain.fullName),
blockchain = blockchain,
)
}
}
}
private fun createFloatingButton(): AddCustomTokenFloatingButton {
return AddCustomTokenFloatingButton(isEnabled = false, onClick = actionsHandler::onAddCustomTokenClick)
}
private inner class ActionsHandler(private val featureRouter: CustomTokenRouter) {
fun onBackButtonClick() {
featureRouter.popBackStack()
}
fun onAddCustomTokenClick() {
// TODO("[REDACTED_TASK_KEY] Add processing")
}
fun onContactAddressValueChange(enteredValue: String) {
with(uiState.form) {
val isValid = ContactAddressValidator.validate(
address = enteredValue,
blockchain = networkSelectorField.selectedItem.blockchain,
)
when (isValid) {
is ContractAddressValidatorResult.Success -> {
uiState = uiState.copySealed(
form = uiState.form.copy(
contractAddressInputField = contractAddressInputField.copy(isError = false),
),
)
// TODO("[REDACTED_TASK_KEY] Add loading custom token information")
}
is ContractAddressValidatorResult.Error -> {
handleContractAddressErrorValidation(type = isValid.type)
}
}
updateDerivationPathSelector()
// TODO("[REDACTED_TASK_KEY] Update warnings")
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
}
fun onNetworkSelectorItemClick(selectedItem: AddCustomTokenSelectorField.SelectorItem.Title) {
uiState = uiState.copySealed(
form = uiState.form.copy(
networkSelectorField = uiState.form.networkSelectorField.copy(selectedItem = selectedItem),
),
)
onContactAddressValueChange(uiState.form.contractAddressInputField.value)
}
fun onTokenNameValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenNameInputField = uiState.form.tokenNameInputField.copy(value = enteredValue),
),
)
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
fun onTokenSymbolValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenSymbolInputField = uiState.form.tokenSymbolInputField.copy(value = enteredValue),
),
)
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
fun onDecimalsValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
decimalsInputField = uiState.form.decimalsInputField.copy(value = enteredValue),
),
)
// TODO("[REDACTED_TASK_KEY] Update floating button")
}
private fun isAnotherTokenFieldsFilled(): Boolean {
return with(uiState.form) {
contractAddressInputField.value.isNotEmpty() || tokenNameInputField.value.isNotEmpty() ||
tokenSymbolInputField.value.isNotEmpty() || decimalsInputField.value.isNotEmpty()
}
}
private fun handleContractAddressErrorValidation(type: AddCustomTokenError) {
with(uiState.form) {
val isNetworkSelectorFilled = networkSelectorField.selectedItem.blockchain != Blockchain.Unknown
val isAnotherTokenFieldsFilled = isAnotherTokenFieldsFilled()
when {
isNetworkSelectorFilled && type == AddCustomTokenError.InvalidContractAddress -> {
// TODO("[REDACTED_TASK_KEY] Add error")
uiState = uiState.copySealed(
form = uiState.form.copy(
tokenNameInputField = tokenNameInputField.copy(isEnabled = isAnotherTokenFieldsFilled),
tokenSymbolInputField = tokenSymbolInputField.copy(
isEnabled = isAnotherTokenFieldsFilled,
),
decimalsInputField = decimalsInputField.copy(isEnabled = isAnotherTokenFieldsFilled),
),
)
}
!isNetworkSelectorFilled || type == AddCustomTokenError.FieldIsEmpty -> {
uiState = uiState.copySealed(
form = uiState.form.copy(
contractAddressInputField = contractAddressInputField.copy(isError = false),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
),
)
}
else -> Unit
}
}
}
private fun updateDerivationPathSelector() {
val selectedValue = uiState.form.derivationPathSelectorField?.selectedItem?.blockchain ?: return
val isSupported = selectedValue.isEvm() || selectedValue == Blockchain.Unknown
if (selectedValue != Blockchain.Unknown && !isSupported) {
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = uiState.form.derivationPathSelectorField?.copy(
selectedItem = createDerivationPathSelectorItem(Blockchain.Unknown),
),
),
)
}
if (uiState.form.derivationPathSelectorField?.isEnabled != isSupported) {
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = uiState.form.derivationPathSelectorField?.copy(
isEnabled = isSupported,
),
),
)
}
}
}
private inner class TestActionsHandler {
fun onClearAddressButtonClick() {
with(uiState.form) {
uiState = uiState.copySealed(
form = copy(
contractAddressInputField = contractAddressInputField.copy(value = ""),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
),
)
}
}
fun onResetButtonClick() {
with(uiState.form) {
uiState = uiState.copySealed(
form = copy(
contractAddressInputField = contractAddressInputField.copy(value = ""),
networkSelectorField = networkSelectorField.copy(
selectedItem = requireNotNull(networkSelectorField.items.firstOrNull()),
),
tokenNameInputField = tokenNameInputField.copy(value = "", isEnabled = false),
tokenSymbolInputField = tokenSymbolInputField.copy(value = "", isEnabled = false),
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
derivationPathSelectorField = derivationPathSelectorField?.copy(
selectedItem = requireNotNull(derivationPathSelectorField.items.firstOrNull()),
),
),
)
}
}
@Suppress("UnusedPrivateMember")
fun onTestTokenClick(address: String) {
// TODO("[REDACTED_TASK_KEY] Add processing")
}
}
private companion object {
val COMMON_TOKENS = persistentListOf(
TestTokenItem(name = "USDC on ETH", address = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"),
TestTokenItem(name = "BUSD on ETH", address = "0x4fabb145d64652a948d72533023f6e7a623c7c53"),
TestTokenItem(name = "ETH on AVALANCHE", address = "0xf20d962a6c8f70c731bd838a3a388d7d48fa6e15"),
TestTokenItem(name = "USDC on ETH (invalid - cut address)", address = "0xa0b86991c6218b36c1d1"),
TestTokenItem(name = "Custom EVM", address = "0x1111111111111111112111111111111111111113"),
TestTokenItem(
name = "Supported by several networks",
address = "0xa1faa113cbe53436df28ff0aee54275c13b40975",
),
TestTokenItem(name = "Invalid", address = "!@#_ _-%%^&&*((){P P2iOWsdfFQLA"),
)
val SOLANA_TOKENS = persistentListOf(
TestTokenItem(name = "USDT (full)", address = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"),
TestTokenItem(
name = "USDT (valid - 2/3 of address)",
address = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8Ben",
),
TestTokenItem(name = "USDT (invalid - 1/3 of address)", address = "Es9vMFrzaCERmJ"),
TestTokenItem(name = "ETH (full)", address = "2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk"),
)
}
}

View file

@ -1,7 +1,6 @@
package com.tangem.domain.features.addCustomToken.redux
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationStyle
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.DomainWrapped
@ -20,9 +19,7 @@ sealed class AddCustomTokenAction : Action {
data class SetOnAddTokenCallback(val callback: (CustomCurrency) -> Unit) : AddCustomTokenAction()
}
object OnCreate : AddCustomTokenAction() {
data class SetDerivationStyle(val derivationStyle: DerivationStyle?) : AddCustomTokenAction()
}
object OnCreate : AddCustomTokenAction()
object OnDestroy : AddCustomTokenAction()