Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-16 13:48:50 +03:00
parent 1470d4b08d
commit 8beb80f5a7
13 changed files with 778 additions and 34 deletions

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di.domain package com.tangem.tap.di.domain
import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
@ -39,10 +40,12 @@ internal object QrScanningDomainModule {
fun provideResolveQrSendTargetsUseCase( fun provideResolveQrSendTargetsUseCase(
multiAccountListSupplier: MultiAccountListSupplier, multiAccountListSupplier: MultiAccountListSupplier,
qrScanningEventsRepository: QrScanningEventsRepository, qrScanningEventsRepository: QrScanningEventsRepository,
userWalletsListRepository: UserWalletsListRepository,
): ResolveQrSendTargetsUseCase { ): ResolveQrSendTargetsUseCase {
return ResolveQrSendTargetsUseCase( return ResolveQrSendTargetsUseCase(
multiAccountListSupplier = multiAccountListSupplier, multiAccountListSupplier = multiAccountListSupplier,
qrScanningEventsRepository = qrScanningEventsRepository, qrScanningEventsRepository = qrScanningEventsRepository,
userWalletsListRepository = userWalletsListRepository,
) )
} }
} }

View file

@ -217,6 +217,7 @@
<string name="common_add">Add</string> <string name="common_add">Add</string>
<string name="common_add_to_portfolio">Add to portfolio</string> <string name="common_add_to_portfolio">Add to portfolio</string>
<string name="common_add_token">Add token</string> <string name="common_add_token">Add token</string>
<string name="common_add_tokens">Add tokens</string>
<string name="common_added">Added</string> <string name="common_added">Added</string>
<string name="common_address">Address</string> <string name="common_address">Address</string>
<string name="common_all">All</string> <string name="common_all">All</string>
@ -325,6 +326,7 @@
<string name="common_nft">NFT</string> <string name="common_nft">NFT</string>
<string name="common_no">No</string> <string name="common_no">No</string>
<string name="common_no_address">No address</string> <string name="common_no_address">No address</string>
<string name="common_no_results">No results</string>
<string name="common_not_added">Not Added</string> <string name="common_not_added">Not Added</string>
<string name="common_not_available">Not available</string> <string name="common_not_available">Not available</string>
<string name="common_not_now">Not now</string> <string name="common_not_now">Not now</string>
@ -383,6 +385,7 @@
<string name="common_to">To</string> <string name="common_to">To</string>
<string name="common_to_wallet_name">To %s</string> <string name="common_to_wallet_name">To %s</string>
<string name="common_today">Today</string> <string name="common_today">Today</string>
<string name="common_token_send">Token to send</string>
<plurals name="common_tokens_count"> <plurals name="common_tokens_count">
<item quantity="one">%d token</item> <item quantity="one">%d token</item>
<item quantity="other">%d tokens</item> <item quantity="other">%d tokens</item>
@ -395,6 +398,7 @@
<string name="common_understand">I understand</string> <string name="common_understand">I understand</string>
<string name="common_understand_continue">I understand, continue</string> <string name="common_understand_continue">I understand, continue</string>
<string name="common_unknown_error">There was an error. Please try again.</string> <string name="common_unknown_error">There was an error. Please try again.</string>
<string name="common_unlock">Unlock</string>
<string name="common_unreachable">Unreachable</string> <string name="common_unreachable">Unreachable</string>
<string name="common_unstake">Unstake</string> <string name="common_unstake">Unstake</string>
<string name="common_utxo_validate_withdrawal_message_warning">Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount.</string> <string name="common_utxo_validate_withdrawal_message_warning">Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount.</string>
@ -1211,6 +1215,10 @@
<string name="send_memo">Memo: %s</string> <string name="send_memo">Memo: %s</string>
<string name="send_memo_destination_tag_error">Invalid Memo</string> <string name="send_memo_destination_tag_error">Invalid Memo</string>
<string name="send_network_fee_warning_title">Network fee coverage</string> <string name="send_network_fee_warning_title">Network fee coverage</string>
<plurals name="send_network_selection_hidden_tokens">
<item quantity="one">%d token isn\'t compatible with this address</item>
<item quantity="other">%d tokens aren\'t compatible with this address</item>
</plurals>
<string name="send_nonce">Nonce</string> <string name="send_nonce">Nonce</string>
<string name="send_nonce_footer">Unique number for each transaction. Use it to resend or cancel a pending transaction.</string> <string name="send_nonce_footer">Unique number for each transaction. Use it to resend or cancel a pending transaction.</string>
<string name="send_nonce_hint">Enter nonce…</string> <string name="send_nonce_hint">Enter nonce…</string>

View file

@ -13,6 +13,7 @@ dependencies {
/** Domain */ /** Domain */
api(projects.domain.models) api(projects.domain.models)
implementation(projects.domain.account) implementation(projects.domain.account)
implementation(projects.domain.common)
implementation(projects.domain.qrScanning.models) implementation(projects.domain.qrScanning.models)
implementation(projects.domain.tokens.models) implementation(projects.domain.tokens.models)

View file

@ -35,6 +35,7 @@ sealed class QrSendTarget {
val accountId: AccountId, val accountId: AccountId,
val accountName: AccountName, val accountName: AccountName,
val currencies: List<CryptoCurrency>, val currencies: List<CryptoCurrency>,
val hiddenTokensCount: Int = 0,
) )
} }

View file

@ -6,6 +6,7 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
import java.math.BigDecimal import java.math.BigDecimal
@ -14,41 +15,41 @@ import com.tangem.domain.qrscanning.models.QrSendTarget
class ResolveQrSendTargetsUseCase( class ResolveQrSendTargetsUseCase(
private val multiAccountListSupplier: MultiAccountListSupplier, private val multiAccountListSupplier: MultiAccountListSupplier,
private val qrScanningEventsRepository: QrScanningEventsRepository, private val qrScanningEventsRepository: QrScanningEventsRepository,
private val userWalletsListRepository: UserWalletsListRepository,
) { ) {
suspend operator fun invoke(qrCode: String): QrSendTarget { suspend operator fun invoke(qrCode: String): QrSendTarget {
val allAccountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty() val allAccountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty()
val userWallets = userWalletsListRepository.userWalletsSync()
val walletNamesMap = userWallets.associate { it.walletId to it.name }
val currencyEntries = allAccountLists.flatMap { accountList -> val allCurrencies = mutableListOf<CryptoCurrency>()
accountList.accounts val currencyLocations = mutableMapOf<CryptoCurrency.ID, MutableList<CurrencyLocation>>()
.filterIsInstance<Account.CryptoPortfolio>() val totalPerAccount = mutableMapOf<AccountId, Int>()
.flatMap { account ->
account.cryptoCurrencies.map { currency -> for (accountList in allAccountLists) {
currency to CurrencyLocation( for (account in accountList.accounts.filterIsInstance<Account.CryptoPortfolio>()) {
userWalletId = accountList.userWalletId, val location = CurrencyLocation(
walletName = accountList.userWalletId.stringValue, walletName = walletNamesMap[account.accountId.userWalletId]
accountId = account.accountId, ?: account.accountId.userWalletId.stringValue,
accountName = account.accountName, accountId = account.accountId,
) accountName = account.accountName,
} )
totalPerAccount[account.accountId] = account.cryptoCurrencies.size
for (currency in account.cryptoCurrencies) {
allCurrencies.add(currency)
currencyLocations.getOrPut(currency.id) { mutableListOf() }.add(location)
} }
}
} }
val allCurrencies = currencyEntries.map { it.first }
val currencyLocations = currencyEntries.groupBy(
keySelector = { it.first.id },
valueTransform = { it.second },
)
val classified = qrScanningEventsRepository.classify(qrCode, allCurrencies) val classified = qrScanningEventsRepository.classify(qrCode, allCurrencies)
val portfolioIndex = PortfolioIndex(currencyLocations, totalPerAccount)
return resolve(classified, currencyLocations) return resolve(classified, portfolioIndex)
} }
private fun resolve( private fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget {
classified: ClassifiedQrContent,
currencyLocations: Map<CryptoCurrency.ID, List<CurrencyLocation>>,
): QrSendTarget {
return when (classified) { return when (classified) {
is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri) is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri)
is ClassifiedQrContent.Unknown -> QrSendTarget.Unknown(classified.raw) is ClassifiedQrContent.Unknown -> QrSendTarget.Unknown(classified.raw)
@ -57,14 +58,14 @@ class ResolveQrSendTargetsUseCase(
amount = null, amount = null,
memo = null, memo = null,
matchingCurrencies = classified.matchingCurrencies, matchingCurrencies = classified.matchingCurrencies,
currencyLocations = currencyLocations, portfolioIndex = portfolioIndex,
) )
is ClassifiedQrContent.PaymentUri -> resolveAddressTarget( is ClassifiedQrContent.PaymentUri -> resolveAddressTarget(
address = classified.address, address = classified.address,
amount = classified.amount, amount = classified.amount,
memo = classified.memo, memo = classified.memo,
matchingCurrencies = classified.matchingCurrencies, matchingCurrencies = classified.matchingCurrencies,
currencyLocations = currencyLocations, portfolioIndex = portfolioIndex,
) )
} }
} }
@ -74,9 +75,9 @@ class ResolveQrSendTargetsUseCase(
amount: BigDecimal?, amount: BigDecimal?,
memo: String?, memo: String?,
matchingCurrencies: List<CryptoCurrency>, matchingCurrencies: List<CryptoCurrency>,
currencyLocations: Map<CryptoCurrency.ID, List<CurrencyLocation>>, portfolioIndex: PortfolioIndex,
): QrSendTarget { ): QrSendTarget {
val walletGroups = buildWalletGroups(matchingCurrencies, currencyLocations) val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex)
val singleGroup = walletGroups.singleOrNull() val singleGroup = walletGroups.singleOrNull()
val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.singleOrNull() val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.singleOrNull()
@ -101,15 +102,15 @@ class ResolveQrSendTargetsUseCase(
private fun buildWalletGroups( private fun buildWalletGroups(
matchingCurrencies: List<CryptoCurrency>, matchingCurrencies: List<CryptoCurrency>,
currencyLocations: Map<CryptoCurrency.ID, List<CurrencyLocation>>, portfolioIndex: PortfolioIndex,
): List<QrSendTarget.Multiple.WalletGroup> { ): List<QrSendTarget.Multiple.WalletGroup> {
val walletMap = linkedMapOf<UserWalletId, WalletInfo>() val walletMap = linkedMapOf<UserWalletId, WalletInfo>()
val uniqueCurrencies = matchingCurrencies.distinctBy { it.id } val uniqueCurrencies = matchingCurrencies.distinctBy { it.id }
for (currency in uniqueCurrencies) { for (currency in uniqueCurrencies) {
val locations = currencyLocations[currency.id] ?: continue val locations = portfolioIndex.currencyLocations[currency.id].orEmpty()
for (location in locations) { for (location in locations) {
val walletInfo = walletMap.getOrPut(location.userWalletId) { val walletInfo = walletMap.getOrPut(location.accountId.userWalletId) {
WalletInfo(location.walletName, linkedMapOf()) WalletInfo(location.walletName, linkedMapOf())
} }
val accountInfo = walletInfo.accounts.getOrPut(location.accountId) { val accountInfo = walletInfo.accounts.getOrPut(location.accountId) {
@ -124,18 +125,24 @@ class ResolveQrSendTargetsUseCase(
userWalletId = walletId, userWalletId = walletId,
walletName = walletInfo.walletName, walletName = walletInfo.walletName,
accounts = walletInfo.accounts.map { (accountId, accountInfo) -> accounts = walletInfo.accounts.map { (accountId, accountInfo) ->
val total = portfolioIndex.totalPerAccount[accountId] ?: 0
QrSendTarget.Multiple.AccountGroup( QrSendTarget.Multiple.AccountGroup(
accountId = accountId, accountId = accountId,
accountName = accountInfo.accountName, accountName = accountInfo.accountName,
currencies = accountInfo.currencies, currencies = accountInfo.currencies,
hiddenTokensCount = total - accountInfo.currencies.size,
) )
}, },
) )
} }
} }
private class PortfolioIndex(
val currencyLocations: Map<CryptoCurrency.ID, List<CurrencyLocation>>,
val totalPerAccount: Map<AccountId, Int>,
)
private data class CurrencyLocation( private data class CurrencyLocation(
val userWalletId: UserWalletId,
val walletName: String, val walletName: String,
val accountId: AccountId, val accountId: AccountId,
val accountName: AccountName, val accountName: AccountName,

View file

@ -0,0 +1,36 @@
package com.tangem.features.send.v2.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableDialogComponent
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
interface NetworkSelectionComponent : ComposableDialogComponent {
data class Params(
val address: String,
val amount: BigDecimal?,
val memo: String?,
val walletGroups: List<WalletGroup>,
val onTokenSelected: (UserWalletId, CryptoCurrency) -> Unit,
val onDismiss: () -> Unit,
) {
data class WalletGroup(
val userWalletId: UserWalletId,
val walletName: String,
val accounts: List<AccountGroup>,
)
data class AccountGroup(
val accountId: AccountId,
val accountName: AccountName,
val currencies: List<CryptoCurrency>,
val hiddenTokensCount: Int = 0,
)
}
interface Factory : ComponentFactory<Params, NetworkSelectionComponent>
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.send.v2.networkselection
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.send.v2.api.NetworkSelectionComponent
import com.tangem.features.send.v2.networkselection.model.NetworkSelectionModel
import com.tangem.features.send.v2.networkselection.ui.NetworkSelectionScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultNetworkSelectionComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: NetworkSelectionComponent.Params,
) : NetworkSelectionComponent, AppComponentContext by context {
private val model: NetworkSelectionModel = getOrCreateModel(params)
override fun dismiss() {
params.onDismiss()
}
@Composable
override fun Dialog() {
val state = model.uiState.collectAsStateWithLifecycle()
NetworkSelectionScreen(state = state.value, onDismiss = ::dismiss)
}
@AssistedFactory
interface Factory : NetworkSelectionComponent.Factory {
override fun create(
context: AppComponentContext,
params: NetworkSelectionComponent.Params,
): DefaultNetworkSelectionComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.networkselection.di
import com.tangem.features.send.v2.api.NetworkSelectionComponent
import com.tangem.features.send.v2.networkselection.DefaultNetworkSelectionComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface NetworkSelectionFeatureModule {
@Binds
@Singleton
fun bindNetworkSelectionComponentFactory(
impl: DefaultNetworkSelectionComponent.Factory,
): NetworkSelectionComponent.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.send.v2.networkselection.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.send.v2.networkselection.model.NetworkSelectionModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface NetworkSelectionModelModule {
@Binds
@IntoMap
@ClassKey(NetworkSelectionModel::class)
fun provideNetworkSelectionModel(model: NetworkSelectionModel): Model
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.send.v2.networkselection.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class NetworkSelectionUM(
val searchBar: SearchBarUM,
val walletGroups: ImmutableList<WalletGroupUM>,
val isBalanceHidden: Boolean,
)
@Immutable
internal data class WalletGroupUM(
val userWalletId: UserWalletId,
val walletName: String,
val isExpanded: Boolean,
val onExpandToggle: () -> Unit,
val accounts: ImmutableList<AccountGroupUM>,
)
@Immutable
internal data class AccountGroupUM(
val accountName: TextReference,
val iconState: CurrencyIconState.CryptoPortfolio?,
val tokens: ImmutableList<TokenItemState>,
val hiddenTokensCount: Int,
)

View file

@ -0,0 +1,264 @@
package com.tangem.features.send.v2.networkselection.model
import androidx.compose.runtime.Stable
import com.tangem.common.getTotalCryptoAmount
import com.tangem.common.getTotalFiatAmount
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.common.ui.account.AccountIconItemStateConverter
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.CurrencyIconStateBuilder
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.StatusSource
import com.tangem.common.ui.account.toUM
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.send.v2.api.NetworkSelectionComponent
import com.tangem.features.send.v2.networkselection.entity.AccountGroupUM
import com.tangem.features.send.v2.networkselection.entity.NetworkSelectionUM
import com.tangem.features.send.v2.networkselection.entity.WalletGroupUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@Stable
@ModelScoped
internal class NetworkSelectionModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
) : Model() {
private val params: NetworkSelectionComponent.Params = paramsContainer.require()
private val searchQuery = MutableStateFlow("")
private val expandedWallets = MutableStateFlow(
params.walletGroups.firstOrNull()
?.userWalletId
?.let(::setOf)
.orEmpty(),
)
val uiState: StateFlow<NetworkSelectionUM> = createUiStateFlow()
private fun createUiStateFlow(): StateFlow<NetworkSelectionUM> {
val appCurrencyFlow = getSelectedAppCurrencyUseCase.invokeOrDefault()
val statusListFlow = multiAccountStatusListSupplier()
val balanceHidingFlow = getBalanceHidingSettingsUseCase()
return combine(
flow = searchQuery,
flow2 = expandedWallets,
flow3 = appCurrencyFlow,
flow4 = statusListFlow,
flow5 = balanceHidingFlow,
) { query, expanded, appCurrency, statusLists, balanceHidingSettings ->
buildState(
query = query,
expandedWallets = expanded,
appCurrency = appCurrency,
statusLists = statusLists,
isBalanceHidden = balanceHidingSettings.isBalanceHidden,
)
}.stateIn(
scope = modelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = buildInitialState(),
)
}
private fun buildState(
query: String,
expandedWallets: Set<UserWalletId>,
appCurrency: AppCurrency,
statusLists: List<AccountStatusList>,
isBalanceHidden: Boolean,
): NetworkSelectionUM {
val walletGroups = params.walletGroups.mapNotNull { walletGroup ->
buildWalletGroup(
walletGroup = walletGroup,
query = query,
expandedWallets = expandedWallets,
appCurrency = appCurrency,
statusLists = statusLists,
)
}.toImmutableList()
return NetworkSelectionUM(
searchBar = createSearchBar(query),
walletGroups = walletGroups,
isBalanceHidden = isBalanceHidden,
)
}
private fun buildWalletGroup(
walletGroup: NetworkSelectionComponent.Params.WalletGroup,
query: String,
expandedWallets: Set<UserWalletId>,
appCurrency: AppCurrency,
statusLists: List<AccountStatusList>,
): WalletGroupUM? {
val statusList = statusLists.find { it.userWalletId == walletGroup.userWalletId }
val tokenBuildContext = TokenMappingParams(
userWalletId = walletGroup.userWalletId,
appCurrency = appCurrency,
statusMap = buildStatusMap(statusList),
)
val accounts = walletGroup.accounts.mapNotNull { accountGroup ->
val iconState = getAccountIcon(accountGroup.accountId, statusList)
buildAccountGroup(
accountGroup = accountGroup,
query = query,
context = tokenBuildContext,
iconState = iconState,
)
}.toImmutableList()
if (accounts.isEmpty()) return null
return WalletGroupUM(
userWalletId = walletGroup.userWalletId,
walletName = walletGroup.walletName,
isExpanded = walletGroup.userWalletId in expandedWallets,
onExpandToggle = { toggleWalletExpanded(walletGroup.userWalletId) },
accounts = accounts,
)
}
private fun buildStatusMap(statusList: AccountStatusList?): Map<CryptoCurrency.ID, CryptoCurrencyStatus> {
if (statusList == null) return emptyMap()
return statusList.accountStatuses
.filterCryptoPortfolio()
.flatMap { it.flattenCurrencies() }
.associateBy { it.currency.id }
}
private fun buildAccountGroup(
accountGroup: NetworkSelectionComponent.Params.AccountGroup,
query: String,
context: TokenMappingParams,
iconState: CurrencyIconState.CryptoPortfolio?,
): AccountGroupUM? {
val tokens = accountGroup.currencies
.filter { matchesQuery(it, query) }
.map { currency -> buildTokenItem(currency, context) }
.toImmutableList()
if (tokens.isEmpty()) return null
return AccountGroupUM(
accountName = accountGroup.accountName.toUM().value,
iconState = iconState,
tokens = tokens,
hiddenTokensCount = accountGroup.hiddenTokensCount,
)
}
private fun getAccountIcon(
accountId: AccountId,
statusList: AccountStatusList?,
): CurrencyIconState.CryptoPortfolio? {
if (statusList == null) return null
val account = statusList.accountStatuses
.filterCryptoPortfolio()
.find { it.accountId == accountId }
?.account ?: return null
return AccountIconItemStateConverter(size = AccountIconSize.ExtraSmall).convert(account)
}
private fun matchesQuery(currency: CryptoCurrency, query: String): Boolean {
if (query.isBlank()) return true
val lowerQuery = query.lowercase()
return currency.name.lowercase().contains(lowerQuery) ||
currency.symbol.lowercase().contains(lowerQuery) ||
currency.network.name.lowercase().contains(lowerQuery)
}
private fun buildTokenItem(currency: CryptoCurrency, context: TokenMappingParams): TokenItemState {
val status = context.statusMap[currency.id]
if (status == null) {
return TokenItemState.Loading(id = currency.id.value)
}
val cryptoAmount = status.getTotalCryptoAmount()
val fiatAmount = status.getTotalFiatAmount()
val isFlickering = status.value.sources.total == StatusSource.CACHE
return TokenItemState.Content(
id = currency.id.value,
iconState = CurrencyIconStateBuilder.build(currency),
titleState = TokenItemState.TitleState.Content(
text = stringReference(currency.name),
),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference(currency.network.name),
),
fiatAmountState = TokenItemState.FiatAmountState.Content(
text = fiatAmount.format {
fiat(
fiatCurrencyCode = context.appCurrency.code,
fiatCurrencySymbol = context.appCurrency.symbol,
)
},
isFlickering = isFlickering,
),
subtitle2State = TokenItemState.Subtitle2State.TextContent(
text = cryptoAmount.format { crypto(currency) },
isFlickering = isFlickering,
),
onItemClick = { params.onTokenSelected(context.userWalletId, currency) },
onItemLongClick = null,
)
}
private fun toggleWalletExpanded(walletId: UserWalletId) {
expandedWallets.update { current ->
if (walletId in current) current - walletId else current + walletId
}
}
private fun createSearchBar(query: String): SearchBarUM {
return SearchBarUM(
placeholderText = resourceReference(com.tangem.core.ui.R.string.common_search_tokens),
query = query,
onQueryChange = { searchQuery.value = it },
isActive = query.isNotEmpty(),
onActiveChange = {},
)
}
private fun buildInitialState(): NetworkSelectionUM {
return NetworkSelectionUM(
searchBar = createSearchBar(""),
walletGroups = persistentListOf(),
isBalanceHidden = true,
)
}
private data class TokenMappingParams(
val userWalletId: UserWalletId,
val appCurrency: AppCurrency,
val statusMap: Map<CryptoCurrency.ID, CryptoCurrencyStatus>,
)
}

View file

@ -0,0 +1,300 @@
package com.tangem.features.send.v2.networkselection.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.pluralStringResourceSafe
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.v2.networkselection.entity.AccountGroupUM
import com.tangem.features.send.v2.networkselection.entity.NetworkSelectionUM
import com.tangem.features.send.v2.networkselection.entity.WalletGroupUM
private const val CHEVRON_EXPANDED_ROTATION = 180f
private const val CHEVRON_COLLAPSED_ROTATION = 0f
@Composable
internal fun NetworkSelectionScreen(state: NetworkSelectionUM, onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors.background.tertiary)
.systemBarsPadding()
.imePadding(),
) {
TangemTopAppBar(
title = stringResourceSafe(R.string.common_token_send),
startButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_close_24,
onClicked = onDismiss,
),
)
SearchBar(
state = state.searchBar,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing22),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
if (state.walletGroups.isEmpty() && state.searchBar.query.isNotBlank()) {
NetworkSelectionEmpty(modifier = Modifier.weight(1f))
} else {
NetworkSelectionContent(state = state)
}
}
}
}
@Composable
private fun NetworkSelectionEmpty(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
Text(
text = stringResourceSafe(R.string.common_no_results),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun NetworkSelectionContent(state: NetworkSelectionUM) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = TangemTheme.dimens.spacing12),
) {
state.walletGroups.forEach { walletGroup ->
item(key = "wallet_header_${walletGroup.userWalletId}") {
WalletHeader(walletGroup = walletGroup)
}
walletGroup.accounts.forEach { accountGroup ->
accountGroupItems(
walletGroup = walletGroup,
accountGroup = accountGroup,
isBalanceHidden = state.isBalanceHidden,
)
}
}
}
}
private fun LazyListScope.accountGroupItems(
walletGroup: WalletGroupUM,
accountGroup: AccountGroupUM,
isBalanceHidden: Boolean,
) {
val hasHiddenTokens = accountGroup.hiddenTokensCount > 0
val lastIndex = accountGroup.tokens.lastIndex.inc() + if (hasHiddenTokens) 1 else 0
item(key = "account_${walletGroup.userWalletId}_${accountGroup.accountName}") {
AnimatedVisibility(
visible = walletGroup.isExpanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
AccountHeader(
accountGroup = accountGroup,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = 0,
lastIndex = lastIndex,
radius = TangemTheme.dimens.radius14,
backgroundColor = TangemTheme.colors.background.action,
),
)
}
}
itemsIndexed(
items = accountGroup.tokens,
key = { _, token -> "token_${walletGroup.userWalletId}_${token.id}" },
) { index, tokenState ->
val indexWithHeader = index.inc()
AnimatedVisibility(
visible = walletGroup.isExpanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
TokenItem(
state = tokenState,
isBalanceHidden = isBalanceHidden,
reorderableTokenListState = null,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = indexWithHeader,
lastIndex = lastIndex,
radius = TangemTheme.dimens.radius14,
backgroundColor = TangemTheme.colors.background.action,
),
)
}
}
if (hasHiddenTokens) {
item(
key = "hidden_${walletGroup.userWalletId}_${accountGroup.accountName}",
) {
AnimatedVisibility(
visible = walletGroup.isExpanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
HiddenTokensFooter(
count = accountGroup.hiddenTokensCount,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = lastIndex,
lastIndex = lastIndex,
radius = TangemTheme.dimens.radius14,
backgroundColor = TangemTheme.colors.background.action,
),
)
}
}
}
}
@Composable
private fun WalletHeader(walletGroup: WalletGroupUM) {
val chevronRotation by animateFloatAsState(
targetValue = if (walletGroup.isExpanded) CHEVRON_COLLAPSED_ROTATION else CHEVRON_EXPANDED_ROTATION,
label = "chevron_rotation",
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = walletGroup.onExpandToggle)
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = walletGroup.walletName,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.weight(1f),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(TangemTheme.dimens.size24)
.background(
color = TangemTheme.colors.button.secondary,
shape = CircleShape,
),
) {
Icon(
painter = painterResource(id = R.drawable.ic_chevron_up_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.rotate(chevronRotation),
)
}
}
}
@Composable
private fun AccountHeader(accountGroup: AccountGroupUM, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing10,
bottom = TangemTheme.dimens.spacing6,
end = TangemTheme.dimens.spacing12,
),
) {
accountGroup.iconState?.let { iconState ->
CurrencyIcon(
state = iconState,
modifier = Modifier.size(TangemTheme.dimens.size18),
)
Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing6))
}
Text(
text = accountGroup.accountName.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Composable
private fun HiddenTokensFooter(count: Int, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
HorizontalDivider(
thickness = 0.5.dp,
color = TangemTheme.colors.stroke.primary,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing14),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing10,
bottom = TangemTheme.dimens.spacing10,
),
) {
Icon(
painter = painterResource(id = R.drawable.ic_eye_off_outline_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.size(TangemTheme.dimens.size16),
)
Spacer(modifier = Modifier.width(TangemTheme.dimens.spacing8))
Text(
text = pluralStringResourceSafe(
id = R.plurals.send_network_selection_hidden_tokens,
count = count,
count,
),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}

View file

@ -281,9 +281,11 @@ internal class SendModel @Inject constructor(
} }
is PredefinedValues.Content.QrCode -> { is PredefinedValues.Content.QrCode -> {
val predefinedAmount = predefinedValues.amount?.parseBigDecimalOrNull() val predefinedAmount = predefinedValues.amount?.parseBigDecimalOrNull()
val amount = predefinedAmount
?: (uiState.value.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value
?: error("Invalid amount")
createTransferTransactionUseCase( createTransferTransactionUseCase(
amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus) amount = amount.convertToSdkAmount(cryptoCurrencyStatus),
?: error("Invalid amount"),
memo = predefinedValues.memo, memo = predefinedValues.memo,
destination = predefinedValues.address, destination = predefinedValues.address,
userWalletId = userWallet.walletId, userWalletId = userWallet.walletId,
@ -422,13 +424,24 @@ internal class SendModel @Inject constructor(
feeCryptoCurrencyStatusFlow, feeCryptoCurrencyStatusFlow,
) { cryptoCurrencyStatus, feeCryptoCurrencyStatus -> ) { cryptoCurrencyStatus, feeCryptoCurrencyStatus ->
if (isAvailableForSend && currentRoute.value == initialRoute) { if (isAvailableForSend && currentRoute.value == initialRoute) {
router.replaceAll(Confirm) if (isPredefinedAmountExceedsBalance(cryptoCurrencyStatus)) {
router.replaceAll(Amount(isEditMode = false))
} else {
router.replaceAll(Confirm)
}
} else if (isUnavailableForSend) { } else if (isUnavailableForSend) {
showAlertError() showAlertError()
} }
}.launchIn(modelScope) }.launchIn(modelScope)
} }
private fun isPredefinedAmountExceedsBalance(cryptoCurrencyStatus: CryptoCurrencyStatus): Boolean {
val predefinedAmount = (predefinedValues as? PredefinedValues.Content)?.amount
?.parseBigDecimalOrNull() ?: return false
val balance = cryptoCurrencyStatus.value.amount ?: return false
return predefinedAmount > balance
}
private fun CryptoCurrencyStatus.hasAvailableStatus(): Boolean = this.value is CryptoCurrencyStatus.Loaded || private fun CryptoCurrencyStatus.hasAvailableStatus(): Boolean = this.value is CryptoCurrencyStatus.Loaded ||
this.value is CryptoCurrencyStatus.Custom || this.value is CryptoCurrencyStatus.Custom ||
this.value is CryptoCurrencyStatus.NoQuote this.value is CryptoCurrencyStatus.NoQuote