Updated on 2026-08-14
This commit is contained in:
parent
3069b8d32d
commit
7b4ae4e23d
39 changed files with 1985 additions and 329 deletions
|
|
@ -374,6 +374,7 @@ internal class ChildFactory @Inject constructor(
|
||||||
is AppRoute.QrScanning.Source.Send -> SourceType.SEND
|
is AppRoute.QrScanning.Source.Send -> SourceType.SEND
|
||||||
is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT
|
is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT
|
||||||
is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN
|
is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN
|
||||||
|
is AppRoute.QrScanning.Source.AddressBook -> SourceType.ADDRESS_BOOK
|
||||||
}
|
}
|
||||||
createComponentChild(
|
createComponentChild(
|
||||||
context = context,
|
context = context,
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ sealed class AppRoute(val path: String) : Route {
|
||||||
is Send -> "/$networkName"
|
is Send -> "/$networkName"
|
||||||
WalletConnect -> ""
|
WalletConnect -> ""
|
||||||
MainScreen -> ""
|
MainScreen -> ""
|
||||||
|
AddressBook -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Send(val networkName: String) : Source()
|
data class Send(val networkName: String) : Source()
|
||||||
|
|
@ -202,6 +203,8 @@ sealed class AppRoute(val path: String) : Route {
|
||||||
data object WalletConnect : Source()
|
data object WalletConnect : Source()
|
||||||
|
|
||||||
data object MainScreen : Source()
|
data object MainScreen : Source()
|
||||||
|
|
||||||
|
data object AddressBook : Source()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting
|
||||||
import com.tangem.blockchain.common.Blockchain
|
import com.tangem.blockchain.common.Blockchain
|
||||||
import com.tangem.blockchain.common.FeePaidCurrency
|
import com.tangem.blockchain.common.FeePaidCurrency
|
||||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||||
|
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
|
||||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||||
import com.tangem.domain.card.common.extensions.canHandleToken
|
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 {
|
private fun Blockchain.getNameResolvingType(): Network.NameResolvingType {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS
|
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,5 @@ enum class SourceType {
|
||||||
WALLET_CONNECT,
|
WALLET_CONNECT,
|
||||||
SEND,
|
SEND,
|
||||||
MAIN_SCREEN,
|
MAIN_SCREEN,
|
||||||
|
ADDRESS_BOOK,
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +19,8 @@ dependencies {
|
||||||
implementation(projects.domain.account)
|
implementation(projects.domain.account)
|
||||||
implementation(projects.domain.addressBook)
|
implementation(projects.domain.addressBook)
|
||||||
implementation(projects.domain.models)
|
implementation(projects.domain.models)
|
||||||
|
implementation(projects.domain.qrScanning)
|
||||||
|
implementation(projects.domain.qrScanning.models)
|
||||||
implementation(projects.domain.wallets)
|
implementation(projects.domain.wallets)
|
||||||
|
|
||||||
/** Common */
|
/** Common */
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ internal class DefaultAddAddressComponent(
|
||||||
|
|
||||||
data class Params(
|
data class Params(
|
||||||
val onBackClick: () -> Unit,
|
val onBackClick: () -> Unit,
|
||||||
|
val onSelectNetworksClick: (address: String, selectedNetworkIds: List<String>) -> Unit,
|
||||||
val onConfirm: (ValidatedAddress) -> Unit,
|
val onConfirm: (ValidatedAddress) -> Unit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,65 +1,113 @@
|
||||||
package com.tangem.features.addressbook.addaddress.model
|
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.di.ModelScoped
|
||||||
import com.tangem.core.decompose.model.Model
|
import com.tangem.core.decompose.model.Model
|
||||||
import com.tangem.core.decompose.model.ParamsContainer
|
import com.tangem.core.decompose.model.ParamsContainer
|
||||||
|
import com.tangem.core.decompose.navigation.Router
|
||||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
import com.tangem.domain.qrscanning.models.SourceType
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||||
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
|
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.UpdateAddAddressInitialStateTransformer
|
||||||
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressInputTransformer
|
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.UpdateAddressValidationTransformer
|
||||||
|
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateMemoInputTransformer
|
||||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
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 com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@Suppress("LongParameterList")
|
||||||
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
@ModelScoped
|
@ModelScoped
|
||||||
internal class AddAddressModel @Inject constructor(
|
internal class AddAddressModel @Inject constructor(
|
||||||
paramsContainer: ParamsContainer,
|
paramsContainer: ParamsContainer,
|
||||||
override val dispatchers: CoroutineDispatcherProvider,
|
override val dispatchers: CoroutineDispatcherProvider,
|
||||||
multiAccountListSupplier: MultiAccountListSupplier,
|
private val supportedNetworksMatcher: SupportedNetworksMatcher,
|
||||||
|
private val memoValidator: AddressMemoValidator,
|
||||||
|
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
|
||||||
private val clipboardManager: ClipboardManager,
|
private val clipboardManager: ClipboardManager,
|
||||||
private val stateController: AddAddressStateController,
|
private val stateController: AddAddressStateController,
|
||||||
|
private val selectNetworksResultHolder: SelectNetworksResultHolder,
|
||||||
|
private val router: Router,
|
||||||
) : Model() {
|
) : Model() {
|
||||||
|
|
||||||
private val params: DefaultAddAddressComponent.Params = paramsContainer.require()
|
private val params: DefaultAddAddressComponent.Params = paramsContainer.require()
|
||||||
|
|
||||||
val state: StateFlow<AddAddressUM> get() = stateController.uiState
|
val state: StateFlow<AddAddressUM> get() = stateController.uiState
|
||||||
|
|
||||||
private val availableCoins: StateFlow<List<CryptoCurrency.Coin>> = multiAccountListSupplier()
|
private val validation: StateFlow<AddressValidation> = state
|
||||||
.map { accountLists ->
|
|
||||||
accountLists
|
|
||||||
.flatMap { it.flattenCurrencies() }
|
|
||||||
.filterIsInstance<CryptoCurrency.Coin>()
|
|
||||||
.distinctBy { it.network.id }
|
|
||||||
}
|
|
||||||
.flowOn(dispatchers.default)
|
|
||||||
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
|
|
||||||
|
|
||||||
private val addressInput = state
|
|
||||||
.map { it.addressField.value }
|
.map { it.addressField.value }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.debounce(ADD_ADDRESS_DEBOUNCE)
|
.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<Set<String>?>(null)
|
||||||
|
|
||||||
|
private val chosenNetworks: StateFlow<ChosenNetworks> = 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 {
|
init {
|
||||||
|
// Drop any selection left over from a previous AddAddress session before subscribing to it.
|
||||||
|
selectNetworksResultHolder.clear()
|
||||||
updateInitialState()
|
updateInitialState()
|
||||||
subscribeToAddressValidation()
|
subscribeToValidation()
|
||||||
|
subscribeToMemoValidation()
|
||||||
|
resetSelectionOnAddressChange()
|
||||||
|
subscribeToSelectedNetworks()
|
||||||
|
subscribeToQrScanResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateInitialState() {
|
private fun updateInitialState() {
|
||||||
stateController.update(
|
stateController.update(
|
||||||
UpdateAddAddressInitialStateTransformer(
|
UpdateAddAddressInitialStateTransformer(
|
||||||
onAddressChange = { onAddressChange(value = it) },
|
intents = UpdateAddAddressInitialStateTransformer.Intents(
|
||||||
onAddressClear = { onAddressChange("") },
|
onAddressChange = ::onAddressChange,
|
||||||
onPasteClick = ::onPaste,
|
onAddressClear = { onAddressChange("") },
|
||||||
onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
|
onPasteClick = ::onPaste,
|
||||||
onBackClick = params.onBackClick,
|
onQrClick = ::onQrClick,
|
||||||
onConfirmClick = ::validateAndConfirm,
|
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))
|
stateController.update(UpdateAddressInputTransformer(value = value))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun subscribeToAddressValidation() {
|
private fun onMemoChange(value: String) {
|
||||||
combine(addressInput, availableCoins) { input, coins ->
|
stateController.update(UpdateMemoInputTransformer(value = value))
|
||||||
UpdateAddressValidationTransformer(address = input, coins = coins)
|
}
|
||||||
|
|
||||||
|
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)
|
.onEach(stateController::update)
|
||||||
.flowOn(dispatchers.default)
|
.flowOn(dispatchers.default)
|
||||||
.launchIn(modelScope)
|
.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() {
|
private fun onPaste() {
|
||||||
onAddressChange(value = clipboardManager.getText().orEmpty())
|
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() {
|
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<Blockchain>, selected: Set<String>?): List<Blockchain> {
|
||||||
|
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<Blockchain>, selected: Set<String>?): List<Blockchain> {
|
||||||
|
if (selected == null) return listOfNotNull(matched.singleOrNull())
|
||||||
|
return matched.filter { it.toNetworkId() in selected }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AddressValidation(
|
||||||
|
val address: String,
|
||||||
|
val matchedBlockchains: List<Blockchain>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ChosenNetworks(
|
||||||
|
val address: String,
|
||||||
|
val matched: List<Blockchain>,
|
||||||
|
val displayed: List<Blockchain>,
|
||||||
|
val selected: List<Blockchain>,
|
||||||
|
) {
|
||||||
|
/** The first selected network that supports a memo / destination tag, if any. */
|
||||||
|
val extrasBlockchain: Blockchain?
|
||||||
|
get() = selected.firstOrNull { it.getSupportedTransactionExtras().isTxExtrasSupported() }
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ADD_ADDRESS_DEBOUNCE = 500L
|
private const val ADD_ADDRESS_DEBOUNCE = 500L
|
||||||
|
private const val MEMO_DEBOUNCE = 300L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -31,13 +31,21 @@ internal class AddAddressStateController @Inject constructor() {
|
||||||
label = resourceReference(R.string.common_address),
|
label = resourceReference(R.string.common_address),
|
||||||
isError = false,
|
isError = false,
|
||||||
),
|
),
|
||||||
|
memoField = AddAddressUM.MemoFieldUM(
|
||||||
|
isVisible = false,
|
||||||
|
value = "",
|
||||||
|
label = resourceReference(R.string.send_extras_hint_memo),
|
||||||
|
isError = false,
|
||||||
|
onValueChange = {},
|
||||||
|
onPasteClick = {},
|
||||||
|
),
|
||||||
buttonUM = TangemButtonUM(
|
buttonUM = TangemButtonUM(
|
||||||
text = TextReference.Res(R.string.address_book_add_address),
|
text = TextReference.Res(R.string.address_book_add_address),
|
||||||
type = TangemButtonType.Primary,
|
type = TangemButtonType.Primary,
|
||||||
isEnabled = false,
|
isEnabled = false,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
),
|
),
|
||||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
|
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden,
|
||||||
onAddressChange = {},
|
onAddressChange = {},
|
||||||
onAddressClear = {},
|
onAddressClear = {},
|
||||||
onPasteClick = {},
|
onPasteClick = {},
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,34 @@ import com.tangem.utils.transformer.Transformer
|
||||||
* state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController].
|
* state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController].
|
||||||
*/
|
*/
|
||||||
internal class UpdateAddAddressInitialStateTransformer(
|
internal class UpdateAddAddressInitialStateTransformer(
|
||||||
private val onAddressChange: (String) -> Unit,
|
private val intents: Intents,
|
||||||
private val onAddressClear: () -> Unit,
|
|
||||||
private val onPasteClick: () -> Unit,
|
|
||||||
private val onQrClick: () -> Unit,
|
|
||||||
private val onBackClick: () -> Unit,
|
|
||||||
private val onConfirmClick: () -> Unit,
|
|
||||||
) : Transformer<AddAddressUM> {
|
) : Transformer<AddAddressUM> {
|
||||||
|
|
||||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||||
return prevState.copy(
|
return prevState.copy(
|
||||||
onAddressChange = onAddressChange,
|
onAddressChange = intents.onAddressChange,
|
||||||
onAddressClear = onAddressClear,
|
onAddressClear = intents.onAddressClear,
|
||||||
onPasteClick = onPasteClick,
|
onPasteClick = intents.onPasteClick,
|
||||||
onQrClick = onQrClick,
|
onQrClick = intents.onQrClick,
|
||||||
onBackClick = onBackClick,
|
onBackClick = intents.onBackClick,
|
||||||
buttonUM = prevState.buttonUM.copy(onClick = onConfirmClick),
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -3,23 +3,41 @@ package com.tangem.features.addressbook.addaddress.state.transformers
|
||||||
import com.tangem.core.ui.R
|
import com.tangem.core.ui.R
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
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
|
||||||
|
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM
|
||||||
import com.tangem.utils.transformer.Transformer
|
import com.tangem.utils.transformer.Transformer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the address field with a freshly entered/pasted [value] and clears any previous error, restoring the default
|
* 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(
|
internal class UpdateAddressInputTransformer(
|
||||||
private val value: String,
|
private val value: String,
|
||||||
) : Transformer<AddAddressUM> {
|
) : Transformer<AddAddressUM> {
|
||||||
|
|
||||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
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(
|
return prevState.copy(
|
||||||
addressField = prevState.addressField.copy(
|
addressField = prevState.addressField.copy(
|
||||||
value = value,
|
value = value,
|
||||||
isError = false,
|
isError = false,
|
||||||
label = resourceReference(R.string.common_address),
|
label = resourceReference(R.string.common_address),
|
||||||
),
|
),
|
||||||
|
chosenNetworkStateUM = chosenNetworkState,
|
||||||
|
buttonUM = prevState.buttonUM.copy(isEnabled = false),
|
||||||
|
memoField = memoField,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,28 +1,51 @@
|
||||||
package com.tangem.features.addressbook.addaddress.state.transformers
|
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.R
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
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
|
||||||
|
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM
|
||||||
import com.tangem.utils.transformer.Transformer
|
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
|
* [matchedBlockchains] are all supported networks the address resolves to. [displayedBlockchains] is what the network
|
||||||
* at least one of the available networks — the same blockchain check the Send flow uses. An invalid (non-empty,
|
* block shows — all matched networks until the user narrows them down on the SelectNetworks screen, then the picked
|
||||||
* matching nothing) address surfaces the error in the field label and disables the confirm button.
|
* 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(
|
internal class UpdateAddressValidationTransformer(
|
||||||
private val address: String,
|
private val address: String,
|
||||||
private val coins: List<CryptoCurrency.Coin>,
|
private val matchedBlockchains: List<Blockchain>,
|
||||||
|
private val displayedBlockchains: List<Blockchain>,
|
||||||
|
private val selectedBlockchains: List<Blockchain>,
|
||||||
|
private val isMemoInvalid: Boolean,
|
||||||
) : Transformer<AddAddressUM> {
|
) : Transformer<AddAddressUM> {
|
||||||
|
|
||||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||||
val hasMatchedAnyNetwork = address.isNotBlank() &&
|
val hasMatch = matchedBlockchains.isNotEmpty()
|
||||||
coins.any { it.network.toBlockchain().validateAddress(address) }
|
val isError = address.isNotBlank() && !hasMatch
|
||||||
val isError = address.isNotBlank() && !hasMatchedAnyNetwork
|
|
||||||
|
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) {
|
val label = if (isError) {
|
||||||
resourceReference(R.string.address_book_invalid_address_error)
|
resourceReference(R.string.address_book_invalid_address_error)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -30,7 +53,32 @@ internal class UpdateAddressValidationTransformer(
|
||||||
}
|
}
|
||||||
return prevState.copy(
|
return prevState.copy(
|
||||||
addressField = prevState.addressField.copy(isError = isError, label = label),
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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<AddAddressUM> {
|
||||||
|
|
||||||
|
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||||
|
return prevState.copy(
|
||||||
|
memoField = prevState.memoField.copy(value = value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Blockchain, NetworkUM> {
|
||||||
|
|
||||||
|
override fun convert(value: Blockchain): NetworkUM = NetworkUM(
|
||||||
|
networkName = value.fullName,
|
||||||
|
iconResId = getActiveIconRes(value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package com.tangem.features.addressbook.addaddress.ui
|
package com.tangem.features.addressbook.addaddress.ui
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.ds2.button.TangemButton
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
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.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||||
|
|
@ -70,17 +74,12 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
|
||||||
onQrClick = state.onQrClick,
|
onQrClick = state.onQrClick,
|
||||||
onPasteClick = state.onPasteClick,
|
onPasteClick = state.onPasteClick,
|
||||||
)
|
)
|
||||||
SpacerH(20.dp)
|
MemoSection(memoField = state.memoField)
|
||||||
NetworkBlock(
|
NetworkSelector(
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(color = TangemTheme.colors3.bg.secondary),
|
|
||||||
chosenNetworkStateUM = state.chosenNetworkStateUM,
|
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
|
@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))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
TangemButton(
|
TangemButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -114,13 +177,21 @@ private fun Preview_AddAddressContent() {
|
||||||
placeholder = resourceReference(R.string.address_book_enter_address),
|
placeholder = resourceReference(R.string.address_book_enter_address),
|
||||||
label = resourceReference(R.string.common_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(
|
buttonUM = TangemButtonUM(
|
||||||
text = TextReference.Res(R.string.address_book_add_address),
|
text = TextReference.Res(R.string.address_book_add_address),
|
||||||
type = TangemButtonType.Primary,
|
type = TangemButtonType.Primary,
|
||||||
isEnabled = false,
|
isEnabled = false,
|
||||||
onClick = { },
|
onClick = { },
|
||||||
),
|
),
|
||||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
|
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden,
|
||||||
onAddressChange = {},
|
onAddressChange = {},
|
||||||
onAddressClear = {},
|
onAddressClear = {},
|
||||||
onPasteClick = {},
|
onPasteClick = {},
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,12 +20,10 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.util.fastForEachIndexed
|
import androidx.compose.ui.util.fastForEachIndexed
|
||||||
import com.tangem.core.ui.R
|
import com.tangem.core.ui.R
|
||||||
import com.tangem.core.ui.components.SpacerH12
|
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.TangemLoader
|
||||||
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
|
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
|
||||||
import com.tangem.core.ui.ds2.row.TangemRow
|
import com.tangem.core.ui.ds2.row.TangemRow
|
||||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
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.extensions.stringResourceSafe
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||||
|
|
@ -48,7 +46,10 @@ internal fun NetworkBlock(
|
||||||
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
|
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val isClickable = chosenNetworkStateUM is AddAddressUM.ChosenNetworkStateUM.Result &&
|
||||||
|
chosenNetworkStateUM.isClickable
|
||||||
TangemRow(
|
TangemRow(
|
||||||
|
onClick = if (isClickable) onNetworkSelectClick else null,
|
||||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
titleSlot = {
|
titleSlot = {
|
||||||
|
|
@ -59,45 +60,27 @@ internal fun NetworkBlock(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
endSlot = {
|
endSlot = {
|
||||||
SelectNetworkButton(
|
when (chosenNetworkStateUM) {
|
||||||
onNetworkSelectClick = onNetworkSelectClick,
|
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20)
|
||||||
chosenNetworkStateUM = chosenNetworkStateUM,
|
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkRow(chosenNetworkStateUM = chosenNetworkStateUM)
|
||||||
)
|
AddAddressUM.ChosenNetworkStateUM.Hidden -> Unit
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SelectNetworkButton(
|
private fun NetworkRow(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM.Result) {
|
||||||
onNetworkSelectClick: () -> Unit,
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
|
NetworkIconsResolver(
|
||||||
) {
|
networks = chosenNetworkStateUM.networkUMList,
|
||||||
Row(
|
showChevron = chosenNetworkStateUM.isClickable,
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
|
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>, showChevron: Boolean) {
|
||||||
when (networks.size) {
|
when (networks.size) {
|
||||||
0 -> Unit
|
0 -> Unit
|
||||||
1 -> {
|
1 -> {
|
||||||
|
|
@ -112,13 +95,13 @@ private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
|
||||||
style = TangemTheme.typography3.body.medium,
|
style = TangemTheme.typography3.body.medium,
|
||||||
color = TangemTheme.colors3.text.secondary,
|
color = TangemTheme.colors3.text.secondary,
|
||||||
)
|
)
|
||||||
ChevronIcon()
|
if (showChevron) ChevronIcon()
|
||||||
}
|
}
|
||||||
// 3 and any larger count share the same rendering: up to MAX_VISIBLE_NETWORKS overlapping
|
// 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.
|
// icons, plus a "+N" badge that appears only when there are more than that.
|
||||||
else -> {
|
else -> {
|
||||||
OverlappingNetworkIcons(networks)
|
OverlappingNetworkIcons(networks)
|
||||||
ChevronIcon()
|
if (showChevron) ChevronIcon()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,6 +174,7 @@ private fun Preview_NetworkBlock() {
|
||||||
networkUMList = persistentListOf(
|
networkUMList = persistentListOf(
|
||||||
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
||||||
),
|
),
|
||||||
|
isClickable = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
SpacerH12()
|
SpacerH12()
|
||||||
|
|
@ -202,6 +186,7 @@ private fun Preview_NetworkBlock() {
|
||||||
NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22),
|
NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22),
|
||||||
NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22),
|
NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22),
|
||||||
),
|
),
|
||||||
|
isClickable = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
SpacerH12()
|
SpacerH12()
|
||||||
|
|
@ -211,12 +196,9 @@ private fun Preview_NetworkBlock() {
|
||||||
networkUMList = List(15) {
|
networkUMList = List(15) {
|
||||||
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
|
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
|
||||||
}.toImmutableList(),
|
}.toImmutableList(),
|
||||||
|
isClickable = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
SpacerH12()
|
|
||||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, onNetworkSelectClick = {})
|
|
||||||
SpacerH12()
|
|
||||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, onNetworkSelectClick = {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,11 +3,13 @@ package com.tangem.features.addressbook.addaddress.ui.state
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||||
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
internal data class AddAddressUM(
|
internal data class AddAddressUM(
|
||||||
val addressField: AddressFieldUM,
|
val addressField: AddressFieldUM,
|
||||||
|
val memoField: MemoFieldUM,
|
||||||
val buttonUM: TangemButtonUM,
|
val buttonUM: TangemButtonUM,
|
||||||
val chosenNetworkStateUM: ChosenNetworkStateUM,
|
val chosenNetworkStateUM: ChosenNetworkStateUM,
|
||||||
val onAddressChange: (String) -> Unit,
|
val onAddressChange: (String) -> Unit,
|
||||||
|
|
@ -17,12 +19,39 @@ internal data class AddAddressUM(
|
||||||
val onBackClick: () -> Unit,
|
val onBackClick: () -> Unit,
|
||||||
val onNetworkClick: () -> 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
|
@Immutable
|
||||||
sealed interface ChosenNetworkStateUM {
|
sealed interface ChosenNetworkStateUM {
|
||||||
data object Loading : ChosenNetworkStateUM
|
|
||||||
data object Empty : ChosenNetworkStateUM
|
|
||||||
|
|
||||||
data class Result(val networkUMList: ImmutableList<NetworkUM>) : 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<NetworkUM>,
|
||||||
|
val isClickable: Boolean,
|
||||||
|
) : ChosenNetworkStateUM {
|
||||||
data class NetworkUM(
|
data class NetworkUM(
|
||||||
val networkName: String,
|
val networkName: String,
|
||||||
@DrawableRes val iconResId: Int,
|
@DrawableRes val iconResId: Int,
|
||||||
|
|
|
||||||
|
|
@ -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.editcontact.ui.state.ValidatedAddress
|
||||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||||
|
import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -47,9 +48,19 @@ internal class AddressBookChildFactory @Inject constructor(
|
||||||
appComponentContext = context,
|
appComponentContext = context,
|
||||||
params = DefaultAddAddressComponent.Params(
|
params = DefaultAddAddressComponent.Params(
|
||||||
onBackClick = clickIntents::onAddAddressBack,
|
onBackClick = clickIntents::onAddAddressBack,
|
||||||
|
onSelectNetworksClick = clickIntents::onSelectNetworksClick,
|
||||||
onConfirm = clickIntents::onAddressConfirmed,
|
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. */
|
/** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,10 @@ internal interface AddressBookClickIntents {
|
||||||
fun onAddAddressBack()
|
fun onAddAddressBack()
|
||||||
|
|
||||||
fun onAddressConfirmed(address: ValidatedAddress)
|
fun onAddressConfirmed(address: ValidatedAddress)
|
||||||
|
|
||||||
|
fun onSelectNetworksClick(address: String, selectedNetworkIds: List<String>)
|
||||||
|
|
||||||
|
fun onSelectNetworksBack()
|
||||||
|
|
||||||
|
fun onNetworksSelected(selectedNetworkIds: Set<String>)
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,13 +29,15 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||||
@Assisted private val params: AddressBookComponent.Params,
|
@Assisted private val params: AddressBookComponent.Params,
|
||||||
private val childFactory: AddressBookChildFactory,
|
private val childFactory: AddressBookChildFactory,
|
||||||
private val resultHolder: AddressBookResultHolder,
|
private val resultHolder: AddressBookResultHolder,
|
||||||
|
private val selectNetworksResultHolder: SelectNetworksResultHolder,
|
||||||
) : AddressBookComponent, AppComponentContext by context {
|
) : AddressBookComponent, AppComponentContext by context {
|
||||||
|
|
||||||
private val navigation = StackNavigation<AddressBookRoute>()
|
private val navigation = StackNavigation<AddressBookRoute>()
|
||||||
|
|
||||||
init {
|
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()
|
resultHolder.clear()
|
||||||
|
selectNetworksResultHolder.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val clickIntents = object : AddressBookClickIntents {
|
private val clickIntents = object : AddressBookClickIntents {
|
||||||
|
|
@ -64,6 +66,21 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||||
resultHolder.setConfirmedAddress(address)
|
resultHolder.setConfirmedAddress(address)
|
||||||
navigation.pop()
|
navigation.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSelectNetworksClick(address: String, selectedNetworkIds: List<String>) {
|
||||||
|
navigation.pushNew(
|
||||||
|
AddressBookRoute.SelectNetworks(address = address, selectedNetworkIds = selectedNetworkIds),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSelectNetworksBack() {
|
||||||
|
navigation.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNetworksSelected(selectedNetworkIds: Set<String>) {
|
||||||
|
selectNetworksResultHolder.setSelectedNetworkIds(selectedNetworkIds)
|
||||||
|
navigation.pop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val contentStack = childStack(
|
private val contentStack = childStack(
|
||||||
|
|
|
||||||
|
|
@ -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<Set<String>?>
|
||||||
|
field = MutableStateFlow<Set<String>?>(null)
|
||||||
|
|
||||||
|
fun setSelectedNetworkIds(networkIds: Set<String>) {
|
||||||
|
selectedNetworkIds.value = networkIds
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
selectedNetworkIds.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> = Blockchain.entries
|
||||||
|
.filter { !it.isTestnet() && it !in excludedBlockchains }
|
||||||
|
|
||||||
|
fun match(address: String): List<Blockchain> {
|
||||||
|
if (address.isBlank()) return emptyList()
|
||||||
|
return supportedBlockchains.filter { blockchain ->
|
||||||
|
runCatching { blockchain.validateAddress(address) }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.block.model.ContactsBlockModel
|
||||||
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
||||||
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
||||||
|
import com.tangem.features.addressbook.selectnetworks.model.SelectNetworksModel
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -35,4 +36,9 @@ internal interface AddressBookModelModule {
|
||||||
@IntoMap
|
@IntoMap
|
||||||
@ClassKey(AddAddressModel::class)
|
@ClassKey(AddAddressModel::class)
|
||||||
fun bindAddAddressModel(model: AddAddressModel): Model
|
fun bindAddAddressModel(model: AddAddressModel): Model
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoMap
|
||||||
|
@ClassKey(SelectNetworksModel::class)
|
||||||
|
fun bindSelectNetworksModel(model: SelectNetworksModel): Model
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
* 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
|
* [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.
|
* 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
|
@Immutable
|
||||||
data class ValidatedAddress(
|
data class ValidatedAddress(
|
||||||
val address: String,
|
val address: String,
|
||||||
val networkIds: ImmutableList<String>,
|
val networkIds: ImmutableList<String>,
|
||||||
|
val memo: String? = null,
|
||||||
)
|
)
|
||||||
|
|
@ -30,6 +30,16 @@ internal sealed class AddressBookRoute {
|
||||||
@Serializable
|
@Serializable
|
||||||
data object AddAddress : AddressBookRoute()
|
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<String> = emptyList(),
|
||||||
|
) : AddressBookRoute()
|
||||||
|
|
||||||
/** How the contacts list is shown — agnostic of which feature opened it. */
|
/** How the contacts list is shown — agnostic of which feature opened it. */
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface ListMode {
|
sealed interface ListMode {
|
||||||
|
|
|
||||||
|
|
@ -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<String>,
|
||||||
|
val onBackClick: () -> Unit,
|
||||||
|
val onDone: (selectedNetworkIds: Set<String>) -> Unit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<Blockchain> = supportedNetworksMatcher.match(params.address)
|
||||||
|
|
||||||
|
private val selectedNetworks = MutableStateFlow(
|
||||||
|
params.selectedNetworkIds.toSet().intersect(
|
||||||
|
matchedBlockchains.map { blockchain -> blockchain.toNetworkId() }.toSet(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val state: StateFlow<SelectNetworksUM> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SelectNetworksUM>
|
||||||
|
field = MutableStateFlow(value = getInitialState())
|
||||||
|
|
||||||
|
fun update(transformer: Transformer<SelectNetworksUM>) {
|
||||||
|
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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<Blockchain>,
|
||||||
|
private val query: String,
|
||||||
|
private val selectedNetworkIds: Set<String>,
|
||||||
|
private val onToggle: (networkId: String) -> Unit,
|
||||||
|
) : Transformer<SelectNetworksUM> {
|
||||||
|
|
||||||
|
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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SelectNetworksUM> {
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SelectNetworksUM> {
|
||||||
|
|
||||||
|
override fun transform(prevState: SelectNetworksUM): SelectNetworksUM {
|
||||||
|
return prevState.copy(
|
||||||
|
searchBar = prevState.searchBar.copy(query = query, isActive = isActive),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SelectNetworkItemConverter.Input, NetworkItemUM> {
|
||||||
|
|
||||||
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<NetworkItemUM>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,33 @@
|
||||||
package com.tangem.features.addressbook.addaddress.model
|
package com.tangem.features.addressbook.addaddress.model
|
||||||
|
|
||||||
|
import arrow.core.right
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import com.tangem.blockchain.common.Blockchain
|
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.MutableParamsContainer
|
||||||
import com.tangem.core.decompose.model.ParamsContainer
|
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.R
|
||||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.domain.account.models.AccountList
|
import com.tangem.domain.qrscanning.models.SourceType
|
||||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||||
import com.tangem.domain.models.account.Account
|
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
|
||||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||||
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
|
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.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||||
import com.tangem.test.mock.MockAccounts
|
|
||||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
|
@ -33,25 +39,30 @@ import org.junit.jupiter.api.*
|
||||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
internal class AddAddressModelTest {
|
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 clipboardManager: ClipboardManager = mockk()
|
||||||
|
private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk()
|
||||||
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
|
private val router: Router = mockk(relaxed = true)
|
||||||
private val ethereum = cryptoCurrencyFactory.createCoin(Blockchain.Ethereum)
|
private val selectNetworksResultHolder = SelectNetworksResultHolder()
|
||||||
private val bitcoin = cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin)
|
|
||||||
|
|
||||||
private var model: AddAddressModel? = null
|
private var model: AddAddressModel? = null
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun resetMocks() {
|
fun resetMocks() {
|
||||||
clearMocks(multiAccountListSupplier, clipboardManager)
|
clearMocks(supportedNetworksMatcher, memoValidator, clipboardManager, listenToQrScanningUseCase, router)
|
||||||
// Default: no accounts, so no coins are available unless a test overrides it.
|
selectNetworksResultHolder.clear()
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
|
// 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<String>().right()
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
fun tearDown() {
|
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?.onDestroy()
|
||||||
model = null
|
model = null
|
||||||
}
|
}
|
||||||
|
|
@ -98,13 +109,14 @@ internal class AddAddressModelTest {
|
||||||
assertThat(model.state.value.addressField.value).isEqualTo(address)
|
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
|
@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
|
// Arrange
|
||||||
var confirmed: ValidatedAddress? = null
|
var confirmed: ValidatedAddress? = null
|
||||||
val model = createModel(testScope = this, onConfirm = { confirmed = it })
|
val model = createModel(testScope = this, onConfirm = { confirmed = it })
|
||||||
model.state.value.onAddressChange("0xABC")
|
model.state.value.onAddressChange("0xABC")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
model.state.value.buttonUM.onClick()
|
model.state.value.buttonUM.onClick()
|
||||||
|
|
@ -119,14 +131,14 @@ internal class AddAddressModelTest {
|
||||||
inner class Validation {
|
inner class Validation {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN coins available WHEN valid address typed THEN no error AND button enabled`() = runTest {
|
fun `GIVEN single matching network WHEN typed THEN no error AND button enabled`() = runTest {
|
||||||
// Arrange
|
// Arrange — a single matched network is auto-selected, so the button is enabled right away.
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum)
|
||||||
val model = createModel(testScope = this)
|
val model = createModel(testScope = this)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
model.state.value.onAddressChange(ADDRESS)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -136,14 +148,31 @@ internal class AddAddressModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN coins available WHEN address matches no network THEN error AND button disabled`() = runTest {
|
fun `GIVEN several matching networks WHEN typed THEN no error but button disabled until selection`() = runTest {
|
||||||
// Arrange
|
// Arrange — several matches are shown for context, but none is selected until the user picks explicitly.
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC)
|
||||||
val model = createModel(testScope = this)
|
val model = createModel(testScope = this)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Act
|
// 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()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -157,7 +186,6 @@ internal class AddAddressModelTest {
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest {
|
fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest {
|
||||||
// Arrange
|
// Arrange
|
||||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum)))
|
|
||||||
val model = createModel(testScope = this)
|
val model = createModel(testScope = this)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|
@ -170,54 +198,362 @@ internal class AddAddressModelTest {
|
||||||
assertThat(state.addressField.isError).isFalse()
|
assertThat(state.addressField.isError).isFalse()
|
||||||
assertThat(state.buttonUM.isEnabled).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
|
@Test
|
||||||
fun `GIVEN address typed before coins load WHEN coins emitted THEN validated reactively`() = runTest {
|
fun `GIVEN blank address WHEN validated THEN selector hidden`() = runTest {
|
||||||
// Arrange
|
// Act
|
||||||
val accountsFlow = MutableStateFlow<List<AccountList>>(emptyList())
|
|
||||||
every { multiAccountListSupplier.invoke() } returns accountsFlow
|
|
||||||
val model = createModel(testScope = this)
|
val model = createModel(testScope = this)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Act — type while coins are still empty
|
// Assert
|
||||||
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Hidden)
|
||||||
advanceUntilIdle()
|
}
|
||||||
// Assert intermediate: nothing to match yet
|
|
||||||
assertThat(model.state.value.buttonUM.isEnabled).isFalse()
|
|
||||||
|
|
||||||
// Act — coins arrive later
|
@Test
|
||||||
accountsFlow.value = listOf(accountListWith(ethereum, bitcoin))
|
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()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
val state = model.state.value
|
assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Hidden)
|
||||||
assertThat(state.buttonUM.isEnabled).isTrue()
|
}
|
||||||
assertThat(state.addressField.isError).isFalse()
|
|
||||||
|
@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<String> = 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 {
|
@Nested
|
||||||
val walletId = MockAccounts.userWalletId
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
val accounts = listOf(
|
inner class Memo {
|
||||||
Account.CryptoPortfolio.createMainAccount(
|
|
||||||
userWalletId = walletId,
|
@Test
|
||||||
cryptoCurrencies = currencies.toList(),
|
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)
|
||||||
return AccountList(
|
val model = createModel(testScope = this)
|
||||||
userWalletId = walletId,
|
advanceUntilIdle()
|
||||||
accounts = accounts,
|
|
||||||
totalAccounts = accounts.size,
|
// Act
|
||||||
totalArchivedAccounts = 0,
|
model.state.value.onAddressChange(ADDRESS)
|
||||||
).getOrNull()!!
|
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(
|
private fun createModel(
|
||||||
testScope: TestScope,
|
testScope: TestScope,
|
||||||
onConfirm: (ValidatedAddress) -> Unit = {},
|
onConfirm: (ValidatedAddress) -> Unit = {},
|
||||||
|
onSelectNetworksClick: (String, List<String>) -> Unit = { _, _ -> },
|
||||||
params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params(
|
params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params(
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
|
onSelectNetworksClick = onSelectNetworksClick,
|
||||||
onConfirm = onConfirm,
|
onConfirm = onConfirm,
|
||||||
),
|
),
|
||||||
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
|
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
|
||||||
|
|
@ -225,9 +561,13 @@ internal class AddAddressModelTest {
|
||||||
return AddAddressModel(
|
return AddAddressModel(
|
||||||
paramsContainer = paramsContainer,
|
paramsContainer = paramsContainer,
|
||||||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||||
multiAccountListSupplier = multiAccountListSupplier,
|
supportedNetworksMatcher = supportedNetworksMatcher,
|
||||||
|
memoValidator = memoValidator,
|
||||||
|
listenToQrScanningUseCase = listenToQrScanningUseCase,
|
||||||
clipboardManager = clipboardManager,
|
clipboardManager = clipboardManager,
|
||||||
stateController = AddAddressStateController(),
|
stateController = AddAddressStateController(),
|
||||||
|
selectNetworksResultHolder = selectNetworksResultHolder,
|
||||||
|
router = router,
|
||||||
).also { model = it }
|
).also { model = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,7 +583,6 @@ internal class AddAddressModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
// EIP-55 checksummed address from the spec — guaranteed to pass Ethereum validation.
|
const val ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
|
||||||
const val VALID_ETH_ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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<String>? = 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<String>? = 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<String> = emptyList(),
|
||||||
|
onDone: (Set<String>) -> 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ internal class InitializeQrScanningStateTransformer(
|
||||||
SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) }
|
SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) }
|
||||||
SourceType.WALLET_CONNECT -> resourceReference(R.string.wc_qr_scan_hint)
|
SourceType.WALLET_CONNECT -> resourceReference(R.string.wc_qr_scan_hint)
|
||||||
SourceType.MAIN_SCREEN -> resourceReference(R.string.main_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(
|
return QrScanningState(
|
||||||
|
|
@ -49,6 +50,10 @@ internal class InitializeQrScanningStateTransformer(
|
||||||
title = null,
|
title = null,
|
||||||
startIcon = R.drawable.ic_close_24,
|
startIcon = R.drawable.ic_close_24,
|
||||||
)
|
)
|
||||||
|
SourceType.ADDRESS_BOOK -> TopBarConfig(
|
||||||
|
title = null,
|
||||||
|
startIcon = R.drawable.ic_back_24,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue