diff --git a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt index 45044583d2..bc33cbeb88 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/QrScanningDomainModule.kt @@ -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, ) } } \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index f60477cf1c..020150535e 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -217,6 +217,7 @@ Add Add to portfolio Add token + Add tokens Added Address All @@ -325,6 +326,7 @@ NFT No No address + No results Not Added Not available Not now @@ -383,6 +385,7 @@ To To %s Today + Token to send %d token %d tokens @@ -395,6 +398,7 @@ I understand I understand, continue There was an error. Please try again. + Unlock Unreachable Unstake 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. @@ -1211,6 +1215,10 @@ Memo: %s Invalid Memo Network fee coverage + + %d token isn\'t compatible with this address + %d tokens aren\'t compatible with this address + Nonce Unique number for each transaction. Use it to resend or cancel a pending transaction. Enter nonce… diff --git a/domain/qr-scanning/build.gradle.kts b/domain/qr-scanning/build.gradle.kts index 564ec92b53..2679da7164 100644 --- a/domain/qr-scanning/build.gradle.kts +++ b/domain/qr-scanning/build.gradle.kts @@ -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) diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt index d802a5f2ec..d0443ffdd7 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/QrSendTarget.kt @@ -35,6 +35,7 @@ sealed class QrSendTarget { val accountId: AccountId, val accountName: AccountName, val currencies: List, + val hiddenTokensCount: Int = 0, ) } diff --git a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt index 2d436c4ceb..2e5cea6b3a 100644 --- a/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt +++ b/domain/qr-scanning/src/main/java/com/tangem/domain/qrscanning/usecases/ResolveQrSendTargetsUseCase.kt @@ -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() - .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() + val currencyLocations = mutableMapOf>() + val totalPerAccount = mutableMapOf() + + for (accountList in allAccountLists) { + for (account in accountList.accounts.filterIsInstance()) { + 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>, - ): 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, - currencyLocations: Map>, + 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, - currencyLocations: Map>, + portfolioIndex: PortfolioIndex, ): List { val walletMap = linkedMapOf() 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>, + val totalPerAccount: Map, + ) + private data class CurrencyLocation( - val userWalletId: UserWalletId, val walletName: String, val accountId: AccountId, val accountName: AccountName, diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NetworkSelectionComponent.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NetworkSelectionComponent.kt new file mode 100644 index 0000000000..3214424d10 --- /dev/null +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NetworkSelectionComponent.kt @@ -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, + val onTokenSelected: (UserWalletId, CryptoCurrency) -> Unit, + val onDismiss: () -> Unit, + ) { + data class WalletGroup( + val userWalletId: UserWalletId, + val walletName: String, + val accounts: List, + ) + + data class AccountGroup( + val accountId: AccountId, + val accountName: AccountName, + val currencies: List, + val hiddenTokensCount: Int = 0, + ) + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/DefaultNetworkSelectionComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/DefaultNetworkSelectionComponent.kt new file mode 100644 index 0000000000..a154ed6312 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/DefaultNetworkSelectionComponent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionFeatureModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionFeatureModule.kt new file mode 100644 index 0000000000..8c099c5bfa --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionFeatureModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionModelModule.kt new file mode 100644 index 0000000000..e1c8e50691 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/di/NetworkSelectionModelModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/entity/NetworkSelectionUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/entity/NetworkSelectionUM.kt new file mode 100644 index 0000000000..680e6b27bf --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/entity/NetworkSelectionUM.kt @@ -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, + val isBalanceHidden: Boolean, +) + +@Immutable +internal data class WalletGroupUM( + val userWalletId: UserWalletId, + val walletName: String, + val isExpanded: Boolean, + val onExpandToggle: () -> Unit, + val accounts: ImmutableList, +) + +@Immutable +internal data class AccountGroupUM( + val accountName: TextReference, + val iconState: CurrencyIconState.CryptoPortfolio?, + val tokens: ImmutableList, + val hiddenTokensCount: Int, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt new file mode 100644 index 0000000000..1f937110c0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt @@ -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 = createUiStateFlow() + + private fun createUiStateFlow(): StateFlow { + 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, + appCurrency: AppCurrency, + statusLists: List, + 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, + appCurrency: AppCurrency, + statusLists: List, + ): 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 { + 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, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/ui/NetworkSelectionScreen.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/ui/NetworkSelectionScreen.kt new file mode 100644 index 0000000000..aae3e17376 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/ui/NetworkSelectionScreen.kt @@ -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, + ) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index db07b4492e..b2cf810c08 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -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