diff --git a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt new file mode 100644 index 0000000000..e94429b646 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt @@ -0,0 +1,23 @@ +package com.tangem.tap.di.domain + +import com.tangem.feature.swap.domain.GetAvailablePairsUseCase +import com.tangem.feature.swap.domain.api.SwapRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** +[REDACTED_AUTHOR] + */ +@Module +@InstallIn(SingletonComponent::class) +internal object SwapDomainModule { + + @Provides + @Singleton + fun provideGetAvailablePairsUseCase(swapRepository: SwapRepository): GetAvailablePairsUseCase { + return GetAvailablePairsUseCase(swapRepository = swapRepository) + } +} \ No newline at end of file diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index be47292f09..0aafc9b959 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -14,6 +14,9 @@ android { dependencies { /** Project - API */ implementation(projects.features.onramp.api) + implementation(projects.features.swap.domain) + implementation(projects.features.swap.domain.api) + implementation(projects.features.swap.domain.models) /** Project - Core */ implementation(projects.core.decompose) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt new file mode 100644 index 0000000000..93b1be67aa --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt @@ -0,0 +1,30 @@ +package com.tangem.features.onramp.swap.availablepairs + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.StateFlow + +/** Token list component that present list of available tokens for swap */ +@Stable +internal interface AvailableSwapPairsComponent : ComposableContentComponent { + + /** Component factory */ + interface Factory : ComponentFactory + + /** + * Params + * + * @property userWalletId id of multi-currency wallet + * @property selectedStatus flow of selected status + * @property onTokenClick callback for token click + */ + data class Params( + val userWalletId: UserWalletId, + val selectedStatus: StateFlow, + val onTokenClick: (TokenItemState, CryptoCurrencyStatus) -> Unit, + ) +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt new file mode 100644 index 0000000000..fd77ee062f --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt @@ -0,0 +1,38 @@ +package com.tangem.features.onramp.swap.availablepairs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel +import com.tangem.features.onramp.tokenlist.ui.TokenList +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: AvailableSwapPairsComponent.Params, +) : AvailableSwapPairsComponent, AppComponentContext by context { + + private val model: AvailableSwapPairsModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + TokenList(state = state, modifier = modifier) + } + + @AssistedFactory + interface Factory : AvailableSwapPairsComponent.Factory { + override fun create( + context: AppComponentContext, + params: AvailableSwapPairsComponent.Params, + ): DefaultAvailableSwapPairsComponent + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsComponentModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsComponentModule.kt new file mode 100644 index 0000000000..0f451e0008 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onramp.swap.availablepairs.di + +import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent +import com.tangem.features.onramp.swap.availablepairs.DefaultAvailableSwapPairsComponent +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 AvailableSwapPairsComponentModule { + + @Binds + @Singleton + fun bindAvailableSwapPairsComponentFactory( + factory: DefaultAvailableSwapPairsComponent.Factory, + ): AvailableSwapPairsComponent.Factory +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsModelModule.kt new file mode 100644 index 0000000000..6bf7c67133 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onramp.swap.availablepairs.di + +import com.tangem.core.decompose.di.DecomposeComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(DecomposeComponent::class) +internal interface AvailableSwapPairsModelModule { + + @Binds + @IntoMap + @ClassKey(AvailableSwapPairsModel::class) + fun bindAvailableSwapPairsModel(model: AvailableSwapPairsModel): Model +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingTokenListItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingTokenListItemConverter.kt new file mode 100644 index 0000000000..056bbaf84f --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingTokenListItemConverter.kt @@ -0,0 +1,26 @@ +package com.tangem.features.onramp.swap.availablepairs.entity.converters + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.utils.converter.Converter + +/** + * Convert from [CryptoCurrencyStatus] to loading [TokensListItemUM.Token] state + * +[REDACTED_AUTHOR] + */ +internal object LoadingTokenListItemConverter : Converter { + + override fun convert(value: CryptoCurrencyStatus): TokensListItemUM.Token { + return TokensListItemUM.Token( + state = TokenItemState.Loading( + id = value.currency.id.value, + iconState = CurrencyIconState.Loading, + titleState = TokenItemState.TitleState.Loading, + subtitleState = TokenItemState.SubtitleState.Loading, + ), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetLoadingTokenItemsTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetLoadingTokenItemsTransformer.kt new file mode 100644 index 0000000000..b14b3a023e --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetLoadingTokenItemsTransformer.kt @@ -0,0 +1,23 @@ +package com.tangem.features.onramp.swap.availablepairs.entity.transformers + +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingTokenListItemConverter +import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer +import kotlinx.collections.immutable.toImmutableList + +/** + * Set [statuses] as loading items + * +[REDACTED_AUTHOR] + */ +internal class SetLoadingTokenItemsTransformer( + private val statuses: List, +) : TokenListUMTransformer { + + override fun transform(prevState: TokenListUM): TokenListUM { + return prevState.copy( + availableItems = LoadingTokenListItemConverter.convertList(input = statuses).toImmutableList(), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt new file mode 100644 index 0000000000..b70a8135b2 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -0,0 +1,174 @@ +package com.tangem.features.onramp.swap.availablepairs.model + +import arrow.core.getOrElse +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.core.utils.getOrElse +import com.tangem.domain.tokens.GetTokenListUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.TokenList +import com.tangem.feature.swap.domain.GetAvailablePairsUseCase +import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo +import com.tangem.feature.swap.domain.models.domain.SwapPairLeast +import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent +import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetLoadingTokenItemsTransformer +import com.tangem.features.onramp.tokenlist.entity.TokenListUM +import com.tangem.features.onramp.tokenlist.entity.TokenListUMController +import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer +import com.tangem.features.onramp.utils.SearchManager +import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer +import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Suppress("LongParameterList") +internal class AvailableSwapPairsModel @Inject constructor( + paramsContainer: ParamsContainer, + getTokenListUseCase: GetTokenListUseCase, + override val dispatchers: CoroutineDispatcherProvider, + private val tokenListUMController: TokenListUMController, + private val searchManager: SearchManager, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getAvailablePairsUseCase: GetAvailablePairsUseCase, +) : Model() { + + val state: StateFlow = tokenListUMController.state + + private var params: AvailableSwapPairsComponent.Params = paramsContainer.require() + + private val tokenListFlow = getTokenListUseCase.launch(userWalletId = params.userWalletId) + .distinctUntilChanged() + .map { maybeTokenList -> + maybeTokenList.getOrElse( + ifLoading = { it ?: TokenList.Empty }, + ifError = { TokenList.Empty }, + ) + .flattenCurrencies() + } + .shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1) + + private val availablePairsByNetworkFlow = MutableStateFlow>>(emptyMap()) + + init { + subscribeOnUpdateState() + subscribeOnAvailablePairsUpdates() + } + + private fun subscribeOnUpdateState() { + combine( + flow = tokenListFlow, + flow2 = getSelectedAppCurrencyUseCase().map { it.getOrElse { AppCurrency.Default } }.distinctUntilChanged(), + flow3 = getBalanceHidingSettingsUseCase().map { it.isBalanceHidden }.distinctUntilChanged(), + flow4 = searchManager.query, + flow5 = availablePairsByNetworkFlow + .map { it[params.selectedStatus.value?.toLeastTokenInfo()].orEmpty() } + .distinctUntilChanged(), + ) { currencies, appCurrency, isBalanceHidden, query, availablePairs -> + if (availablePairs.isEmpty()) { + SetLoadingTokenItemsTransformer(currencies) + } else { + val filterTokenList = currencies + .filterByQuery(query = query) + .filterByAvailability(availablePairs = availablePairs) + + Timber.e("${filterTokenList[true].orEmpty().size}") + + UpdateTokenItemsTransformer( + appCurrency = appCurrency, + onItemClick = params.onTokenClick, + statuses = filterTokenList, + isBalanceHidden = isBalanceHidden, + hasSearchBar = currencies.isNotEmpty(), + onQueryChange = ::onSearchQueryChange, + onActiveChange = ::onSearchBarActiveChange, + ) + } + } + .onEach(tokenListUMController::update) + .flowOn(dispatchers.main) + .launchIn(modelScope) + } + + private fun subscribeOnAvailablePairsUpdates() { + modelScope.launch { + params.selectedStatus + .filterNotNull() + .collectLatest { selectedStatus -> + val initialCurrency = selectedStatus.toLeastTokenInfo() + val tokenList = tokenListFlow.firstOrNull() ?: return@collectLatest + + val availablePairs = availablePairsByNetworkFlow.value[initialCurrency] + if (!availablePairs.isNullOrEmpty()) return@collectLatest + + val pairs = getAvailablePairsUseCase( + initialCurrency = initialCurrency, + currencies = tokenList.map(CryptoCurrencyStatus::currency), + ) + + Timber.e("$initialCurrency = ${pairs.size}") + + availablePairsByNetworkFlow.update { + it.toMutableMap().apply { + put(initialCurrency, pairs) + } + } + } + } + } + + private fun onSearchQueryChange(newQuery: String) { + val searchBar = tokenListUMController.getSearchBar() + if (searchBar?.searchBarUM?.query == newQuery) return + + modelScope.launch { + tokenListUMController.update(transformer = UpdateSearchQueryTransformer(newQuery)) + + searchManager.update(newQuery) + } + } + + private fun onSearchBarActiveChange(isActive: Boolean) { + tokenListUMController.update( + transformer = UpdateSearchBarActiveStateTransformer( + isActive = isActive, + placeHolder = resourceReference(id = R.string.common_search), + ), + ) + } + + private fun List.filterByQuery(query: String): List { + return filter { + it.currency.name.contains(other = query, ignoreCase = true) || + it.currency.symbol.contains(other = query, ignoreCase = true) + } + } + + private fun List.filterByAvailability( + availablePairs: List, + ): Map> { + return groupBy { status -> + val isAvailable = availablePairs.map(SwapPairLeast::to).contains(status.toLeastTokenInfo()) + + isAvailable && + status.value !is CryptoCurrencyStatus.MissedDerivation && + status.value !is CryptoCurrencyStatus.Unreachable + } + } + + private fun CryptoCurrencyStatus.toLeastTokenInfo(): LeastTokenInfo { + return LeastTokenInfo( + contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", + network = currency.network.backendId, + ) + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt new file mode 100644 index 0000000000..113c0ca915 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt @@ -0,0 +1,18 @@ +package com.tangem.feature.swap.domain + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.feature.swap.domain.api.SwapRepository +import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo +import com.tangem.feature.swap.domain.models.domain.SwapPairLeast + +class GetAvailablePairsUseCase( + private val swapRepository: SwapRepository, +) { + + suspend operator fun invoke( + initialCurrency: LeastTokenInfo, + currencies: List, + ): List { + return swapRepository.getPairsOnly(initialCurrency, currencies).pairs + } +} \ No newline at end of file