diff --git a/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/AddCustomTokenFragment.kt b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/AddCustomTokenFragment.kt index e08097076b..8967312f0f 100644 --- a/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/AddCustomTokenFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/AddCustomTokenFragment.kt @@ -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() \ No newline at end of file +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() + + TangemTheme { + AddCustomTokenScreen(stateHolder = viewModel.uiState) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/validators/ContactAddressValidator.kt b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/validators/ContactAddressValidator.kt new file mode 100644 index 0000000000..b522a922b1 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/validators/ContactAddressValidator.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/validators/ContractAddressValidatorResult.kt b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/validators/ContractAddressValidatorResult.kt new file mode 100644 index 0000000000..417b3821e0 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/validators/ContractAddressValidatorResult.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/viewmodels/AddCustomTokenViewModel.kt b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/viewmodels/AddCustomTokenViewModel.kt new file mode 100644 index 0000000000..b3ebc34569 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/viewmodels/AddCustomTokenViewModel.kt @@ -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 { + 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 { + 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"), + ) + } +} \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenAction.kt b/domain/legacy/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenAction.kt index c2ae9ff018..c178c4a610 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenAction.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/features/addCustomToken/redux/AddCustomTokenAction.kt @@ -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()