diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt index d50f9f295c..6e5a872f02 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt @@ -1,6 +1,9 @@ package com.tangem.utils.coroutines +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** * Job holder. It is automatically finished old job if new one is started @@ -26,4 +29,13 @@ class JobHolder { fun Job.saveIn(jobHolder: JobHolder): Job = jobHolder.update(job = this) -suspend fun Job.saveInAndJoin(jobHolder: JobHolder) = saveIn(jobHolder).join() \ No newline at end of file +suspend fun Job.saveInAndJoin(jobHolder: JobHolder) = saveIn(jobHolder).join() + +fun CoroutineScope.withDebounce(jobHolder: JobHolder, timeMillis: Long = 800L, function: () -> Unit) { + launch { + delay(timeMillis = timeMillis) + + function() + } + .saveIn(jobHolder) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/DefaultTokenListComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/DefaultTokenListComponent.kt new file mode 100644 index 0000000000..c74231c846 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/DefaultTokenListComponent.kt @@ -0,0 +1,35 @@ +package com.tangem.feature.wallet.presentation.tokenlist + +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.feature.wallet.presentation.tokenlist.model.TokenListModel +import com.tangem.feature.wallet.presentation.tokenlist.ui.TokenList +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultTokenListComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: TokenListComponent.Params, +) : TokenListComponent, AppComponentContext by context { + + private val model: TokenListModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + TokenList(state = state, modifier = modifier) + } + + @AssistedFactory + interface Factory : TokenListComponent.Factory { + override fun create(context: AppComponentContext, params: TokenListComponent.Params): DefaultTokenListComponent + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/TokenListComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/TokenListComponent.kt new file mode 100644 index 0000000000..3c271c0894 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/TokenListComponent.kt @@ -0,0 +1,28 @@ +package com.tangem.feature.wallet.presentation.tokenlist + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWalletId + +/** Token list component that present list of token for multi-currency wallet */ +@Stable +internal interface TokenListComponent : ComposableContentComponent { + + /** Component factory */ + interface Factory : ComponentFactory + + /** + * Params + * + * @property hasSearchBar flag that indicates if search bar should be shown + * @property userWalletId id of multi-currency wallet + * @property onTokenClick callback for token click + */ + data class Params( + val hasSearchBar: Boolean, + val userWalletId: UserWalletId, + val onTokenClick: (CryptoCurrencyStatus) -> Unit, + ) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/di/TokenListComponentModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/di/TokenListComponentModule.kt new file mode 100644 index 0000000000..1e2672c2af --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/di/TokenListComponentModule.kt @@ -0,0 +1,18 @@ +package com.tangem.feature.wallet.presentation.tokenlist.di + +import com.tangem.feature.wallet.presentation.tokenlist.DefaultTokenListComponent +import com.tangem.feature.wallet.presentation.tokenlist.TokenListComponent +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 TokenListComponentModule { + + @Binds + @Singleton + fun bindTokenListComponentFactory(factory: DefaultTokenListComponent.Factory): TokenListComponent.Factory +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/di/TokenListModelModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/di/TokenListModelModule.kt new file mode 100644 index 0000000000..732340f818 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/di/TokenListModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.feature.wallet.presentation.tokenlist.di + +import com.tangem.core.decompose.di.DecomposeComponent +import com.tangem.core.decompose.model.Model +import com.tangem.feature.wallet.presentation.tokenlist.model.TokenListModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(DecomposeComponent::class) +internal interface TokenListModelModule { + + @Binds + @IntoMap + @ClassKey(TokenListModel::class) + fun provideTokenListModel(model: TokenListModel): Model +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/TokenListUMController.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/TokenListUMController.kt index 6e3f7a5c6a..04592b5ed0 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/TokenListUMController.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/TokenListUMController.kt @@ -1,8 +1,5 @@ package com.tangem.feature.wallet.presentation.tokenlist.entity -import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState.TokensListItemState import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow @@ -22,7 +19,7 @@ internal class TokenListUMController @Inject constructor() { private val _state: MutableStateFlow = MutableStateFlow( value = TokenListUM( - items = persistentListOf(createInitialSearchBar()), + items = persistentListOf(), isBalanceHidden = false, ), ) @@ -38,17 +35,7 @@ internal class TokenListUMController @Inject constructor() { } /** Get search bar if it exists */ - fun getSearchBar(): TokensListItemState.SearchBar? = _state.value.getSearchBar() - - private fun createInitialSearchBar(): TokensListItemState.SearchBar { - return TokensListItemState.SearchBar( - searchBarUM = SearchBarUM( - placeholderText = resourceReference(id = R.string.common_search), - query = "", - onQueryChange = {}, - isActive = false, - onActiveChange = {}, - ), - ) + fun getSearchBar(): TokensListItemState.SearchBar? { + return _state.value.getSearchBar() } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/transformer/UpdateSearchBarIntentsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/transformer/UpdateSearchBarIntentsTransformer.kt deleted file mode 100644 index de58815cd1..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/transformer/UpdateSearchBarIntentsTransformer.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.feature.wallet.presentation.tokenlist.entity.transformer - -import com.tangem.core.ui.components.fields.entity.SearchBarUM - -internal class UpdateSearchBarIntentsTransformer( - private val onQueryChange: (String) -> Unit, - private val onActiveChange: (Boolean) -> Unit, -) : SearchBarUMTransformer() { - - override fun transform(prevState: SearchBarUM): SearchBarUM { - return prevState.copy(onQueryChange = onQueryChange, onActiveChange = onActiveChange) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/transformer/UpdateTokenItemsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/transformer/UpdateTokenItemsTransformer.kt index 89d171d3f9..ed4dcf5c9e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/transformer/UpdateTokenItemsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/entity/transformer/UpdateTokenItemsTransformer.kt @@ -1,28 +1,48 @@ package com.tangem.feature.wallet.presentation.tokenlist.entity.transformer import com.tangem.common.ui.tokens.TokenItemStateConverter -import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.tokenlist.entity.TokenListUM import com.tangem.feature.wallet.presentation.tokenlist.entity.TokenListUMTransformer import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState.TokensListItemState import kotlinx.collections.immutable.toImmutableList internal class UpdateTokenItemsTransformer( - appCurrency: AppCurrency, - onItemClick: (CryptoCurrencyStatus) -> Unit, + private val tokenItemStateConverter: TokenItemStateConverter, private val statuses: List, private val isBalanceHidden: Boolean, + private val hasSearchBar: Boolean, + private val onQueryChange: (String) -> Unit, + private val onActiveChange: (Boolean) -> Unit, ) : TokenListUMTransformer { - private val converter = TokenItemStateConverter(appCurrency = appCurrency, onItemClick = onItemClick) - override fun transform(prevState: TokenListUM): TokenListUM { - val items = converter.convertList(input = statuses).map(TokensListItemState::Token) + val items = tokenItemStateConverter.convertList(input = statuses).map(TokensListItemState::Token) + + val searchBarItem = if (hasSearchBar) { + prevState.getSearchBar() ?: createSearchBarItem() + } else { + null + } return prevState.copy( - items = (listOfNotNull(prevState.getSearchBar()) + items).toImmutableList(), + items = (listOfNotNull(searchBarItem) + items).toImmutableList(), isBalanceHidden = isBalanceHidden, ) } + + private fun createSearchBarItem(): TokensListItemState.SearchBar { + return TokensListItemState.SearchBar( + searchBarUM = SearchBarUM( + placeholderText = resourceReference(id = R.string.common_search), + query = "", + onQueryChange = onQueryChange, + isActive = false, + onActiveChange = onActiveChange, + ), + ) + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/model/TokenListModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/model/TokenListModel.kt new file mode 100644 index 0000000000..fb13ef8d9d --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/model/TokenListModel.kt @@ -0,0 +1,105 @@ +package com.tangem.feature.wallet.presentation.tokenlist.model + +import arrow.core.getOrElse +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +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.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.TokenList +import com.tangem.feature.wallet.presentation.tokenlist.TokenListComponent +import com.tangem.feature.wallet.presentation.tokenlist.entity.TokenListUM +import com.tangem.feature.wallet.presentation.tokenlist.entity.TokenListUMController +import com.tangem.feature.wallet.presentation.tokenlist.entity.transformer.UpdateSearchBarActiveStateTransformer +import com.tangem.feature.wallet.presentation.tokenlist.entity.transformer.UpdateSearchQueryTransformer +import com.tangem.feature.wallet.presentation.tokenlist.entity.transformer.UpdateTokenItemsTransformer +import com.tangem.feature.wallet.presentation.tokenlist.utils.SearchTokensManager +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +internal class TokenListModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val tokenListUMController: TokenListUMController, + private val searchTokensManager: SearchTokensManager, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getTokenListUseCase: GetTokenListUseCase, +) : Model() { + + val state: StateFlow = tokenListUMController.state + + private val params: TokenListComponent.Params = paramsContainer.require() + + init { + subscribeOnUpdateState() + } + + private fun subscribeOnUpdateState() { + combine( + flow = getTokenListUseCase.launch(userWalletId = params.userWalletId).distinctUntilChanged(), + flow2 = getSelectedAppCurrencyUseCase().map { it.getOrElse { AppCurrency.Default } }.distinctUntilChanged(), + flow3 = getBalanceHidingSettingsUseCase().map { it.isBalanceHidden }.distinctUntilChanged(), + flow4 = searchTokensManager.query, + ) { maybeTokenList, appCurrency, isBalanceHidden, query -> + val currencies = maybeTokenList.getOrElse( + ifLoading = { it ?: TokenList.Empty }, + ifError = { TokenList.Empty }, + ) + .flattenCurrencies() + + val filterTokenList = currencies + .filterByQuery(query = query) + .filterByAvailability() + + UpdateTokenItemsTransformer( + tokenItemStateConverter = TokenItemStateConverter( + appCurrency = appCurrency, + onItemClick = params.onTokenClick, + ), + statuses = filterTokenList, + isBalanceHidden = isBalanceHidden, + hasSearchBar = params.hasSearchBar && currencies.isNotEmpty(), + onQueryChange = ::onSearchQueryChange, + onActiveChange = ::onSearchBarActiveChange, + ) + } + .onEach(tokenListUMController::update) + .flowOn(dispatchers.main) + .launchIn(modelScope) + } + + private fun onSearchQueryChange(newQuery: String) { + val searchBar = tokenListUMController.getSearchBar() + if (searchBar?.searchBarUM?.query == newQuery) return + + modelScope.launch { + tokenListUMController.update(transformer = UpdateSearchQueryTransformer(newQuery)) + + searchTokensManager.update(newQuery) + } + } + + private fun onSearchBarActiveChange(isActive: Boolean) { + tokenListUMController.update(transformer = UpdateSearchBarActiveStateTransformer(isActive)) + } + + 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) + } + } + + // TODO: [REDACTED_JIRA] + private fun List.filterByAvailability(): List { + return this + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/ui/TokenList.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/ui/TokenList.kt new file mode 100644 index 0000000000..e780c03e44 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/ui/TokenList.kt @@ -0,0 +1,64 @@ +package com.tangem.feature.wallet.presentation.tokenlist.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.feature.wallet.presentation.tokenlist.entity.TokenListUM +import com.tangem.feature.wallet.presentation.tokenlist.ui.preview.PreviewTokenListUMProvider +import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.MultiCurrencyContentItem + +/** + * Token list + * + * @param state state + * @param modifier modifier + * +[REDACTED_AUTHOR] + */ +@Composable +internal fun TokenList(state: TokenListUM, modifier: Modifier = Modifier) { + LazyColumn(modifier = modifier) { + itemsIndexed( + items = state.items, + key = { _, item -> item.id }, + contentType = { _, item -> item::class.java }, + itemContent = { index, item -> + MultiCurrencyContentItem( + state = item, + isBalanceHidden = state.isBalanceHidden, + modifier = Modifier + .animateItem() + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + addDefaultPadding = false, + ), + ) + }, + ) + } +} + +@Preview +@Composable +private fun Preview_TokenList(@PreviewParameter(PreviewTokenListUMProvider::class) state: TokenListUM) { + TangemThemePreview { + TokenList( + state = state, + modifier = Modifier + .fillMaxWidth() + .background(color = TangemTheme.colors.background.secondary) + .padding(16.dp), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/ui/preview/PreviewTokenListUMProvider.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/ui/preview/PreviewTokenListUMProvider.kt new file mode 100644 index 0000000000..4a0b5ad769 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/ui/preview/PreviewTokenListUMProvider.kt @@ -0,0 +1,62 @@ +package com.tangem.feature.wallet.presentation.tokenlist.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.feature.wallet.impl.R +import com.tangem.feature.wallet.presentation.tokenlist.entity.TokenListUM +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState.TokensListItemState +import kotlinx.collections.immutable.toImmutableList + +internal class PreviewTokenListUMProvider : PreviewParameterProvider { + + override val values: Sequence = sequenceOf( + createTokensList(hasSearchBar = false, createDefaultTokenItem()), + createTokensList(hasSearchBar = true, createDefaultTokenItem()), + ) + + private fun createTokensList(hasSearchBar: Boolean, vararg items: TokensListItemState): TokenListUM { + return TokenListUM( + items = buildList { + if (hasSearchBar) { + TokensListItemState.SearchBar( + searchBarUM = SearchBarUM( + placeholderText = resourceReference(id = R.string.common_search), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + ), + ) + .let { add(element = it) } + } + + addAll(items) + } + .toImmutableList(), + isBalanceHidden = false, + ) + } + + private fun createDefaultTokenItem(): TokensListItemState.Token { + return TokensListItemState.Token( + state = TokenItemState.Content( + id = "1", + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = "Bitcoin"), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "12 368,14 \$"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "0,35853044 BTC"), + subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( + price = "34 496,75 \$", + priceChangePercent = "0,43 %", + type = PriceChangeType.DOWN, + ), + onItemClick = {}, + onItemLongClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/utils/SearchTokensManager.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/utils/SearchTokensManager.kt new file mode 100644 index 0000000000..98258cca19 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/tokenlist/utils/SearchTokensManager.kt @@ -0,0 +1,29 @@ +package com.tangem.feature.wallet.presentation.tokenlist.utils + +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.withDebounce +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + +/** + * Search tokens manager + * +[REDACTED_AUTHOR] + */ +internal class SearchTokensManager @Inject constructor() { + + val query: Flow + get() = _query + + private val _query = MutableStateFlow(value = "") + + private val jobHolder = JobHolder() + + suspend fun update(value: String) { + coroutineScope { + withDebounce(jobHolder) { _query.value = value } + } + } +} \ No newline at end of file