Updated on 2026-08-14

This commit is contained in:
Tangem 2023-06-08 17:04:35 +04:00
commit b4b6ecd764
7 changed files with 180 additions and 75 deletions

View file

@ -67,6 +67,8 @@ internal data class AddCustomTokenChooseTokenBottomSheet(
* @property tokenSymbolInputField input field to enter the token symbol
* @property decimalsInputField input field to enter the token decimals
* @property derivationPathSelectorField selector field to select the derivation path
* @property derivationPathInputField input field for a custom derivation path
* @property showTokenFields if token fields should be shown
*/
internal data class AddCustomTokenForm(
val contractAddressInputField: AddCustomTokenInputField.ContactAddress,
@ -75,6 +77,8 @@ internal data class AddCustomTokenForm(
val tokenSymbolInputField: AddCustomTokenInputField.TokenSymbol,
val decimalsInputField: AddCustomTokenInputField.Decimals,
val derivationPathSelectorField: AddCustomTokenSelectorField.DerivationPath?,
val derivationPathInputField: AddCustomTokenInputField.DerivationPath?,
val showTokenFields: Boolean = false,
)
/** Base input field model of add custom token screen */
@ -174,6 +178,25 @@ internal sealed interface AddCustomTokenInputField {
override val placeholder: TextReference,
val isEnabled: Boolean,
) : AddCustomTokenInputField
/**
* Input field model to enter a custom derivation path
*
* @property value current value
* @property onValueChange lambda be invoked when value is been changed
* @property keyboardOptions keyboard options
* @property label label
* @property placeholder placeholder (hint)
* @property showField whether the field should be shown
*/
data class DerivationPath(
override val value: String,
override val onValueChange: (String) -> Unit,
override val keyboardOptions: KeyboardOptions,
override val label: TextReference,
override val placeholder: TextReference,
val showField: Boolean = false,
) : AddCustomTokenInputField
}
/** Base selector field model of add custom token screen */
@ -251,10 +274,15 @@ internal sealed interface AddCustomTokenSelectorField {
override val title: TextReference,
override val blockchain: Blockchain,
val subtitle: TextReference,
val type: DerivationPathSelectorType = DerivationPathSelectorType.BLOCKCHAIN,
) : SelectorItem
}
}
enum class DerivationPathSelectorType {
DEFAULT, CUSTOM, BLOCKCHAIN
}
/**
* Warning model of add custom token screen
*
@ -276,6 +304,10 @@ internal sealed class AddCustomTokenWarning(val description: TextReference) {
object UnsupportedSolanaToken : AddCustomTokenWarning(
description = TextReference.Res(R.string.alert_manage_tokens_unsupported_message),
)
object WrongDerivationPath : AddCustomTokenWarning(
description = TextReference.Res(R.string.custom_token_invalid_derivation_path),
)
}
/**

View file

@ -4,14 +4,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.Blockchain
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenChooseTokenBottomSheet
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.AddCustomTokenWarning
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokensToolbar
import com.tangem.tap.features.customtoken.impl.presentation.models.*
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.wallet.R
@ -85,6 +78,7 @@ internal object AddCustomTokenPreviewData {
onMenuItemClick = {},
isEnabled = true,
),
derivationPathInputField = null,
)
}

View file

@ -1,31 +1,11 @@
package com.tangem.tap.features.customtoken.impl.presentation.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ExposedDropdownMenuBox
import androidx.compose.material.ExposedDropdownMenuDefaults
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
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.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -62,12 +42,13 @@ internal fun AddCustomTokenForm(model: AddCustomTokenForm) {
modifier = Modifier.padding(TangemTheme.dimens.spacing16),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
InputField(model = model.contractAddressInputField)
if (model.showTokenFields) InputField(model = model.contractAddressInputField)
SelectorField(model = model.networkSelectorField)
InputField(model = model.tokenNameInputField)
InputField(model = model.tokenSymbolInputField)
InputField(model = model.decimalsInputField)
if (model.showTokenFields) InputField(model = model.tokenNameInputField)
if (model.showTokenFields) InputField(model = model.tokenSymbolInputField)
if (model.showTokenFields) InputField(model = model.decimalsInputField)
model.derivationPathSelectorField?.let { SelectorField(model = it) }
if (model.derivationPathInputField?.showField == true) InputField(model = model.derivationPathInputField)
}
}
}
@ -103,6 +84,7 @@ private fun TextField(model: AddCustomTokenInputField, isError: Boolean) {
is AddCustomTokenInputField.Decimals -> model.isEnabled
is AddCustomTokenInputField.TokenName -> model.isEnabled
is AddCustomTokenInputField.TokenSymbol -> model.isEnabled
is AddCustomTokenInputField.DerivationPath -> true
}
OutlinedTextField(

View file

@ -15,6 +15,7 @@ import com.tangem.blockchain.common.DerivationStyle
import com.tangem.blockchain.common.Token
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.HDWalletError
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.extensions.*
@ -135,18 +136,28 @@ internal class AddCustomTokenViewModel @Inject constructor(
tokenSymbolInputField = createTokenSymbolInputField(),
decimalsInputField = createDecimalsInputField(),
derivationPathSelectorField = createDerivationPathsSelectorField(),
derivationPathInputField = createDerivationPathInputField(),
)
}
fun createDerivationPathSelectorItem(blockchain: Blockchain): SelectorItem.TitleWithSubtitle {
return if (blockchain == Blockchain.Unknown) {
SelectorItem.TitleWithSubtitle(
fun createDerivationPathSelectorAdditionalItem(
blockchain: Blockchain,
type: DerivationPathSelectorType = DerivationPathSelectorType.BLOCKCHAIN,
): SelectorItem.TitleWithSubtitle {
return when (type) {
DerivationPathSelectorType.DEFAULT -> 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,
type = DerivationPathSelectorType.DEFAULT,
)
} else {
SelectorItem.TitleWithSubtitle(
DerivationPathSelectorType.CUSTOM -> SelectorItem.TitleWithSubtitle(
title = TextReference.Res(R.string.custom_token_custom_derivation),
subtitle = TextReference.Res(R.string.custom_token_custom_derivation),
blockchain = Blockchain.Unknown,
type = DerivationPathSelectorType.CUSTOM,
)
DerivationPathSelectorType.BLOCKCHAIN -> 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),
@ -175,7 +186,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
onValueChange = actionsHandler::onContactAddressValueChange,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
label = TextReference.Res(R.string.custom_token_contract_address_input_title),
placeholder = TextReference.Str(value = "0x0000000000000000000000000000000000000000"),
placeholder = TextReference.Str(value = CONTRACT_ADDRESS_PLACEHOLDER),
isLoading = false,
isError = false,
error = null,
@ -196,8 +207,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
val defaultNetwork = createNetworkSelectorItem(blockchain = Blockchain.Unknown)
return listOf(defaultNetwork) + Blockchain.values()
.filter { blockchain ->
(blockchain.isEvm() || blockchain.canHandleTokens()) &&
reduxStateHolder.scanResponse?.card?.supportedBlockchains()?.contains(blockchain) == true
reduxStateHolder.scanResponse?.card?.supportedBlockchains()?.contains(blockchain) == true
}
.sortedBy(Blockchain::fullName)
.map(::createNetworkSelectorItem)
@ -231,7 +241,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
onValueChange = actionsHandler::onDecimalsValueChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
label = TextReference.Res(R.string.custom_token_decimals_input_title),
placeholder = TextReference.Str(value = "8"),
placeholder = TextReference.Str(value = DECIMALS_PLACEHOLDER),
isEnabled = false,
)
}
@ -250,11 +260,31 @@ internal class AddCustomTokenViewModel @Inject constructor(
}
private fun getDerivationPathsSelectorItems(): List<SelectorItem.TitleWithSubtitle> {
val defaultDerivationPath = createDerivationPathSelectorItem(Blockchain.Unknown)
return listOf(defaultDerivationPath) + Blockchain.values()
.filter { blockchain -> blockchain.isEvm() && blockchain.isSupportedInApp() && !blockchain.isTestnet() }
return listOf(
createDerivationPathSelectorAdditionalItem(
blockchain = Blockchain.Unknown,
type = DerivationPathSelectorType.DEFAULT,
),
createDerivationPathSelectorAdditionalItem(
blockchain = Blockchain.Unknown,
type = DerivationPathSelectorType.CUSTOM,
),
) + Blockchain.values()
.filter { blockchain -> blockchain.isSupportedInApp() && !blockchain.isTestnet() }
.sortedBy(Blockchain::fullName)
.map(::createDerivationPathSelectorItem)
.map(::createDerivationPathSelectorAdditionalItem)
}
private fun createDerivationPathInputField(): AddCustomTokenInputField.DerivationPath? {
if (reduxStateHolder.scanResponse?.card?.settings?.isHDWalletAllowed == false) return null
return AddCustomTokenInputField.DerivationPath(
value = "",
onValueChange = actionsHandler::onDerivationPathValueChange,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
label = TextReference.Res(R.string.custom_token_custom_derivation),
placeholder = TextReference.Str(value = DERIVATION_PATH_PLACEHOLDER),
)
}
}
@ -306,25 +336,16 @@ internal class AddCustomTokenViewModel @Inject constructor(
Timber.e(it)
}
updateDerivationPathSelector()
updateWarnings()
updateFloatingButton()
}
}
private fun updateDerivationPathSelector() {
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = uiState.form.derivationPathSelectorField?.copy(
isEnabled = uiState.form.networkSelectorField.selectedItem.blockchain.isEvm(),
),
),
)
}
private fun isDerivationPathSelected(): Boolean {
return with(uiState.form.derivationPathSelectorField?.selectedItem?.blockchain) {
this != null && this != Blockchain.Unknown
this != null && this != Blockchain.Unknown ||
uiState.form.derivationPathSelectorField?.selectedItem?.type == DerivationPathSelectorType.CUSTOM &&
!uiState.warnings.contains(AddCustomTokenWarning.WrongDerivationPath)
}
}
@ -397,6 +418,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
}
private fun updateFloatingButton() {
uiState = updateStateWithDerivationError(uiState)
if (isCustomTokenAlreadyAdded()) {
uiState = uiState.copySealed(
warnings = uiState.warnings + AddCustomTokenWarning.TokenAlreadyAdded,
@ -405,6 +427,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
return
}
val isCorrectDerivationInput = !uiState.warnings.contains(AddCustomTokenWarning.WrongDerivationPath)
val state = when {
isAllTokenFieldsFilled() && isNetworkSelected() -> {
val networkSelectorValue = uiState.form.networkSelectorField.selectedItem.blockchain
@ -418,7 +441,8 @@ internal class AddCustomTokenViewModel @Inject constructor(
uiState.copySealed(
floatingButton = uiState.floatingButton.copy(
isEnabled = error is ContractAddressValidatorResult.Success && isSupportedToken,
isEnabled = error is ContractAddressValidatorResult.Success &&
isSupportedToken && isCorrectDerivationInput,
),
)
}
@ -430,17 +454,43 @@ internal class AddCustomTokenViewModel @Inject constructor(
else -> {
uiState.copySealed(
floatingButton = uiState.floatingButton.copy(
isEnabled = if (isNetworkSelected()) !isBlockchainAlreadyAdded() else false,
isEnabled = if (isNetworkSelected()) {
!isBlockchainAlreadyAdded() && isCorrectDerivationInput
} else {
false
},
),
)
}
}
uiState = state.copySealed(
warnings = uiState.warnings - AddCustomTokenWarning.TokenAlreadyAdded,
warnings = state.warnings - AddCustomTokenWarning.TokenAlreadyAdded,
)
}
private fun updateStateWithDerivationError(uiState: AddCustomTokenStateHolder): AddCustomTokenStateHolder {
val updatedWarnings = if (isWrongDerivationPathEntered(
derivationPathSelectorType = uiState.form.derivationPathSelectorField?.selectedItem?.type,
derivationPath = getDerivationPath(),
)
) {
uiState.warnings + AddCustomTokenWarning.WrongDerivationPath
} else {
uiState.warnings - AddCustomTokenWarning.WrongDerivationPath
}
return uiState.copySealed(
warnings = updatedWarnings,
)
}
private fun isWrongDerivationPathEntered(
derivationPathSelectorType: DerivationPathSelectorType?,
derivationPath: DerivationPath?,
): Boolean {
return derivationPathSelectorType == DerivationPathSelectorType.CUSTOM && derivationPath == null
}
private fun isCustomTokenAlreadyAdded(): Boolean {
return when (getCustomTokenType()) {
CustomTokenType.TOKEN -> isTokenAlreadyAdded()
@ -520,16 +570,36 @@ internal class AddCustomTokenViewModel @Inject constructor(
}
private fun getDerivationPath(): DerivationPath? {
return when (uiState.form.derivationPathSelectorField?.selectedItem?.type) {
DerivationPathSelectorType.CUSTOM ->
createDerivationPathOrNull(uiState.form.derivationPathInputField?.value ?: "")
else ->
getDerivationPathForBlockchain(uiState.form.derivationPathSelectorField?.selectedItem?.blockchain)
}
}
private fun createDerivationPathOrNull(rawPath: String): DerivationPath? {
return try {
DerivationPath(rawPath)
} catch (error: HDWalletError) {
null
}
}
private fun getDerivationPathForBlockchain(blockchain: Blockchain?): DerivationPath? {
if (blockchain == null) return null
val derivationStyle = if (!isDerivationPathSelected()) {
reduxStateHolder.scanResponse?.card?.derivationStyle
} else {
DerivationStyle.LEGACY
}
return when (val derivationNetwork = uiState.form.derivationPathSelectorField?.selectedItem?.blockchain) {
Blockchain.Unknown -> uiState.form.networkSelectorField.selectedItem.blockchain
else -> derivationNetwork
}?.derivationPath(derivationStyle)
val derivationNetwork = if (blockchain == Blockchain.Unknown) {
uiState.form.networkSelectorField.selectedItem.blockchain
} else {
blockchain
}
return derivationNetwork.derivationPath(derivationStyle)
}
private inner class ActionsHandler(private val featureRouter: CustomTokenRouter) {
@ -566,7 +636,6 @@ internal class AddCustomTokenViewModel @Inject constructor(
is ContractAddressValidatorResult.Error -> {
handleContractAddressErrorValidation(type = validatorResult.type)
updateDerivationPathSelector()
updateWarnings()
updateFloatingButton()
}
@ -574,11 +643,13 @@ internal class AddCustomTokenViewModel @Inject constructor(
}
fun onNetworkSelectorItemClick(index: Int) {
val selectedItem = requireNotNull(uiState.form.networkSelectorField.items.getOrNull(index))
uiState = uiState.copySealed(
form = uiState.form.copy(
networkSelectorField = uiState.form.networkSelectorField.copy(
selectedItem = requireNotNull(uiState.form.networkSelectorField.items.getOrNull(index)),
selectedItem = selectedItem,
),
showTokenFields = selectedItem.blockchain.canHandleTokens(),
),
)
onContactAddressValueChange(uiState.form.contractAddressInputField.value)
@ -612,12 +683,26 @@ internal class AddCustomTokenViewModel @Inject constructor(
}
fun onDerivationPathSelectorItemClick(index: Int) {
val field = requireNotNull(uiState.form.derivationPathSelectorField)
val derivationSelector = requireNotNull(uiState.form.derivationPathSelectorField)
val selected = requireNotNull(derivationSelector.items.getOrNull(index))
val derivationInputField = requireNotNull(uiState.form.derivationPathInputField)
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathSelectorField = field.copy(
selectedItem = requireNotNull(field.items.getOrNull(index)),
derivationPathSelectorField = derivationSelector.copy(
selectedItem = selected,
),
derivationPathInputField = derivationInputField.copy(
showField = selected.type == DerivationPathSelectorType.CUSTOM,
),
),
)
updateFloatingButton()
}
fun onDerivationPathValueChange(enteredValue: String) {
uiState = uiState.copySealed(
form = uiState.form.copy(
derivationPathInputField = uiState.form.derivationPathInputField?.copy(value = enteredValue),
),
)
updateFloatingButton()
@ -691,7 +776,11 @@ internal class AddCustomTokenViewModel @Inject constructor(
decimalsInputField = decimalsInputField.copy(value = "", isEnabled = false),
derivationPathSelectorField = derivationPathSelectorField?.copy(
isEnabled = true,
selectedItem = formStateBuilder.createDerivationPathSelectorItem(Blockchain.Unknown),
selectedItem = formStateBuilder
.createDerivationPathSelectorAdditionalItem(
blockchain = Blockchain.Unknown,
type = DerivationPathSelectorType.DEFAULT,
),
),
),
)
@ -722,5 +811,8 @@ internal class AddCustomTokenViewModel @Inject constructor(
TestTokenItem(name = "USDT (invalid - 1/3 of address)", address = "Es9vMFrzaCERmJ"),
TestTokenItem(name = "ETH (full)", address = "2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk"),
)
const val DERIVATION_PATH_PLACEHOLDER = "m/44'/0'/0'/0/0"
const val DECIMALS_PLACEHOLDER = "8"
const val CONTRACT_ADDRESS_PLACEHOLDER = "0x0000000000000000000000000000000000000000"
}
}

View file

@ -84,9 +84,11 @@
<string name="custom_token_creation_error_network_not_selected">Пожалуйста, выберите сеть</string>
<string name="custom_token_creation_error_required_field">Обязательное поле</string>
<string name="custom_token_creation_error_wrong_decimals">Количество знаков после запятой должно быть корректным числом не больше %d</string>
<string name="custom_token_custom_derivation">Своя деривация</string>
<string name="custom_token_decimals_input_title">Знаков после запятой</string>
<string name="custom_token_derivation_path_default">По умолчанию</string>
<string name="custom_token_derivation_path_input_title">Деривация по BIP44</string>
<string name="custom_token_invalid_derivation_path">Введенный путь деривации некорректен</string>
<string name="custom_token_name_input_placeholder">Например, USD Coin</string>
<string name="custom_token_name_input_title">Название токена</string>
<string name="custom_token_network_input_not_selected">Не выбрано</string>

View file

@ -82,9 +82,11 @@
<string name="custom_token_creation_error_network_not_selected">Please select the network</string>
<string name="custom_token_creation_error_required_field">Required field</string>
<string name="custom_token_creation_error_wrong_decimals">Decimal number must be a valid integer, no higher than %d</string>
<string name="custom_token_custom_derivation">Custom derivation</string>
<string name="custom_token_decimals_input_title">Decimals</string>
<string name="custom_token_derivation_path_default">Default</string>
<string name="custom_token_derivation_path_input_title">BIP44 coin type</string>
<string name="custom_token_invalid_derivation_path">The derivation path you\'ve entered is not valid</string>
<string name="custom_token_name_input_placeholder">E.g. USD Coin</string>
<string name="custom_token_name_input_title">Name</string>
<string name="custom_token_network_input_not_selected">Not selected</string>

View file

@ -177,4 +177,5 @@ fun Blockchain.isSupportedInApp(): Boolean {
private val excludedBlockchains = listOf(
Blockchain.SaltPay,
Blockchain.Unknown,
)