diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 13e28e17e8..7c1054f8cc 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -374,6 +374,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.QrScanning.Source.Send -> SourceType.SEND is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN + is AppRoute.QrScanning.Source.AddressBook -> SourceType.ADDRESS_BOOK } createComponentChild( context = context, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 93c4629c98..63c6e73fdb 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -195,6 +195,7 @@ sealed class AppRoute(val path: String) : Route { is Send -> "/$networkName" WalletConnect -> "" MainScreen -> "" + AddressBook -> "" } data class Send(val networkName: String) : Source() @@ -202,6 +203,8 @@ sealed class AppRoute(val path: String) : Route { data object WalletConnect : Source() data object MainScreen : Source() + + data object AddressBook : Source() } } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt index 5adabfe981..a8a877a103 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt @@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.FeePaidCurrency import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.domain.card.common.extensions.canHandleToken @@ -217,172 +218,6 @@ class NetworkFactory @Inject constructor( } } - @Suppress("LongMethod") - private fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType { - return when (this) { - Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG - Blockchain.Binance, - Blockchain.TON, - Blockchain.Cosmos, - Blockchain.TerraV1, - Blockchain.TerraV2, - Blockchain.Stellar, - Blockchain.Hedera, - Blockchain.Algorand, - Blockchain.Sei, - Blockchain.InternetComputer, - Blockchain.Casper, - -> Network.TransactionExtrasType.MEMO - // region Other blockchains - Blockchain.Unknown, - Blockchain.Alephium, - Blockchain.AlephiumTestnet, - Blockchain.Arbitrum, - Blockchain.ArbitrumTestnet, - Blockchain.Avalanche, - Blockchain.AvalancheTestnet, - Blockchain.BinanceTestnet, - Blockchain.BSC, - Blockchain.BSCTestnet, - Blockchain.Bitcoin, - Blockchain.BitcoinTestnet, - Blockchain.BitcoinCash, - Blockchain.BitcoinCashTestnet, - Blockchain.Cardano, - Blockchain.CosmosTestnet, - Blockchain.Dogecoin, - Blockchain.Ducatus, - Blockchain.Ethereum, - Blockchain.EthereumTestnet, - Blockchain.EthereumClassic, - Blockchain.EthereumClassicTestnet, - Blockchain.Fantom, - Blockchain.FantomTestnet, - Blockchain.Litecoin, - Blockchain.Near, - Blockchain.NearTestnet, - Blockchain.Polkadot, - Blockchain.PolkadotTestnet, - Blockchain.Kava, - Blockchain.KavaTestnet, - Blockchain.Kusama, - Blockchain.Polygon, - Blockchain.PolygonTestnet, - Blockchain.RSK, - Blockchain.SeiTestnet, - Blockchain.StellarTestnet, - Blockchain.Solana, - Blockchain.SolanaTestnet, - Blockchain.Tezos, - Blockchain.Tron, - Blockchain.TronTestnet, - Blockchain.Gnosis, - Blockchain.Dash, - Blockchain.Optimism, - Blockchain.OptimismTestnet, - Blockchain.Dischain, - Blockchain.EthereumPow, - Blockchain.EthereumPowTestnet, - Blockchain.Kaspa, - Blockchain.KaspaTestnet, - Blockchain.Telos, - Blockchain.TelosTestnet, - Blockchain.TONTestnet, - Blockchain.Ravencoin, - Blockchain.Clore, - Blockchain.RavencoinTestnet, - Blockchain.Cronos, - Blockchain.AlephZero, - Blockchain.AlephZeroTestnet, - Blockchain.OctaSpace, - Blockchain.OctaSpaceTestnet, - Blockchain.Chia, - Blockchain.ChiaTestnet, - Blockchain.Decimal, - Blockchain.DecimalTestnet, - Blockchain.XDC, - Blockchain.XDCTestnet, - Blockchain.VeChain, - Blockchain.VeChainTestnet, - Blockchain.Aptos, - Blockchain.AptosTestnet, - Blockchain.Playa3ull, - Blockchain.Shibarium, - Blockchain.ShibariumTestnet, - Blockchain.AlgorandTestnet, - Blockchain.HederaTestnet, - Blockchain.Aurora, - Blockchain.AuroraTestnet, - Blockchain.Areon, - Blockchain.AreonTestnet, - Blockchain.PulseChain, - Blockchain.PulseChainTestnet, - Blockchain.ZkSyncEra, - Blockchain.ZkSyncEraTestnet, - Blockchain.Nexa, - Blockchain.NexaTestnet, - Blockchain.Moonbeam, - Blockchain.MoonbeamTestnet, - Blockchain.Manta, - Blockchain.MantaTestnet, - Blockchain.PolygonZkEVM, - Blockchain.PolygonZkEVMTestnet, - Blockchain.Radiant, - Blockchain.Fact0rn, - Blockchain.Base, - Blockchain.BaseTestnet, - Blockchain.Moonriver, - Blockchain.MoonriverTestnet, - Blockchain.Mantle, - Blockchain.MantleTestnet, - Blockchain.Flare, - Blockchain.FlareTestnet, - Blockchain.Taraxa, - Blockchain.TaraxaTestnet, - Blockchain.Koinos, - Blockchain.KoinosTestnet, - Blockchain.Joystream, - Blockchain.Bittensor, - Blockchain.Filecoin, - Blockchain.Blast, - Blockchain.BlastTestnet, - Blockchain.Cyber, - Blockchain.CyberTestnet, - Blockchain.Sui, - Blockchain.SuiTestnet, - Blockchain.EnergyWebChain, - Blockchain.EnergyWebChainTestnet, - Blockchain.EnergyWebX, - Blockchain.EnergyWebXTestnet, - Blockchain.CasperTestnet, - Blockchain.Core, - Blockchain.CoreTestnet, - Blockchain.Xodex, - Blockchain.Canxium, - Blockchain.Chiliz, - Blockchain.ChilizTestnet, - Blockchain.VanarChain, - Blockchain.VanarChainTestnet, - Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet, - Blockchain.Bitrock, Blockchain.BitrockTestnet, - Blockchain.Sonic, Blockchain.SonicTestnet, - Blockchain.ApeChain, Blockchain.ApeChainTestnet, - Blockchain.Scroll, Blockchain.ScrollTestnet, - Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet, - Blockchain.Pepecoin, Blockchain.PepecoinTestnet, - Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet, - Blockchain.Quai, Blockchain.QuaiTestnet, - Blockchain.Linea, Blockchain.LineaTestnet, - Blockchain.ArbitrumNova, - Blockchain.Plasma, Blockchain.PlasmaTestnet, - Blockchain.Adi, Blockchain.AdiTestnet, - Blockchain.SeiEvm, Blockchain.SeiEvmTestnet, - Blockchain.Monad, Blockchain.MonadTestnet, - -> Network.TransactionExtrasType.NONE - // endregion - } - } - private fun Blockchain.getNameResolvingType(): Network.NameResolvingType { return when (this) { Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt index 3f4e122ff6..0a1d238a12 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt @@ -4,4 +4,5 @@ enum class SourceType { WALLET_CONNECT, SEND, MAIN_SCREEN, + ADDRESS_BOOK, } \ No newline at end of file diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts index 6cf352ac01..a3833531f8 100644 --- a/features/address-book/impl/build.gradle.kts +++ b/features/address-book/impl/build.gradle.kts @@ -19,6 +19,8 @@ dependencies { implementation(projects.domain.account) implementation(projects.domain.addressBook) implementation(projects.domain.models) + implementation(projects.domain.qrScanning) + implementation(projects.domain.qrScanning.models) implementation(projects.domain.wallets) /** Common */ diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt index 62d3e586a8..43f75ec912 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt @@ -31,6 +31,7 @@ internal class DefaultAddAddressComponent( data class Params( val onBackClick: () -> Unit, + val onSelectNetworksClick: (address: String, selectedNetworkIds: List) -> Unit, val onConfirm: (ValidatedAddress) -> Unit, ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt index 925354b0d5..36a6d06870 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt @@ -1,65 +1,113 @@ package com.tangem.features.addressbook.addaddress.model +import arrow.core.getOrElse +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.common.routing.AppRoute 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.navigation.Router import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.domain.account.supplier.MultiAccountListSupplier -import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.SourceType +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent import com.tangem.features.addressbook.addaddress.state.AddAddressStateController import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddAddressInitialStateTransformer import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressInputTransformer import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer +import com.tangem.features.addressbook.addaddress.state.transformers.UpdateMemoInputTransformer import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.common.AddressMemoValidator +import com.tangem.features.addressbook.common.SelectNetworksResultHolder +import com.tangem.features.addressbook.common.SupportedNetworksMatcher +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* import javax.inject.Inject -@OptIn(FlowPreview::class) +@Suppress("LongParameterList") +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @ModelScoped internal class AddAddressModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, - multiAccountListSupplier: MultiAccountListSupplier, + private val supportedNetworksMatcher: SupportedNetworksMatcher, + private val memoValidator: AddressMemoValidator, + private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val clipboardManager: ClipboardManager, private val stateController: AddAddressStateController, + private val selectNetworksResultHolder: SelectNetworksResultHolder, + private val router: Router, ) : Model() { private val params: DefaultAddAddressComponent.Params = paramsContainer.require() val state: StateFlow get() = stateController.uiState - private val availableCoins: StateFlow> = multiAccountListSupplier() - .map { accountLists -> - accountLists - .flatMap { it.flattenCurrencies() } - .filterIsInstance() - .distinctBy { it.network.id } - } - .flowOn(dispatchers.default) - .stateIn(modelScope, SharingStarted.Eagerly, emptyList()) - - private val addressInput = state + private val validation: StateFlow = state .map { it.addressField.value } .distinctUntilChanged() .debounce(ADD_ADDRESS_DEBOUNCE) + .map { address -> + AddressValidation(address = address, matchedBlockchains = supportedNetworksMatcher.match(address)) + } + .flowOn(dispatchers.default) + .stateIn(modelScope, SharingStarted.Eagerly, AddressValidation(address = "", matchedBlockchains = emptyList())) + + /** `true` when a non-blank memo doesn't pass the chosen network's format rules (e.g. XRP destination tag). */ + private val isMemoInvalid = MutableStateFlow(false) + + private val selectedNetworkIds = MutableStateFlow?>(null) + + private val chosenNetworks: StateFlow = combine( + validation, + selectedNetworkIds, + ) { validation, selected -> + val matched = validation.matchedBlockchains + ChosenNetworks( + address = validation.address, + matched = matched, + displayed = displayedNetworks(matched, selected), + selected = selectedNetworks(matched, selected), + ) + } + .flowOn(dispatchers.default) + .stateIn( + modelScope, + SharingStarted.Eagerly, + ChosenNetworks(address = "", matched = emptyList(), displayed = emptyList(), selected = emptyList()), + ) init { + // Drop any selection left over from a previous AddAddress session before subscribing to it. + selectNetworksResultHolder.clear() updateInitialState() - subscribeToAddressValidation() + subscribeToValidation() + subscribeToMemoValidation() + resetSelectionOnAddressChange() + subscribeToSelectedNetworks() + subscribeToQrScanResult() } private fun updateInitialState() { stateController.update( UpdateAddAddressInitialStateTransformer( - onAddressChange = { onAddressChange(value = it) }, - onAddressClear = { onAddressChange("") }, - onPasteClick = ::onPaste, - onQrClick = { /* [REDACTED_TODO_COMMENT] */ }, - onBackClick = params.onBackClick, - onConfirmClick = ::validateAndConfirm, + intents = UpdateAddAddressInitialStateTransformer.Intents( + onAddressChange = ::onAddressChange, + onAddressClear = { onAddressChange("") }, + onPasteClick = ::onPaste, + onQrClick = ::onQrClick, + onBackClick = params.onBackClick, + onNetworkClick = ::onNetworkClick, + onMemoChange = ::onMemoChange, + onMemoPasteClick = ::onMemoPaste, + onConfirmClick = ::validateAndConfirm, + ), ), ) } @@ -68,24 +116,138 @@ internal class AddAddressModel @Inject constructor( stateController.update(UpdateAddressInputTransformer(value = value)) } - private fun subscribeToAddressValidation() { - combine(addressInput, availableCoins) { input, coins -> - UpdateAddressValidationTransformer(address = input, coins = coins) + private fun onMemoChange(value: String) { + stateController.update(UpdateMemoInputTransformer(value = value)) + } + + private fun subscribeToValidation() { + combine(chosenNetworks, isMemoInvalid) { networks, memoInvalid -> + UpdateAddressValidationTransformer( + address = networks.address, + matchedBlockchains = networks.matched, + displayedBlockchains = networks.displayed, + selectedBlockchains = networks.selected, + isMemoInvalid = memoInvalid, + ) } .onEach(stateController::update) .flowOn(dispatchers.default) .launchIn(modelScope) } + private fun subscribeToMemoValidation() { + val memoInput = state.map { it.memoField.value }.distinctUntilChanged().debounce(MEMO_DEBOUNCE) + combine(memoInput, chosenNetworks) { memo, networks -> memo to networks.extrasBlockchain } + .mapLatest { (memo, blockchain) -> + blockchain != null && memo.isNotBlank() && !memoValidator.isValid(blockchain, memo) + } + .onEach { isMemoInvalid.value = it } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun resetSelectionOnAddressChange() { + validation + .map { it.address } + .distinctUntilChanged() + .onEach { selectedNetworkIds.value = null } + .launchIn(modelScope) + } + + private fun subscribeToSelectedNetworks() { + selectNetworksResultHolder.selectedNetworkIds + .filterNotNull() + .onEach { ids -> + selectedNetworkIds.value = ids + selectNetworksResultHolder.clear() + } + .launchIn(modelScope) + } + private fun onPaste() { onAddressChange(value = clipboardManager.getText().orEmpty()) } + private fun onMemoPaste() { + onMemoChange(value = clipboardManager.getText().orEmpty()) + } + + private fun onQrClick() { + router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.AddressBook)) + } + + private fun subscribeToQrScanResult() { + listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) + .getOrElse { emptyFlow() } + .onEach { onAddressChange(value = normalizeScannedAddress(it)) } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + /** + * Extracts the bare address from a scanned payment URI like `ethereum:0xADDR@1?amount=1.5`: drops the query + * (`?…`), the chain suffix (`@…`) and the scheme (`scheme:`). A plain address is returned unchanged. + */ + private fun normalizeScannedAddress(raw: String): String { + val withoutQueryAndChain = raw.trim().substringBefore('?').substringBefore('@') + return withoutQueryAndChain.substringAfter(':', missingDelimiterValue = withoutQueryAndChain) + } + + private fun onNetworkClick() { + params.onSelectNetworksClick( + stateController.uiState.value.addressField.value, + selectedNetworkIds.value?.toList().orEmpty(), + ) + } + private fun validateAndConfirm() { - // TODO Address book ([REDACTED_TASK_KEY]): navigate to the network-selection with the address and its matching networks. + val networks = chosenNetworks.value + if (networks.selected.isEmpty()) return + + val memoField = stateController.uiState.value.memoField + val memo = memoField.value.trim().takeIf { memoField.isVisible && it.isNotEmpty() } + params.onConfirm( + ValidatedAddress( + address = networks.address, + networkIds = networks.selected.map { it.toNetworkId() }.toImmutableList(), + memo = memo, + ), + ) + } + + /** What the network block shows: all matched networks until the user narrows them down, then the picked subset. */ + private fun displayedNetworks(matched: List, selected: Set?): List { + if (selected == null) return matched + return matched.filter { it.toNetworkId() in selected } + } + + /** + * What is actually selected for saving. A single matched network is auto-selected (there is nothing to choose and + * the selection screen can't be opened); otherwise the user must pick explicitly before saving. + */ + private fun selectedNetworks(matched: List, selected: Set?): List { + if (selected == null) return listOfNotNull(matched.singleOrNull()) + return matched.filter { it.toNetworkId() in selected } + } + + private data class AddressValidation( + val address: String, + val matchedBlockchains: List, + ) + + private data class ChosenNetworks( + val address: String, + val matched: List, + val displayed: List, + val selected: List, + ) { + /** The first selected network that supports a memo / destination tag, if any. */ + val extrasBlockchain: Blockchain? + get() = selected.firstOrNull { it.getSupportedTransactionExtras().isTxExtrasSupported() } } companion object { private const val ADD_ADDRESS_DEBOUNCE = 500L + private const val MEMO_DEBOUNCE = 300L } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt index b0f71b411c..9c1b47d57a 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt @@ -31,13 +31,21 @@ internal class AddAddressStateController @Inject constructor() { label = resourceReference(R.string.common_address), isError = false, ), + memoField = AddAddressUM.MemoFieldUM( + isVisible = false, + value = "", + label = resourceReference(R.string.send_extras_hint_memo), + isError = false, + onValueChange = {}, + onPasteClick = {}, + ), buttonUM = TangemButtonUM( text = TextReference.Res(R.string.address_book_add_address), type = TangemButtonType.Primary, isEnabled = false, onClick = {}, ), - chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden, onAddressChange = {}, onAddressClear = {}, onPasteClick = {}, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt index 15007b6655..089e805bfe 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt @@ -8,22 +8,34 @@ import com.tangem.utils.transformer.Transformer * state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController]. */ internal class UpdateAddAddressInitialStateTransformer( - private val onAddressChange: (String) -> Unit, - private val onAddressClear: () -> Unit, - private val onPasteClick: () -> Unit, - private val onQrClick: () -> Unit, - private val onBackClick: () -> Unit, - private val onConfirmClick: () -> Unit, + private val intents: Intents, ) : Transformer { override fun transform(prevState: AddAddressUM): AddAddressUM { return prevState.copy( - onAddressChange = onAddressChange, - onAddressClear = onAddressClear, - onPasteClick = onPasteClick, - onQrClick = onQrClick, - onBackClick = onBackClick, - buttonUM = prevState.buttonUM.copy(onClick = onConfirmClick), + onAddressChange = intents.onAddressChange, + onAddressClear = intents.onAddressClear, + onPasteClick = intents.onPasteClick, + onQrClick = intents.onQrClick, + onBackClick = intents.onBackClick, + onNetworkClick = intents.onNetworkClick, + memoField = prevState.memoField.copy( + onValueChange = intents.onMemoChange, + onPasteClick = intents.onMemoPasteClick, + ), + buttonUM = prevState.buttonUM.copy(onClick = intents.onConfirmClick), ) } + + data class Intents( + val onAddressChange: (String) -> Unit, + val onAddressClear: () -> Unit, + val onPasteClick: () -> Unit, + val onQrClick: () -> Unit, + val onBackClick: () -> Unit, + val onNetworkClick: () -> Unit, + val onMemoChange: (String) -> Unit, + val onMemoPasteClick: () -> Unit, + val onConfirmClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt index f9b84f065e..d1b7454081 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt @@ -3,23 +3,41 @@ package com.tangem.features.addressbook.addaddress.state.transformers import com.tangem.core.ui.R import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM import com.tangem.utils.transformer.Transformer /** * Updates the address field with a freshly entered/pasted [value] and clears any previous error, restoring the default - * label. The actual (re)validation runs after a debounce — see [UpdateAddressValidationTransformer]. + * label. The confirm button is disabled while validation is pending; the actual (re)validation runs after a debounce — + * see [UpdateAddressValidationTransformer]. + * + * The network selector reflects the pending validation: a non-blank address shows [ChosenNetworkStateUM.Loading], but + * an already-resolved selector keeps its networks on screen instead of flashing back to the spinner on every keystroke. */ internal class UpdateAddressInputTransformer( private val value: String, ) : Transformer { override fun transform(prevState: AddAddressUM): AddAddressUM { + val chosenNetworkState = when { + value.isBlank() -> ChosenNetworkStateUM.Hidden + prevState.chosenNetworkStateUM is ChosenNetworkStateUM.Result -> prevState.chosenNetworkStateUM + else -> ChosenNetworkStateUM.Loading + } + val memoField = if (value.isBlank()) { + prevState.memoField.copy(isVisible = false, value = "", isError = false) + } else { + prevState.memoField + } return prevState.copy( addressField = prevState.addressField.copy( value = value, isError = false, label = resourceReference(R.string.common_address), ), + chosenNetworkStateUM = chosenNetworkState, + buttonUM = prevState.buttonUM.copy(isEnabled = false), + memoField = memoField, ) } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt index 6d7c469965..8421b1efc7 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt @@ -1,28 +1,51 @@ package com.tangem.features.addressbook.addaddress.state.transformers -import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras import com.tangem.core.ui.R import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.features.addressbook.addaddress.state.transformers.converter.ChosenNetworkConverter import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toImmutableList /** - * Validates [address] against the wallet's [coins] and reflects the result in the UI. + * Reflects the result of validating an address (and its memo) in the UI. * - * The network is not chosen on this screen (it is selected on the next screen), so the address is valid when it matches - * at least one of the available networks — the same blockchain check the Send flow uses. An invalid (non-empty, - * matching nothing) address surfaces the error in the field label and disables the confirm button. + * [matchedBlockchains] are all supported networks the address resolves to. [displayedBlockchains] is what the network + * block shows — all matched networks until the user narrows them down on the SelectNetworks screen, then the picked + * subset. [selectedBlockchains] is what is actually chosen for saving (a single match is auto-selected; for several + * matches the user must pick explicitly). While the address is blank or matches nothing the network selector stays + * [ChosenNetworkStateUM.Hidden]; an invalid (non-empty, matching nothing) address surfaces the error in the field label. + * + * The confirm button is enabled only once at least one network is actually selected (and the memo, if any, is valid) — + * showing the available networks is not the same as selecting them. The memo field is shown when a selected network + * supports transaction extras; [isMemoInvalid] marks a malformed memo. */ internal class UpdateAddressValidationTransformer( private val address: String, - private val coins: List, + private val matchedBlockchains: List, + private val displayedBlockchains: List, + private val selectedBlockchains: List, + private val isMemoInvalid: Boolean, ) : Transformer { override fun transform(prevState: AddAddressUM): AddAddressUM { - val hasMatchedAnyNetwork = address.isNotBlank() && - coins.any { it.network.toBlockchain().validateAddress(address) } - val isError = address.isNotBlank() && !hasMatchedAnyNetwork + val hasMatch = matchedBlockchains.isNotEmpty() + val isError = address.isNotBlank() && !hasMatch + + val chosenNetworkState = if (hasMatch) { + ChosenNetworkStateUM.Result( + networkUMList = displayedBlockchains.map(ChosenNetworkConverter()::convert).toImmutableList(), + // A single matched network leaves nothing to choose, so the selection screen is not opened. + isClickable = matchedBlockchains.size > 1, + ) + } else { + ChosenNetworkStateUM.Hidden + } + val label = if (isError) { resourceReference(R.string.address_book_invalid_address_error) } else { @@ -30,7 +53,32 @@ internal class UpdateAddressValidationTransformer( } return prevState.copy( addressField = prevState.addressField.copy(isError = isError, label = label), - buttonUM = prevState.buttonUM.copy(isEnabled = hasMatchedAnyNetwork), + chosenNetworkStateUM = chosenNetworkState, + memoField = resolveMemoField(prevState.memoField), + buttonUM = prevState.buttonUM.copy(isEnabled = selectedBlockchains.isNotEmpty() && !isMemoInvalid), + ) + } + + /** + * Shows the memo field with the right label when a chosen network supports transaction extras; hides it and clears + * the value otherwise (e.g. the supporting network was deselected or the address changed). A malformed memo + * ([isMemoInvalid]) turns the field label into an error. + */ + private fun resolveMemoField(prevMemoField: AddAddressUM.MemoFieldUM): AddAddressUM.MemoFieldUM { + val extrasType = selectedBlockchains + .map { it.getSupportedTransactionExtras() } + .firstOrNull { it.isTxExtrasSupported() } + ?: return prevMemoField.copy(isVisible = false, value = "", isError = false) + + val fieldLabelRes = when (extrasType) { + Network.TransactionExtrasType.DESTINATION_TAG -> R.string.send_destination_tag_field + else -> R.string.send_extras_hint_memo + } + val labelRes = if (isMemoInvalid) R.string.send_memo_destination_tag_error else fieldLabelRes + return prevMemoField.copy( + isVisible = true, + label = resourceReference(labelRes), + isError = isMemoInvalid, ) } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateMemoInputTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateMemoInputTransformer.kt new file mode 100644 index 0000000000..3a0d347b70 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateMemoInputTransformer.kt @@ -0,0 +1,15 @@ +package com.tangem.features.addressbook.addaddress.state.transformers + +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateMemoInputTransformer( + private val value: String, +) : Transformer { + + override fun transform(prevState: AddAddressUM): AddAddressUM { + return prevState.copy( + memoField = prevState.memoField.copy(value = value), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/converter/ChosenNetworkConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/converter/ChosenNetworkConverter.kt new file mode 100644 index 0000000000..fdc8a26a64 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/converter/ChosenNetworkConverter.kt @@ -0,0 +1,14 @@ +package com.tangem.features.addressbook.addaddress.state.transformers.converter + +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.ui.extensions.getActiveIconRes +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM +import com.tangem.utils.converter.Converter + +internal class ChosenNetworkConverter : Converter { + + override fun convert(value: Blockchain): NetworkUM = NetworkUM( + networkName = value.fullName, + iconResId = getActiveIconRes(value), + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt index 63f24efd33..f225531ff8 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt @@ -1,11 +1,14 @@ package com.tangem.features.addressbook.addaddress.ui import android.content.res.Configuration +import androidx.compose.animation.* +import androidx.compose.animation.core.snap import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -21,6 +24,7 @@ import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM @@ -70,17 +74,12 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie onQrClick = state.onQrClick, onPasteClick = state.onPasteClick, ) - SpacerH(20.dp) - NetworkBlock( - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(16.dp)) - .fillMaxWidth() - .background(color = TangemTheme.colors3.bg.secondary), + MemoSection(memoField = state.memoField) + NetworkSelector( chosenNetworkStateUM = state.chosenNetworkStateUM, - onNetworkSelectClick = state.onNetworkClick, + onNetworkClick = state.onNetworkClick, ) - PrimaryButton(state.buttonUM) + AddButton(state.buttonUM) } } } @@ -88,7 +87,71 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie } @Composable -private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) { +private fun MemoSection(memoField: AddAddressUM.MemoFieldUM) { + AnimatedVisibility( + visible = memoField.isVisible, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + MemoRow( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 12.dp), + memoField = memoField, + ) + SpacerH(10.dp) + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResourceSafe(R.string.send_recipient_memo_footer_v2), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResourceSafe(R.string.send_recipient_memo_footer_v2_highlighted), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.primary, + ) + } + } +} + +@Composable +private fun NetworkSelector(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, onNetworkClick: () -> Unit) { + AnimatedContent( + targetState = chosenNetworkStateUM, + transitionSpec = { + ContentTransform( + targetContentEnter = fadeIn(), + initialContentExit = fadeOut(), + sizeTransform = SizeTransform(clip = false) { _, _ -> snap() }, + ) + }, + contentKey = { it::class }, + modifier = Modifier.animateContentSize(), + label = "network_selector", + ) { networkState -> + when (networkState) { + AddAddressUM.ChosenNetworkStateUM.Hidden -> Box(modifier = Modifier.fillMaxWidth()) + AddAddressUM.ChosenNetworkStateUM.Loading, + is AddAddressUM.ChosenNetworkStateUM.Result, + -> NetworkBlock( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .fillMaxWidth() + .background(color = TangemTheme.colors3.bg.secondary), + chosenNetworkStateUM = networkState, + onNetworkSelectClick = onNetworkClick, + ) + } + } +} + +@Composable +private fun ColumnScope.AddButton(buttonUM: TangemButtonUM) { Spacer(modifier = Modifier.weight(1f)) TangemButton( modifier = Modifier @@ -114,13 +177,21 @@ private fun Preview_AddAddressContent() { placeholder = resourceReference(R.string.address_book_enter_address), label = resourceReference(R.string.common_address), ), + memoField = AddAddressUM.MemoFieldUM( + isVisible = false, + value = "", + label = resourceReference(R.string.send_extras_hint_memo), + isError = false, + onValueChange = {}, + onPasteClick = {}, + ), buttonUM = TangemButtonUM( text = TextReference.Res(R.string.address_book_add_address), type = TangemButtonType.Primary, isEnabled = false, onClick = { }, ), - chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden, onAddressChange = {}, onAddressClear = {}, onPasteClick = {}, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/MemoRow.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/MemoRow.kt new file mode 100644 index 0000000000..4cc1f1da64 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/MemoRow.kt @@ -0,0 +1,101 @@ +package com.tangem.features.addressbook.addaddress.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.fields.SimpleTextField +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowContentLead +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM + +@Composable +internal fun MemoRow(memoField: AddAddressUM.MemoFieldUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .clip(RoundedCornerShape(24.dp)) + .fillMaxWidth() + .background(TangemTheme.colors3.bg.secondary), + ) { + Text( + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + text = memoField.label.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = if (memoField.isError) { + TangemTheme.colors3.text.status.error + } else { + TangemTheme.colors3.text.secondary + }, + ) + TangemRow( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = TangemRowVerticalAlignment.Center, + contentLead = TangemRowContentLead.Start, + titleSlot = { + SimpleTextField( + modifier = Modifier.weight(1f), + value = memoField.value, + onValueChange = memoField.onValueChange, + placeholder = resourceReference(R.string.send_optional_field), + ) + }, + endSlot = { + if (memoField.value.isNotEmpty()) { + Icon( + modifier = Modifier + .clip(CircleShape) + .clickable(onClick = { memoField.onValueChange("") }), + imageVector = Icons.ic_cross_circle_20_filled, + tint = TangemTheme.colors3.icon.tertiary, + contentDescription = null, + ) + } else { + TangemButton( + size = TangemButton.Size.X9, + text = TextReference.Res(id = R.string.common_paste), + onClick = memoField.onPasteClick, + ) + } + }, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_MemoRow() { + TangemThemePreviewRedesign { + MemoRow( + memoField = AddAddressUM.MemoFieldUM( + isVisible = true, + value = "123456", + label = resourceReference(R.string.send_destination_tag_field), + isError = false, + onValueChange = {}, + onPasteClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt index e2b3e1b6a5..4dd21d9cfa 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt @@ -20,12 +20,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.ds2.loader.TangemLoader import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.row.TangemRow import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment -import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -48,7 +46,10 @@ internal fun NetworkBlock( chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, modifier: Modifier = Modifier, ) { + val isClickable = chosenNetworkStateUM is AddAddressUM.ChosenNetworkStateUM.Result && + chosenNetworkStateUM.isClickable TangemRow( + onClick = if (isClickable) onNetworkSelectClick else null, verticalAlignment = TangemRowVerticalAlignment.Center, modifier = modifier, titleSlot = { @@ -59,45 +60,27 @@ internal fun NetworkBlock( ) }, endSlot = { - SelectNetworkButton( - onNetworkSelectClick = onNetworkSelectClick, - chosenNetworkStateUM = chosenNetworkStateUM, - ) + when (chosenNetworkStateUM) { + AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20) + is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkRow(chosenNetworkStateUM = chosenNetworkStateUM) + AddAddressUM.ChosenNetworkStateUM.Hidden -> Unit + } }, ) } @Composable -private fun SelectNetworkButton( - onNetworkSelectClick: () -> Unit, - chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, -) { - Row( - modifier = Modifier.clickableSingle( - onClick = onNetworkSelectClick, - enabled = chosenNetworkStateUM !is AddAddressUM.ChosenNetworkStateUM.Loading, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - when (chosenNetworkStateUM) { - is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList) - AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20) - AddAddressUM.ChosenNetworkStateUM.Empty -> { - Text( - modifier = Modifier.padding(start = 8.dp), - text = stringResourceSafe(R.string.address_book_select_network), - style = TangemTheme.typography3.body.medium, - color = TangemTheme.colors3.text.secondary, - ) - SpacerW(4.dp) - ChevronIcon() - } - } +private fun NetworkRow(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM.Result) { + Row(verticalAlignment = Alignment.CenterVertically) { + NetworkIconsResolver( + networks = chosenNetworkStateUM.networkUMList, + showChevron = chosenNetworkStateUM.isClickable, + ) } } @Composable -private fun NetworkIconsResolver(networks: ImmutableList) { +private fun NetworkIconsResolver(networks: ImmutableList, showChevron: Boolean) { when (networks.size) { 0 -> Unit 1 -> { @@ -112,13 +95,13 @@ private fun NetworkIconsResolver(networks: ImmutableList) { style = TangemTheme.typography3.body.medium, color = TangemTheme.colors3.text.secondary, ) - ChevronIcon() + if (showChevron) ChevronIcon() } // 3 and any larger count share the same rendering: up to MAX_VISIBLE_NETWORKS overlapping // icons, plus a "+N" badge that appears only when there are more than that. else -> { OverlappingNetworkIcons(networks) - ChevronIcon() + if (showChevron) ChevronIcon() } } } @@ -191,6 +174,7 @@ private fun Preview_NetworkBlock() { networkUMList = persistentListOf( NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22), ), + isClickable = false, ), ) SpacerH12() @@ -202,6 +186,7 @@ private fun Preview_NetworkBlock() { NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22), NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22), ), + isClickable = true, ), ) SpacerH12() @@ -211,12 +196,9 @@ private fun Preview_NetworkBlock() { networkUMList = List(15) { NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22) }.toImmutableList(), + isClickable = true, ), ) - SpacerH12() - NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, onNetworkSelectClick = {}) - SpacerH12() - NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, onNetworkSelectClick = {}) } } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt index 45704c2355..b01a540f2f 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt @@ -3,11 +3,13 @@ package com.tangem.features.addressbook.addaddress.ui.state import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @Immutable internal data class AddAddressUM( val addressField: AddressFieldUM, + val memoField: MemoFieldUM, val buttonUM: TangemButtonUM, val chosenNetworkStateUM: ChosenNetworkStateUM, val onAddressChange: (String) -> Unit, @@ -17,12 +19,39 @@ internal data class AddAddressUM( val onBackClick: () -> Unit, val onNetworkClick: () -> Unit, ) { + + /** + * Optional memo / destination-tag input shown below the address only when a chosen network supports transaction + * extras. [isVisible] toggles the whole field; [label] adapts to memo vs destination tag. + */ + @Immutable + data class MemoFieldUM( + val isVisible: Boolean, + val value: String, + val label: TextReference, + val isError: Boolean, + val onValueChange: (String) -> Unit, + val onPasteClick: () -> Unit, + ) + @Immutable sealed interface ChosenNetworkStateUM { - data object Loading : ChosenNetworkStateUM - data object Empty : ChosenNetworkStateUM - data class Result(val networkUMList: ImmutableList) : ChosenNetworkStateUM { + /** No address entered yet, or the address matched nothing — the network selector is not shown. */ + data object Hidden : ChosenNetworkStateUM + + /** A non-blank address is being validated against the supported networks. */ + data object Loading : ChosenNetworkStateUM + + /** + * A valid address resolved to [networkUMList] (the currently selected networks). [isClickable] is `false` when + * the address matched only a single network — there is nothing to choose, so the network-selection screen is + * not opened. + */ + data class Result( + val networkUMList: ImmutableList, + val isClickable: Boolean, + ) : ChosenNetworkStateUM { data class NetworkUM( val networkName: String, @DrawableRes val iconResId: Int, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt index d07a64c7f5..457ea265a1 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt @@ -9,6 +9,7 @@ import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.route.AddressBookRoute +import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent import kotlinx.collections.immutable.persistentListOf import javax.inject.Inject @@ -47,9 +48,19 @@ internal class AddressBookChildFactory @Inject constructor( appComponentContext = context, params = DefaultAddAddressComponent.Params( onBackClick = clickIntents::onAddAddressBack, + onSelectNetworksClick = clickIntents::onSelectNetworksClick, onConfirm = clickIntents::onAddressConfirmed, ), ) + is AddressBookRoute.SelectNetworks -> DefaultSelectNetworksComponent( + appComponentContext = context, + params = DefaultSelectNetworksComponent.Params( + address = route.address, + selectedNetworkIds = route.selectedNetworkIds, + onBackClick = clickIntents::onSelectNetworksBack, + onDone = clickIntents::onNetworksSelected, + ), + ) } /** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */ diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt index 5b13a5204f..315ba66a4a 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt @@ -23,4 +23,10 @@ internal interface AddressBookClickIntents { fun onAddAddressBack() fun onAddressConfirmed(address: ValidatedAddress) + + fun onSelectNetworksClick(address: String, selectedNetworkIds: List) + + fun onSelectNetworksBack() + + fun onNetworksSelected(selectedNetworkIds: Set) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressMemoValidator.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressMemoValidator.kt new file mode 100644 index 0000000000..8302fbbaef --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressMemoValidator.kt @@ -0,0 +1,28 @@ +package com.tangem.features.addressbook.common + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.memo.MemoState +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchainsdk.BlockchainSDKFactory +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class AddressMemoValidator @Inject constructor( + private val blockchainSDKFactory: BlockchainSDKFactory, + private val dispatchers: CoroutineDispatcherProvider, +) { + + suspend fun isValid(blockchain: Blockchain, memo: String): Boolean = withContext(dispatchers.io) { + val factory = blockchainSDKFactory.getMemoValidatorFactorySync() ?: return@withContext true + when (val result = factory.create(blockchain).validateMemo(memo)) { + is Result.Success -> when (result.data) { + MemoState.Valid, + MemoState.NotSupported, + -> true + MemoState.Invalid -> false + } + is Result.Failure -> true + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt index 5066885b29..3e216c7bb1 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt @@ -29,13 +29,15 @@ internal class DefaultAddressBookComponent @AssistedInject constructor( @Assisted private val params: AddressBookComponent.Params, private val childFactory: AddressBookChildFactory, private val resultHolder: AddressBookResultHolder, + private val selectNetworksResultHolder: SelectNetworksResultHolder, ) : AddressBookComponent, AppComponentContext by context { private val navigation = StackNavigation() init { - // Drop any address left over from a previous session before the (possibly preloaded) stack starts collecting. + // Drop any results left over from a previous session before the (possibly preloaded) stack starts collecting. resultHolder.clear() + selectNetworksResultHolder.clear() } private val clickIntents = object : AddressBookClickIntents { @@ -64,6 +66,21 @@ internal class DefaultAddressBookComponent @AssistedInject constructor( resultHolder.setConfirmedAddress(address) navigation.pop() } + + override fun onSelectNetworksClick(address: String, selectedNetworkIds: List) { + navigation.pushNew( + AddressBookRoute.SelectNetworks(address = address, selectedNetworkIds = selectedNetworkIds), + ) + } + + override fun onSelectNetworksBack() { + navigation.pop() + } + + override fun onNetworksSelected(selectedNetworkIds: Set) { + selectNetworksResultHolder.setSelectedNetworkIds(selectedNetworkIds) + navigation.pop() + } } private val contentStack = childStack( diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SelectNetworksResultHolder.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SelectNetworksResultHolder.kt new file mode 100644 index 0000000000..7895cdee16 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SelectNetworksResultHolder.kt @@ -0,0 +1,30 @@ +package com.tangem.features.addressbook.common + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Carries the set of network ids confirmed on the SelectNetworks screen back to the AddAddress screen. + * + * The two screens live in independent model scopes, so a shared singleton holder hands the result over instead of + * routing it through navigation. Only the "Done" action sets a result; the producer calls [setSelectedNetworkIds], the + * consumer observes [selectedNetworkIds] and calls [clear] after applying it so it is not re-applied on resubscription. + * + * Mirrors [AddressBookResultHolder]. + */ +@Singleton +internal class SelectNetworksResultHolder @Inject constructor() { + + val selectedNetworkIds: StateFlow?> + field = MutableStateFlow?>(null) + + fun setSelectedNetworkIds(networkIds: Set) { + selectedNetworkIds.value = networkIds + } + + fun clear() { + selectedNetworkIds.value = null + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SupportedNetworksMatcher.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SupportedNetworksMatcher.kt new file mode 100644 index 0000000000..b21065b273 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SupportedNetworksMatcher.kt @@ -0,0 +1,26 @@ +package com.tangem.features.addressbook.common + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import javax.inject.Inject + +/** + * Finds every supported mainnet network whose address format matches a given address. + * + * The match runs over the whole SDK blockchain set (minus testnets and excluded chains), not just the networks already + * added to the wallet — entering/scanning an address must surface every network it could belong to. + */ +internal class SupportedNetworksMatcher @Inject constructor( + excludedBlockchains: ExcludedBlockchains, +) { + + private val supportedBlockchains: List = Blockchain.entries + .filter { !it.isTestnet() && it !in excludedBlockchains } + + fun match(address: String): List { + if (address.isBlank()) return emptyList() + return supportedBlockchains.filter { blockchain -> + runCatching { blockchain.validateAddress(address) }.getOrDefault(false) + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt index 9b21153da9..cd4e7b32e2 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt @@ -6,6 +6,7 @@ import com.tangem.features.addressbook.addaddress.model.AddAddressModel import com.tangem.features.addressbook.block.model.ContactsBlockModel import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.editcontact.model.EditContactModel +import com.tangem.features.addressbook.selectnetworks.model.SelectNetworksModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -35,4 +36,9 @@ internal interface AddressBookModelModule { @IntoMap @ClassKey(AddAddressModel::class) fun bindAddAddressModel(model: AddAddressModel): Model + + @Binds + @IntoMap + @ClassKey(SelectNetworksModel::class) + fun bindSelectNetworksModel(model: SelectNetworksModel): Model } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt index 8d36e4c6a8..17d22645a8 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt @@ -9,9 +9,12 @@ import kotlinx.collections.immutable.ImmutableList * A single address can belong to several networks (e.g. the same address across EVM chains), so it carries a list of * [networkIds]. This is the in-progress (pre-save) representation accumulated in [EditContactUM]; the [networkIds] are * used to rebuild the domain `AddressEntry`s when the contact is persisted. + * [memo] is an optional destination tag / memo entered for networks that support transaction extras (XRP, Stellar, TON, + * …). It is `null` when the matched networks don't support extras or the user left it empty. */ @Immutable data class ValidatedAddress( val address: String, val networkIds: ImmutableList, + val memo: String? = null, ) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt index e0a39904b6..caa8ece2d7 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt @@ -30,6 +30,16 @@ internal sealed class AddressBookRoute { @Serializable data object AddAddress : AddressBookRoute() + /** + * Network-selection screen for the [address] entered on [AddAddress]. [selectedNetworkIds] carries the current + * selection so it can be restored; empty means nothing is pre-selected. + */ + @Serializable + data class SelectNetworks( + val address: String, + val selectedNetworkIds: kotlin.collections.List = emptyList(), + ) : AddressBookRoute() + /** How the contacts list is shown — agnostic of which feature opened it. */ @Serializable sealed interface ListMode { diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt new file mode 100644 index 0000000000..f36705c5be --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.addressbook.selectnetworks + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.addressbook.selectnetworks.model.SelectNetworksModel +import com.tangem.features.addressbook.selectnetworks.ui.SelectNetworksContent + +internal class DefaultSelectNetworksComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: SelectNetworksModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + SelectNetworksContent( + state = state, + modifier = modifier, + ) + BackHandler(onBack = state.onBackClick) + } + + data class Params( + val address: String, + val selectedNetworkIds: List, + val onBackClick: () -> Unit, + val onDone: (selectedNetworkIds: Set) -> Unit, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt new file mode 100644 index 0000000000..594990b053 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt @@ -0,0 +1,97 @@ +package com.tangem.features.addressbook.selectnetworks.model + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.addressbook.common.SupportedNetworksMatcher +import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent +import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController +import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateNetworksContentTransformer +import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateSelectNetworksInitialStateTransformer +import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateSelectNetworksSearchBarTransformer +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@Suppress("NamedArguments") +@ModelScoped +internal class SelectNetworksModel @Inject constructor( + paramsContainer: ParamsContainer, + supportedNetworksMatcher: SupportedNetworksMatcher, + override val dispatchers: CoroutineDispatcherProvider, + private val stateController: SelectNetworksStateController, +) : Model() { + + private val params: DefaultSelectNetworksComponent.Params = paramsContainer.require() + private val query = MutableStateFlow("") + private val isSearchActive = MutableStateFlow(false) + + private val matchedBlockchains: List = supportedNetworksMatcher.match(params.address) + + private val selectedNetworks = MutableStateFlow( + params.selectedNetworkIds.toSet().intersect( + matchedBlockchains.map { blockchain -> blockchain.toNetworkId() }.toSet(), + ), + ) + + val state: StateFlow get() = stateController.uiState + + init { + updateInitialState() + subscribeToContent() + } + + private fun updateInitialState() { + stateController.update( + UpdateSelectNetworksInitialStateTransformer( + onQueryChange = ::onQueryChange, + onActiveChange = ::onActiveChange, + onBackClick = params.onBackClick, + onDoneClick = ::onDoneClick, + ), + ) + } + + private fun subscribeToContent() { + combine(query, selectedNetworks) { query, selection -> + UpdateNetworksContentTransformer( + matchedBlockchains = matchedBlockchains, + query = query, + selectedNetworkIds = selection, + onToggle = ::onToggle, + ) + } + .onEach(stateController::update) + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun onQueryChange(value: String) { + query.value = value + updateSearchBar(query = value, isActive = isSearchActive.value) + } + + private fun onActiveChange(isActive: Boolean) { + isSearchActive.value = isActive + updateSearchBar(query = query.value, isActive = isActive) + } + + /** Reflects the search field immediately on the caller (main) thread, decoupled from the content recomputation. */ + private fun updateSearchBar(query: String, isActive: Boolean) { + stateController.update(UpdateSelectNetworksSearchBarTransformer(query = query, isActive = isActive)) + } + + private fun onToggle(networkId: String) { + val current = selectedNetworks.value + selectedNetworks.value = if (networkId in current) current - networkId else current + networkId + } + + private fun onDoneClick() { + val selected = selectedNetworks.value + if (selected.isEmpty()) return + params.onDone(selected) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/SelectNetworksStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/SelectNetworksStateController.kt new file mode 100644 index 0000000000..5b7275aeb0 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/SelectNetworksStateController.kt @@ -0,0 +1,47 @@ +package com.tangem.features.addressbook.selectnetworks.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds2.search.TangemSearch +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +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 SelectNetworksStateController @Inject constructor() { + + val uiState: StateFlow + field = MutableStateFlow(value = getInitialState()) + + fun update(transformer: Transformer) { + uiState.update(function = transformer::transform) + } + + private fun getInitialState(): SelectNetworksUM = SelectNetworksUM( + searchBar = TangemSearch.State( + placeholderText = resourceReference(R.string.common_search), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + onClearClick = {}, + onCloseClick = {}, + ), + networks = persistentListOf(), + doneButton = TangemButtonUM( + text = TextReference.Res(R.string.common_done), + type = TangemButtonType.Primary, + isEnabled = false, + onClick = {}, + ), + onBackClick = {}, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateNetworksContentTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateNetworksContentTransformer.kt new file mode 100644 index 0000000000..63837c244a --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateNetworksContentTransformer.kt @@ -0,0 +1,45 @@ +package com.tangem.features.addressbook.selectnetworks.state.transformers + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.features.addressbook.selectnetworks.state.transformers.converter.SelectNetworkItemConverter +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toImmutableList + +internal class UpdateNetworksContentTransformer( + private val matchedBlockchains: List, + private val query: String, + private val selectedNetworkIds: Set, + private val onToggle: (networkId: String) -> Unit, +) : Transformer { + + override fun transform(prevState: SelectNetworksUM): SelectNetworksUM { + val visible = if (query.isBlank()) { + matchedBlockchains + } else { + matchedBlockchains.filter { blockchain -> + blockchain.fullName.contains(query, ignoreCase = true) || + blockchain.currency.contains(query, ignoreCase = true) || + blockchain.name.contains(query, ignoreCase = true) + } + } + val networks = visible + .map { blockchain -> + SelectNetworkItemConverter().convert( + SelectNetworkItemConverter.Input( + blockchain = blockchain, + isSelected = blockchain.toNetworkId() in selectedNetworkIds, + onToggle = onToggle, + ), + ) + } + .toImmutableList() + + // Search field is owned by UpdateSelectNetworksSearchBarTransformer and intentionally left untouched here. + return prevState.copy( + networks = networks, + doneButton = prevState.doneButton.copy(isEnabled = selectedNetworkIds.isNotEmpty()), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksInitialStateTransformer.kt new file mode 100644 index 0000000000..2421d856d8 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksInitialStateTransformer.kt @@ -0,0 +1,25 @@ +package com.tangem.features.addressbook.selectnetworks.state.transformers + +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateSelectNetworksInitialStateTransformer( + private val onQueryChange: (String) -> Unit, + private val onActiveChange: (Boolean) -> Unit, + private val onBackClick: () -> Unit, + private val onDoneClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: SelectNetworksUM): SelectNetworksUM { + return prevState.copy( + searchBar = prevState.searchBar.copy( + onQueryChange = onQueryChange, + onActiveChange = onActiveChange, + onCloseClick = { onActiveChange(false) }, + onClearClick = { onQueryChange("") }, + ), + doneButton = prevState.doneButton.copy(onClick = onDoneClick), + onBackClick = onBackClick, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksSearchBarTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksSearchBarTransformer.kt new file mode 100644 index 0000000000..874f4478bd --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksSearchBarTransformer.kt @@ -0,0 +1,16 @@ +package com.tangem.features.addressbook.selectnetworks.state.transformers + +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateSelectNetworksSearchBarTransformer( + private val query: String, + private val isActive: Boolean, +) : Transformer { + + override fun transform(prevState: SelectNetworksUM): SelectNetworksUM { + return prevState.copy( + searchBar = prevState.searchBar.copy(query = query, isActive = isActive), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/converter/SelectNetworkItemConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/converter/SelectNetworkItemConverter.kt new file mode 100644 index 0000000000..8c1219e6ec --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/converter/SelectNetworkItemConverter.kt @@ -0,0 +1,28 @@ +package com.tangem.features.addressbook.selectnetworks.state.transformers.converter + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.common.ui.extensions.getActiveIconRes +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM.NetworkItemUM +import com.tangem.utils.converter.Converter + +internal class SelectNetworkItemConverter : Converter { + + data class Input( + val blockchain: Blockchain, + val isSelected: Boolean, + val onToggle: (networkId: String) -> Unit, + ) + + override fun convert(value: Input): NetworkItemUM { + val id = value.blockchain.toNetworkId() + return NetworkItemUM( + id = id, + name = value.blockchain.fullName, + symbol = value.blockchain.currency, + iconResId = getActiveIconRes(value.blockchain), + isSelected = value.isSelected, + onCheckedChange = { value.onToggle(id) }, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/SelectNetworksContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/SelectNetworksContent.kt new file mode 100644 index 0000000000..efa891b007 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/SelectNetworksContent.kt @@ -0,0 +1,195 @@ +package com.tangem.features.addressbook.selectnetworks.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerWMax +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.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.checkbox.TangemCheckmark +import com.tangem.core.ui.ds2.search.TangemSearch +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM.NetworkItemUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun SelectNetworksContent(state: SelectNetworksUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(color = TangemTheme.colors3.bg.primary) + .systemBarsPadding(), + ) { + TangemTopBar( + title = resourceReference(R.string.common_choose_network), + startContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24), + onClick = state.onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + ) + TangemSearch( + state = state.searchBar, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) + val doneButtonVerticalPadding = 12.dp + val doneButtonAreaHeight = 48.dp + doneButtonVerticalPadding * 2 + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = doneButtonAreaHeight) + .background( + color = TangemTheme.colors3.bg.secondary, + shape = RoundedCornerShape(24.dp), + ), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + item { + Text( + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + text = stringResourceSafe(R.string.common_available_networks), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + } + items(items = state.networks, key = NetworkItemUM::id) { item -> + NetworkRow(item = item) + } + } + TangemButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = doneButtonVerticalPadding) + .imePadding(), + onClick = state.doneButton.onClick, + isEnabled = state.doneButton.isEnabled, + size = TangemButton.Size.X12, + text = state.doneButton.text, + ) + } + } +} + +@Composable +private fun NetworkRow(item: NetworkItemUM) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickableSingle(onClick = item.onCheckedChange) + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = item.iconResId), + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + Text( + modifier = Modifier.padding(start = 12.dp), + text = item.name, + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.primary, + ) + Text( + modifier = Modifier.padding(start = 4.dp), + text = item.symbol, + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.secondary, + ) + SpacerWMax() + TangemCheckmark( + checked = item.isSelected, + onCheckedChange = { item.onCheckedChange() }, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_SelectNetworksContent() { + TangemThemePreviewRedesign { + SelectNetworksContent( + state = SelectNetworksUM( + searchBar = TangemSearch.State( + placeholderText = resourceReference(R.string.common_search), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + onCloseClick = {}, + ), + networks = persistentListOf( + NetworkItemUM( + id = "ethereum", + name = "Ethereum", + symbol = "ETH", + iconResId = R.drawable.img_eth_22, + isSelected = true, + onCheckedChange = {}, + ), + NetworkItemUM( + id = "bsc", + name = "BNB Smart Chain", + iconResId = R.drawable.img_bsc_22, + isSelected = false, + symbol = "BNB", + onCheckedChange = {}, + ), + NetworkItemUM( + id = "polygon", + name = "Polygon", + iconResId = R.drawable.img_polygon_22, + isSelected = true, + symbol = "POL", + onCheckedChange = {}, + ), + ), + doneButton = TangemButtonUM( + text = TextReference.Res(R.string.common_done), + type = TangemButtonType.Primary, + isEnabled = true, + onClick = {}, + ), + onBackClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/state/SelectNetworksUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/state/SelectNetworksUM.kt new file mode 100644 index 0000000000..eb565d9c15 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/state/SelectNetworksUM.kt @@ -0,0 +1,26 @@ +package com.tangem.features.addressbook.selectnetworks.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds2.search.TangemSearch +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class SelectNetworksUM( + val searchBar: TangemSearch.State, + val networks: ImmutableList, + val doneButton: TangemButtonUM, + val onBackClick: () -> Unit, +) { + + @Immutable + data class NetworkItemUM( + val id: String, + val name: String, + val symbol: String, + @DrawableRes val iconResId: Int, + val isSelected: Boolean, + val onCheckedChange: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt index cce48d3bbe..32bfd72b9f 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt @@ -1,27 +1,33 @@ package com.tangem.features.addressbook.addaddress.model +import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Blockchain -import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.R import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.account.models.AccountList -import com.tangem.domain.account.supplier.MultiAccountListSupplier -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.SourceType +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent import com.tangem.features.addressbook.addaddress.state.AddAddressStateController +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM +import com.tangem.features.addressbook.common.AddressMemoValidator +import com.tangem.features.addressbook.common.SelectNetworksResultHolder +import com.tangem.features.addressbook.common.SupportedNetworksMatcher import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress -import com.tangem.test.mock.MockAccounts import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -33,25 +39,30 @@ import org.junit.jupiter.api.* @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class AddAddressModelTest { - private val multiAccountListSupplier: MultiAccountListSupplier = mockk() + private val supportedNetworksMatcher: SupportedNetworksMatcher = mockk() + private val memoValidator: AddressMemoValidator = mockk() private val clipboardManager: ClipboardManager = mockk() - - private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() - private val ethereum = cryptoCurrencyFactory.createCoin(Blockchain.Ethereum) - private val bitcoin = cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin) + private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk() + private val router: Router = mockk(relaxed = true) + private val selectNetworksResultHolder = SelectNetworksResultHolder() private var model: AddAddressModel? = null @BeforeEach fun resetMocks() { - clearMocks(multiAccountListSupplier, clipboardManager) - // Default: no accounts, so no coins are available unless a test overrides it. - every { multiAccountListSupplier.invoke() } returns flowOf(emptyList()) + clearMocks(supportedNetworksMatcher, memoValidator, clipboardManager, listenToQrScanningUseCase, router) + selectNetworksResultHolder.clear() + // Default: an address matches nothing unless a test stubs a specific value. + every { supportedNetworksMatcher.match(any()) } returns emptyList() + // Default: any memo passes unless a test stubs an invalid one. + coEvery { memoValidator.isValid(any(), any()) } returns true + // Default: no QR results unless a test overrides it. + every { listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) } returns flowOf().right() } @AfterEach fun tearDown() { - // Cancels modelScope, stopping the long-lived availableCoins / address-input collectors. + // Cancels modelScope, stopping the long-lived validation / address-input collectors. model?.onDestroy() model = null } @@ -98,13 +109,14 @@ internal class AddAddressModelTest { assertThat(model.state.value.addressField.value).isEqualTo(address) } - // validateAndConfirm() is an unimplemented seam — the button click must NOT emit a result yet. + // No network matches, so the button is disabled; clicking it must not emit a result. @Test - fun `GIVEN typed address WHEN button clicked THEN onConfirm not called yet`() = runTest { + fun `GIVEN no matching network WHEN button clicked THEN onConfirm not called`() = runTest { // Arrange var confirmed: ValidatedAddress? = null val model = createModel(testScope = this, onConfirm = { confirmed = it }) model.state.value.onAddressChange("0xABC") + advanceUntilIdle() // Act model.state.value.buttonUM.onClick() @@ -119,14 +131,14 @@ internal class AddAddressModelTest { inner class Validation { @Test - fun `GIVEN coins available WHEN valid address typed THEN no error AND button enabled`() = runTest { - // Arrange - every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin))) + fun `GIVEN single matching network WHEN typed THEN no error AND button enabled`() = runTest { + // Arrange — a single matched network is auto-selected, so the button is enabled right away. + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum) val model = createModel(testScope = this) advanceUntilIdle() // Act - model.state.value.onAddressChange(VALID_ETH_ADDRESS) + model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() // Assert @@ -136,14 +148,31 @@ internal class AddAddressModelTest { } @Test - fun `GIVEN coins available WHEN address matches no network THEN error AND button disabled`() = runTest { - // Arrange - every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin))) + fun `GIVEN several matching networks WHEN typed THEN no error but button disabled until selection`() = runTest { + // Arrange — several matches are shown for context, but none is selected until the user picks explicitly. + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) val model = createModel(testScope = this) advanceUntilIdle() // Act - model.state.value.onAddressChange("not-an-address") + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Assert + val state = model.state.value + assertThat(state.addressField.isError).isFalse() + assertThat(state.buttonUM.isEnabled).isFalse() + } + + @Test + fun `GIVEN address matching no network WHEN typed THEN error AND button disabled`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns emptyList() + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() // Assert @@ -157,7 +186,6 @@ internal class AddAddressModelTest { @Test fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest { // Arrange - every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum))) val model = createModel(testScope = this) advanceUntilIdle() @@ -170,54 +198,362 @@ internal class AddAddressModelTest { assertThat(state.addressField.isError).isFalse() assertThat(state.buttonUM.isEnabled).isFalse() } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class NetworkSelector { - // The address is typed before coins load; validity must resolve reactively once the supplier emits them. @Test - fun `GIVEN address typed before coins load WHEN coins emitted THEN validated reactively`() = runTest { - // Arrange - val accountsFlow = MutableStateFlow>(emptyList()) - every { multiAccountListSupplier.invoke() } returns accountsFlow + fun `GIVEN blank address WHEN validated THEN selector hidden`() = runTest { + // Act val model = createModel(testScope = this) advanceUntilIdle() - // Act — type while coins are still empty - model.state.value.onAddressChange(VALID_ETH_ADDRESS) - advanceUntilIdle() - // Assert intermediate: nothing to match yet - assertThat(model.state.value.buttonUM.isEnabled).isFalse() + // Assert + assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Hidden) + } - // Act — coins arrive later - accountsFlow.value = listOf(accountListWith(ethereum, bitcoin)) + @Test + fun `GIVEN invalid address WHEN validated THEN selector hidden`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns emptyList() + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() // Assert - val state = model.state.value - assertThat(state.buttonUM.isEnabled).isTrue() - assertThat(state.addressField.isError).isFalse() + assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Hidden) + } + + @Test + fun `GIVEN address matching several networks WHEN validated THEN all shown AND clickable`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Assert — all matched networks are shown by default; the block opens the selection screen to narrow them. + val result = model.state.value.chosenNetworkStateUM as ChosenNetworkStateUM.Result + assertThat(result.networkUMList.map { it.networkName }) + .containsExactly(Blockchain.Ethereum.fullName, Blockchain.BSC.fullName) + assertThat(result.isClickable).isTrue() + } + + @Test + fun `GIVEN address matching a single network WHEN validated THEN selector is not clickable`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Bitcoin) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Assert + val result = model.state.value.chosenNetworkStateUM as ChosenNetworkStateUM.Result + assertThat(result.networkUMList.map { it.networkName }).containsExactly(Blockchain.Bitcoin.fullName) + assertThat(result.isClickable).isFalse() + } + + @Test + fun `GIVEN valid address WHEN onNetworkClick THEN opens selector with address and default selection`() = + runTest { + // Arrange + var openedAddress: String? = null + var openedSelection: List = listOf("sentinel") + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel( + testScope = this, + onSelectNetworksClick = { address, selection -> + openedAddress = address + openedSelection = selection + }, + ) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act + model.state.value.onNetworkClick() + + // Assert — empty selection means "nothing selected yet" on the selection screen. + assertThat(openedAddress).isEqualTo(ADDRESS) + assertThat(openedSelection).isEmpty() + } + + @Test + fun `GIVEN networks chosen via holder WHEN applied THEN selector reflects subset AND confirm uses it`() = + runTest { + // Arrange + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this, onConfirm = { confirmed = it }) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act — the user keeps only Ethereum on the network-selection screen. + selectNetworksResultHolder.setSelectedNetworkIds(setOf(Blockchain.Ethereum.toNetworkId())) + advanceUntilIdle() + + // Assert — selector shows the subset and the result is consumed. + val chosen = model.state.value.chosenNetworkStateUM as ChosenNetworkStateUM.Result + assertThat(chosen.networkUMList.map { it.networkName }).containsExactly(Blockchain.Ethereum.fullName) + assertThat(selectNetworksResultHolder.selectedNetworkIds.value).isNull() + + // And confirm persists only the kept network. + model.state.value.buttonUM.onClick() + assertThat(confirmed).isEqualTo( + ValidatedAddress( + address = ADDRESS, + networkIds = persistentListOf(Blockchain.Ethereum.toNetworkId()), + ), + ) + } + + @Test + fun `GIVEN non-blank address WHEN typed THEN loading shown AND button blocked until validated`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act — typed, but validation is still debounced. + model.state.value.onAddressChange(ADDRESS) + + // Assert + assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Loading) + assertThat(model.state.value.buttonUM.isEnabled).isFalse() + } + + @Test + fun `GIVEN resolved networks WHEN address edited THEN keeps result without flashing loading`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(any()) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this) + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + assertThat(model.state.value.chosenNetworkStateUM).isInstanceOf(ChosenNetworkStateUM.Result::class.java) + + // Act — keep typing; validation is pending again. + model.state.value.onAddressChange(ADDRESS + "00") + + // Assert — the resolved networks stay on screen (no spinner), but the button is blocked while validating. + assertThat(model.state.value.chosenNetworkStateUM).isInstanceOf(ChosenNetworkStateUM.Result::class.java) + assertThat(model.state.value.buttonUM.isEnabled).isFalse() + } + + @Test + fun `GIVEN single matched network WHEN confirmed THEN it is persisted`() = runTest { + // Arrange — a single match is auto-selected, so confirm works without opening the selection screen. + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum) + val model = createModel(testScope = this, onConfirm = { confirmed = it }) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act + model.state.value.buttonUM.onClick() + + // Assert + assertThat(confirmed).isEqualTo( + ValidatedAddress( + address = ADDRESS, + networkIds = persistentListOf(Blockchain.Ethereum.toNetworkId()), + ), + ) + } + + @Test + fun `GIVEN several matched networks AND none selected WHEN confirmed THEN nothing persisted`() = runTest { + // Arrange + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this, onConfirm = { confirmed = it }) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act — networks are shown but not selected, so confirming is a no-op. + model.state.value.buttonUM.onClick() + + // Assert + assertThat(confirmed).isNull() } } - private fun accountListWith(vararg currencies: CryptoCurrency): AccountList { - val walletId = MockAccounts.userWalletId - val accounts = listOf( - Account.CryptoPortfolio.createMainAccount( - userWalletId = walletId, - cryptoCurrencies = currencies.toList(), - ), - ) - return AccountList( - userWalletId = walletId, - accounts = accounts, - totalAccounts = accounts.size, - totalArchivedAccounts = 0, - ).getOrNull()!! + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Memo { + + @Test + fun `GIVEN address matching an extras network WHEN validated THEN memo field shown`() = runTest { + // Arrange — XRP supports a destination tag. + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Assert + val memoField = model.state.value.memoField + assertThat(memoField.isVisible).isTrue() + assertThat(memoField.label).isEqualTo(resourceReference(R.string.send_destination_tag_field)) + } + + @Test + fun `GIVEN non-extras networks WHEN validated THEN memo field hidden`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.memoField.isVisible).isFalse() + } + + @Test + fun `GIVEN extras network and memo entered WHEN confirmed THEN memo included`() = runTest { + // Arrange + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP) + val model = createModel(testScope = this, onConfirm = { confirmed = it }) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act + model.state.value.memoField.onValueChange("123456") + model.state.value.buttonUM.onClick() + + // Assert + assertThat(confirmed).isEqualTo( + ValidatedAddress( + address = ADDRESS, + networkIds = persistentListOf(Blockchain.XRP.toNetworkId()), + memo = "123456", + ), + ) + } + + @Test + fun `GIVEN invalid memo WHEN entered THEN memo error shown AND button blocked`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP) + coEvery { memoValidator.isValid(Blockchain.XRP, "bad-tag") } returns false + val model = createModel(testScope = this) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act — type a malformed destination tag. + model.state.value.memoField.onValueChange("bad-tag") + advanceUntilIdle() + + // Assert + assertThat(model.state.value.memoField.isError).isTrue() + assertThat(model.state.value.buttonUM.isEnabled).isFalse() + } + + @Test + fun `GIVEN non-extras network WHEN confirmed THEN memo is null`() = runTest { + // Arrange + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum) + val model = createModel(testScope = this, onConfirm = { confirmed = it }) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act + model.state.value.buttonUM.onClick() + + // Assert + assertThat(confirmed?.memo).isNull() + } + + @Test + fun `WHEN memo paste clicked THEN clipboard goes into memo and not address`() = runTest { + // Arrange + every { clipboardManager.getText() } returns "TAG-123" + val model = createModel(testScope = this) + + // Act + model.state.value.memoField.onPasteClick() + + // Assert + assertThat(model.state.value.memoField.value).isEqualTo("TAG-123") + assertThat(model.state.value.addressField.value).isEmpty() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class QrScan { + + @Test + fun `WHEN onQrClick THEN navigates to address-book QR scanning`() = runTest { + // Arrange + val model = createModel(testScope = this) + + // Act + model.state.value.onQrClick() + + // Assert + verify { router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.AddressBook)) } + } + + @Test + fun `GIVEN scanned address WHEN emitted THEN address field updated`() = runTest { + // Arrange + every { listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) } returns flowOf(ADDRESS).right() + val model = createModel(testScope = this) + + // Act + advanceUntilIdle() + + // Assert + assertThat(model.state.value.addressField.value).isEqualTo(ADDRESS) + } + + @Test + fun `GIVEN scanned payment URI WHEN emitted THEN scheme and query stripped`() = runTest { + // Arrange + every { listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) } returns + flowOf("ethereum:$ADDRESS?amount=1.5").right() + val model = createModel(testScope = this) + + // Act + advanceUntilIdle() + + // Assert + assertThat(model.state.value.addressField.value).isEqualTo(ADDRESS) + } } private fun createModel( testScope: TestScope, onConfirm: (ValidatedAddress) -> Unit = {}, + onSelectNetworksClick: (String, List) -> Unit = { _, _ -> }, params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params( onBackClick = {}, + onSelectNetworksClick = onSelectNetworksClick, onConfirm = onConfirm, ), paramsContainer: ParamsContainer = MutableParamsContainer(value = params), @@ -225,9 +561,13 @@ internal class AddAddressModelTest { return AddAddressModel( paramsContainer = paramsContainer, dispatchers = testScope.createTestingCoroutineDispatcherProvider(), - multiAccountListSupplier = multiAccountListSupplier, + supportedNetworksMatcher = supportedNetworksMatcher, + memoValidator = memoValidator, + listenToQrScanningUseCase = listenToQrScanningUseCase, clipboardManager = clipboardManager, stateController = AddAddressStateController(), + selectNetworksResultHolder = selectNetworksResultHolder, + router = router, ).also { model = it } } @@ -243,7 +583,6 @@ internal class AddAddressModelTest { } private companion object { - // EIP-55 checksummed address from the spec — guaranteed to pass Ethereum validation. - const val VALID_ETH_ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" + const val ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" } } \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt new file mode 100644 index 0000000000..2a8fd39c9e --- /dev/null +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt @@ -0,0 +1,181 @@ +package com.tangem.features.addressbook.selectnetworks.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.addressbook.common.SupportedNetworksMatcher +import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent +import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* + +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SelectNetworksModelTest { + + private val supportedNetworksMatcher: SupportedNetworksMatcher = mockk() + + private val ethereum = Blockchain.Ethereum + private val bsc = Blockchain.BSC + + private var model: SelectNetworksModel? = null + + @BeforeEach + fun resetMocks() { + clearMocks(supportedNetworksMatcher) + // The address resolves to two networks unless a test overrides it. + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(ethereum, bsc) + } + + @AfterEach + fun tearDown() { + model?.onDestroy() + model = null + } + + @Test + fun `GIVEN no prior selection WHEN created THEN nothing selected AND done disabled`() = runTest { + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert — all matched networks are listed but none is checked by default. + val state = model.state.value + assertThat(state.networks.map { it.name }).containsExactly(ethereum.fullName, bsc.fullName) + assertThat(state.networks.none { it.isSelected }).isTrue() + assertThat(state.doneButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN explicit selection WHEN created THEN only those networks selected`() = runTest { + // Act + val model = createModel(testScope = this, selectedNetworkIds = listOf(ethereum.toNetworkId())) + advanceUntilIdle() + + // Assert + val networks = model.state.value.networks + assertThat(networks.first { it.id == ethereum.toNetworkId() }.isSelected).isTrue() + assertThat(networks.first { it.id == bsc.toNetworkId() }.isSelected).isFalse() + } + + @Test + fun `GIVEN nothing selected WHEN a network toggled on THEN it becomes selected AND done enabled`() = runTest { + // Arrange + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.networks.first { it.id == ethereum.toNetworkId() }.onCheckedChange() + advanceUntilIdle() + + // Assert + val networks = model.state.value.networks + assertThat(networks.first { it.id == ethereum.toNetworkId() }.isSelected).isTrue() + assertThat(networks.first { it.id == bsc.toNetworkId() }.isSelected).isFalse() + assertThat(model.state.value.doneButton.isEnabled).isTrue() + } + + @Test + fun `GIVEN a selected network toggled off THEN done disabled again`() = runTest { + // Arrange + val model = createModel(testScope = this, selectedNetworkIds = listOf(ethereum.toNetworkId())) + advanceUntilIdle() + + // Act + model.state.value.networks.first { it.id == ethereum.toNetworkId() }.onCheckedChange() + advanceUntilIdle() + + // Assert + assertThat(model.state.value.networks.none { it.isSelected }).isTrue() + assertThat(model.state.value.doneButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN query WHEN typed THEN list filtered by network name`() = runTest { + // Arrange + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.searchBar.onQueryChange(ethereum.fullName) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.networks.map { it.name }).containsExactly(ethereum.fullName) + } + + @Test + fun `GIVEN selected networks WHEN done clicked THEN onDone called with them`() = runTest { + // Arrange + var result: Set? = null + val model = createModel(testScope = this, onDone = { result = it }) + advanceUntilIdle() + model.state.value.networks.first { it.id == ethereum.toNetworkId() }.onCheckedChange() + advanceUntilIdle() + + // Act + model.state.value.doneButton.onClick() + + // Assert + assertThat(result).containsExactly(ethereum.toNetworkId()) + } + + @Test + fun `GIVEN no networks selected WHEN done clicked THEN onDone not called`() = runTest { + // Arrange + var result: Set? = null + val model = createModel(testScope = this, onDone = { result = it }) + advanceUntilIdle() + + // Act — nothing selected by default. + model.state.value.doneButton.onClick() + + // Assert + assertThat(result).isNull() + } + + private fun createModel( + testScope: TestScope, + selectedNetworkIds: List = emptyList(), + onDone: (Set) -> Unit = {}, + params: DefaultSelectNetworksComponent.Params = DefaultSelectNetworksComponent.Params( + address = ADDRESS, + selectedNetworkIds = selectedNetworkIds, + onBackClick = {}, + onDone = onDone, + ), + paramsContainer: ParamsContainer = MutableParamsContainer(value = params), + ): SelectNetworksModel { + return SelectNetworksModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + supportedNetworksMatcher = supportedNetworksMatcher, + stateController = SelectNetworksStateController(), + ).also { model = it } + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + private companion object { + const val ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" + } +} \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt index ca3528a5e9..c1ef19d53e 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt @@ -23,6 +23,7 @@ internal class InitializeQrScanningStateTransformer( SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) } SourceType.WALLET_CONNECT -> resourceReference(R.string.wc_qr_scan_hint) SourceType.MAIN_SCREEN -> resourceReference(R.string.main_qr_scan_hint) + SourceType.ADDRESS_BOOK -> resourceReference(R.string.main_qr_scan_hint) } return QrScanningState( @@ -49,6 +50,10 @@ internal class InitializeQrScanningStateTransformer( title = null, startIcon = R.drawable.ic_close_24, ) + SourceType.ADDRESS_BOOK -> TopBarConfig( + title = null, + startIcon = R.drawable.ic_back_24, + ) } } diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt new file mode 100644 index 0000000000..13dd7d0e13 --- /dev/null +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt @@ -0,0 +1,175 @@ +package com.tangem.blockchainsdk.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.domain.models.network.Network + +/** + * The kind of transaction extras (memo / destination tag) a [Blockchain] supports, mapped to the domain + * [Network.TransactionExtrasType]. Single source of truth for both [com.tangem.data.common.network.NetworkFactory] and + * any feature that needs to know whether an address on this chain can carry a memo/tag. + */ +@Suppress("LongMethod") +fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType { + return when (this) { + Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG + Blockchain.Binance, + Blockchain.TON, + Blockchain.Cosmos, + Blockchain.TerraV1, + Blockchain.TerraV2, + Blockchain.Stellar, + Blockchain.Hedera, + Blockchain.Algorand, + Blockchain.Sei, + Blockchain.InternetComputer, + Blockchain.Casper, + -> Network.TransactionExtrasType.MEMO + // region Other blockchains + Blockchain.Unknown, + Blockchain.Alephium, + Blockchain.AlephiumTestnet, + Blockchain.Arbitrum, + Blockchain.ArbitrumTestnet, + Blockchain.Avalanche, + Blockchain.AvalancheTestnet, + Blockchain.BinanceTestnet, + Blockchain.BSC, + Blockchain.BSCTestnet, + Blockchain.Bitcoin, + Blockchain.BitcoinTestnet, + Blockchain.BitcoinCash, + Blockchain.BitcoinCashTestnet, + Blockchain.Cardano, + Blockchain.CosmosTestnet, + Blockchain.Dogecoin, + Blockchain.Ducatus, + Blockchain.Ethereum, + Blockchain.EthereumTestnet, + Blockchain.EthereumClassic, + Blockchain.EthereumClassicTestnet, + Blockchain.Fantom, + Blockchain.FantomTestnet, + Blockchain.Litecoin, + Blockchain.Near, + Blockchain.NearTestnet, + Blockchain.Polkadot, + Blockchain.PolkadotTestnet, + Blockchain.Kava, + Blockchain.KavaTestnet, + Blockchain.Kusama, + Blockchain.Polygon, + Blockchain.PolygonTestnet, + Blockchain.RSK, + Blockchain.SeiTestnet, + Blockchain.StellarTestnet, + Blockchain.Solana, + Blockchain.SolanaTestnet, + Blockchain.Tezos, + Blockchain.Tron, + Blockchain.TronTestnet, + Blockchain.Gnosis, + Blockchain.Dash, + Blockchain.Optimism, + Blockchain.OptimismTestnet, + Blockchain.Dischain, + Blockchain.EthereumPow, + Blockchain.EthereumPowTestnet, + Blockchain.Kaspa, + Blockchain.KaspaTestnet, + Blockchain.Telos, + Blockchain.TelosTestnet, + Blockchain.TONTestnet, + Blockchain.Ravencoin, + Blockchain.Clore, + Blockchain.RavencoinTestnet, + Blockchain.Cronos, + Blockchain.AlephZero, + Blockchain.AlephZeroTestnet, + Blockchain.OctaSpace, + Blockchain.OctaSpaceTestnet, + Blockchain.Chia, + Blockchain.ChiaTestnet, + Blockchain.Decimal, + Blockchain.DecimalTestnet, + Blockchain.XDC, + Blockchain.XDCTestnet, + Blockchain.VeChain, + Blockchain.VeChainTestnet, + Blockchain.Aptos, + Blockchain.AptosTestnet, + Blockchain.Playa3ull, + Blockchain.Shibarium, + Blockchain.ShibariumTestnet, + Blockchain.AlgorandTestnet, + Blockchain.HederaTestnet, + Blockchain.Aurora, + Blockchain.AuroraTestnet, + Blockchain.Areon, + Blockchain.AreonTestnet, + Blockchain.PulseChain, + Blockchain.PulseChainTestnet, + Blockchain.ZkSyncEra, + Blockchain.ZkSyncEraTestnet, + Blockchain.Nexa, + Blockchain.NexaTestnet, + Blockchain.Moonbeam, + Blockchain.MoonbeamTestnet, + Blockchain.Manta, + Blockchain.MantaTestnet, + Blockchain.PolygonZkEVM, + Blockchain.PolygonZkEVMTestnet, + Blockchain.Radiant, + Blockchain.Fact0rn, + Blockchain.Base, + Blockchain.BaseTestnet, + Blockchain.Moonriver, + Blockchain.MoonriverTestnet, + Blockchain.Mantle, + Blockchain.MantleTestnet, + Blockchain.Flare, + Blockchain.FlareTestnet, + Blockchain.Taraxa, + Blockchain.TaraxaTestnet, + Blockchain.Koinos, + Blockchain.KoinosTestnet, + Blockchain.Joystream, + Blockchain.Bittensor, + Blockchain.Filecoin, + Blockchain.Blast, + Blockchain.BlastTestnet, + Blockchain.Cyber, + Blockchain.CyberTestnet, + Blockchain.Sui, + Blockchain.SuiTestnet, + Blockchain.EnergyWebChain, + Blockchain.EnergyWebChainTestnet, + Blockchain.EnergyWebX, + Blockchain.EnergyWebXTestnet, + Blockchain.CasperTestnet, + Blockchain.Core, + Blockchain.CoreTestnet, + Blockchain.Xodex, + Blockchain.Canxium, + Blockchain.Chiliz, + Blockchain.ChilizTestnet, + Blockchain.VanarChain, + Blockchain.VanarChainTestnet, + Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet, + Blockchain.Bitrock, Blockchain.BitrockTestnet, + Blockchain.Sonic, Blockchain.SonicTestnet, + Blockchain.ApeChain, Blockchain.ApeChainTestnet, + Blockchain.Scroll, Blockchain.ScrollTestnet, + Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet, + Blockchain.Pepecoin, Blockchain.PepecoinTestnet, + Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet, + Blockchain.Quai, Blockchain.QuaiTestnet, + Blockchain.Linea, Blockchain.LineaTestnet, + Blockchain.ArbitrumNova, + Blockchain.Plasma, Blockchain.PlasmaTestnet, + Blockchain.Adi, Blockchain.AdiTestnet, + Blockchain.SeiEvm, Blockchain.SeiEvmTestnet, + Blockchain.Monad, Blockchain.MonadTestnet, + -> Network.TransactionExtrasType.NONE + // endregion + } +} \ No newline at end of file