Updated on 2026-08-14
This commit is contained in:
parent
1470d4b08d
commit
8beb80f5a7
13 changed files with 778 additions and 34 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.tap.di.domain
|
||||
|
||||
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.usecases.EmitQrScannedEventUseCase
|
||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||
|
|
@ -39,10 +40,12 @@ internal object QrScanningDomainModule {
|
|||
fun provideResolveQrSendTargetsUseCase(
|
||||
multiAccountListSupplier: MultiAccountListSupplier,
|
||||
qrScanningEventsRepository: QrScanningEventsRepository,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
): ResolveQrSendTargetsUseCase {
|
||||
return ResolveQrSendTargetsUseCase(
|
||||
multiAccountListSupplier = multiAccountListSupplier,
|
||||
qrScanningEventsRepository = qrScanningEventsRepository,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -217,6 +217,7 @@
|
|||
<string name="common_add">Add</string>
|
||||
<string name="common_add_to_portfolio">Add to portfolio</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_address">Address</string>
|
||||
<string name="common_all">All</string>
|
||||
|
|
@ -325,6 +326,7 @@
|
|||
<string name="common_nft">NFT</string>
|
||||
<string name="common_no">No</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_available">Not available</string>
|
||||
<string name="common_not_now">Not now</string>
|
||||
|
|
@ -383,6 +385,7 @@
|
|||
<string name="common_to">To</string>
|
||||
<string name="common_to_wallet_name">To %s</string>
|
||||
<string name="common_today">Today</string>
|
||||
<string name="common_token_send">Token to send</string>
|
||||
<plurals name="common_tokens_count">
|
||||
<item quantity="one">%d token</item>
|
||||
<item quantity="other">%d tokens</item>
|
||||
|
|
@ -395,6 +398,7 @@
|
|||
<string name="common_understand">I understand</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_unlock">Unlock</string>
|
||||
<string name="common_unreachable">Unreachable</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>
|
||||
|
|
@ -1211,6 +1215,10 @@
|
|||
<string name="send_memo">Memo: %s</string>
|
||||
<string name="send_memo_destination_tag_error">Invalid Memo</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_footer">Unique number for each transaction. Use it to resend or cancel a pending transaction.</string>
|
||||
<string name="send_nonce_hint">Enter nonce…</string>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ dependencies {
|
|||
/** Domain */
|
||||
api(projects.domain.models)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ sealed class QrSendTarget {
|
|||
val accountId: AccountId,
|
||||
val accountName: AccountName,
|
||||
val currencies: List<CryptoCurrency>,
|
||||
val hiddenTokensCount: Int = 0,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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 com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
|
||||
import java.math.BigDecimal
|
||||
|
|
@ -14,41 +15,41 @@ import com.tangem.domain.qrscanning.models.QrSendTarget
|
|||
class ResolveQrSendTargetsUseCase(
|
||||
private val multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val qrScanningEventsRepository: QrScanningEventsRepository,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(qrCode: String): QrSendTarget {
|
||||
val allAccountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty()
|
||||
val userWallets = userWalletsListRepository.userWalletsSync()
|
||||
val walletNamesMap = userWallets.associate { it.walletId to it.name }
|
||||
|
||||
val currencyEntries = allAccountLists.flatMap { accountList ->
|
||||
accountList.accounts
|
||||
.filterIsInstance<Account.CryptoPortfolio>()
|
||||
.flatMap { account ->
|
||||
account.cryptoCurrencies.map { currency ->
|
||||
currency to CurrencyLocation(
|
||||
userWalletId = accountList.userWalletId,
|
||||
walletName = accountList.userWalletId.stringValue,
|
||||
accountId = account.accountId,
|
||||
accountName = account.accountName,
|
||||
)
|
||||
}
|
||||
val allCurrencies = mutableListOf<CryptoCurrency>()
|
||||
val currencyLocations = mutableMapOf<CryptoCurrency.ID, MutableList<CurrencyLocation>>()
|
||||
val totalPerAccount = mutableMapOf<AccountId, Int>()
|
||||
|
||||
for (accountList in allAccountLists) {
|
||||
for (account in accountList.accounts.filterIsInstance<Account.CryptoPortfolio>()) {
|
||||
val location = CurrencyLocation(
|
||||
walletName = walletNamesMap[account.accountId.userWalletId]
|
||||
?: account.accountId.userWalletId.stringValue,
|
||||
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 portfolioIndex = PortfolioIndex(currencyLocations, totalPerAccount)
|
||||
|
||||
return resolve(classified, currencyLocations)
|
||||
return resolve(classified, portfolioIndex)
|
||||
}
|
||||
|
||||
private fun resolve(
|
||||
classified: ClassifiedQrContent,
|
||||
currencyLocations: Map<CryptoCurrency.ID, List<CurrencyLocation>>,
|
||||
): QrSendTarget {
|
||||
private fun resolve(classified: ClassifiedQrContent, portfolioIndex: PortfolioIndex): QrSendTarget {
|
||||
return when (classified) {
|
||||
is ClassifiedQrContent.WalletConnect -> QrSendTarget.WalletConnect(classified.uri)
|
||||
is ClassifiedQrContent.Unknown -> QrSendTarget.Unknown(classified.raw)
|
||||
|
|
@ -57,14 +58,14 @@ class ResolveQrSendTargetsUseCase(
|
|||
amount = null,
|
||||
memo = null,
|
||||
matchingCurrencies = classified.matchingCurrencies,
|
||||
currencyLocations = currencyLocations,
|
||||
portfolioIndex = portfolioIndex,
|
||||
)
|
||||
is ClassifiedQrContent.PaymentUri -> resolveAddressTarget(
|
||||
address = classified.address,
|
||||
amount = classified.amount,
|
||||
memo = classified.memo,
|
||||
matchingCurrencies = classified.matchingCurrencies,
|
||||
currencyLocations = currencyLocations,
|
||||
portfolioIndex = portfolioIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,9 +75,9 @@ class ResolveQrSendTargetsUseCase(
|
|||
amount: BigDecimal?,
|
||||
memo: String?,
|
||||
matchingCurrencies: List<CryptoCurrency>,
|
||||
currencyLocations: Map<CryptoCurrency.ID, List<CurrencyLocation>>,
|
||||
portfolioIndex: PortfolioIndex,
|
||||
): QrSendTarget {
|
||||
val walletGroups = buildWalletGroups(matchingCurrencies, currencyLocations)
|
||||
val walletGroups = buildWalletGroups(matchingCurrencies, portfolioIndex)
|
||||
|
||||
val singleGroup = walletGroups.singleOrNull()
|
||||
val singleCurrency = singleGroup?.accounts?.singleOrNull()?.currencies?.singleOrNull()
|
||||
|
|
@ -101,15 +102,15 @@ class ResolveQrSendTargetsUseCase(
|
|||
|
||||
private fun buildWalletGroups(
|
||||
matchingCurrencies: List<CryptoCurrency>,
|
||||
currencyLocations: Map<CryptoCurrency.ID, List<CurrencyLocation>>,
|
||||
portfolioIndex: PortfolioIndex,
|
||||
): List<QrSendTarget.Multiple.WalletGroup> {
|
||||
val walletMap = linkedMapOf<UserWalletId, WalletInfo>()
|
||||
val uniqueCurrencies = matchingCurrencies.distinctBy { it.id }
|
||||
|
||||
for (currency in uniqueCurrencies) {
|
||||
val locations = currencyLocations[currency.id] ?: continue
|
||||
val locations = portfolioIndex.currencyLocations[currency.id].orEmpty()
|
||||
for (location in locations) {
|
||||
val walletInfo = walletMap.getOrPut(location.userWalletId) {
|
||||
val walletInfo = walletMap.getOrPut(location.accountId.userWalletId) {
|
||||
WalletInfo(location.walletName, linkedMapOf())
|
||||
}
|
||||
val accountInfo = walletInfo.accounts.getOrPut(location.accountId) {
|
||||
|
|
@ -124,18 +125,24 @@ class ResolveQrSendTargetsUseCase(
|
|||
userWalletId = walletId,
|
||||
walletName = walletInfo.walletName,
|
||||
accounts = walletInfo.accounts.map { (accountId, accountInfo) ->
|
||||
val total = portfolioIndex.totalPerAccount[accountId] ?: 0
|
||||
QrSendTarget.Multiple.AccountGroup(
|
||||
accountId = accountId,
|
||||
accountName = accountInfo.accountName,
|
||||
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(
|
||||
val userWalletId: UserWalletId,
|
||||
val walletName: String,
|
||||
val accountId: AccountId,
|
||||
val accountName: AccountName,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -281,9 +281,11 @@ internal class SendModel @Inject constructor(
|
|||
}
|
||||
is PredefinedValues.Content.QrCode -> {
|
||||
val predefinedAmount = predefinedValues.amount?.parseBigDecimalOrNull()
|
||||
val amount = predefinedAmount
|
||||
?: (uiState.value.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value
|
||||
?: error("Invalid amount")
|
||||
createTransferTransactionUseCase(
|
||||
amount = predefinedAmount?.convertToSdkAmount(cryptoCurrencyStatus)
|
||||
?: error("Invalid amount"),
|
||||
amount = amount.convertToSdkAmount(cryptoCurrencyStatus),
|
||||
memo = predefinedValues.memo,
|
||||
destination = predefinedValues.address,
|
||||
userWalletId = userWallet.walletId,
|
||||
|
|
@ -422,13 +424,24 @@ internal class SendModel @Inject constructor(
|
|||
feeCryptoCurrencyStatusFlow,
|
||||
) { cryptoCurrencyStatus, feeCryptoCurrencyStatus ->
|
||||
if (isAvailableForSend && currentRoute.value == initialRoute) {
|
||||
router.replaceAll(Confirm)
|
||||
if (isPredefinedAmountExceedsBalance(cryptoCurrencyStatus)) {
|
||||
router.replaceAll(Amount(isEditMode = false))
|
||||
} else {
|
||||
router.replaceAll(Confirm)
|
||||
}
|
||||
} else if (isUnavailableForSend) {
|
||||
showAlertError()
|
||||
}
|
||||
}.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 ||
|
||||
this.value is CryptoCurrencyStatus.Custom ||
|
||||
this.value is CryptoCurrencyStatus.NoQuote
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue