Updated on 2026-08-14

This commit is contained in:
Tangem 2025-06-04 12:04:37 +05:00
parent d98b2d98ed
commit 552ad875b1
8 changed files with 675 additions and 0 deletions

View file

@ -0,0 +1,21 @@
package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
interface ChooseManagedTokensComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val initialCurrency: CryptoCurrency,
val source: Source,
)
enum class Source {
SendViaSwap,
}
interface Factory : ComponentFactory<Params, ChooseManagedTokensComponent>
}

View file

@ -0,0 +1,72 @@
package com.tangem.features.managetokens.choosetoken
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.managetokens.choosetoken.entity.ChooseManageTokensBottomSheetConfig
import com.tangem.features.managetokens.choosetoken.model.ChooseManagedTokensModel
import com.tangem.features.managetokens.choosetoken.ui.ChooseManagedTokenContent
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultChooseManagedTokensComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: ChooseManagedTokensComponent.Params,
) : ChooseManagedTokensComponent, AppComponentContext by context {
private val model: ChooseManagedTokensModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = ChooseManageTokensBottomSheetConfig.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val uiState by model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
ChooseManagedTokenContent(
state = uiState,
)
bottomSheet.child?.instance?.BottomSheet()
}
@Suppress("UnusedPrivateMember")
private fun bottomSheetChild(
config: ChooseManageTokensBottomSheetConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
else -> getStubComponent()
}
private fun getStubComponent() = StubComponent()
class StubComponent : ComposableBottomSheetComponent {
override fun dismiss() {}
@Composable
override fun BottomSheet() {
/* no-op */
}
}
@AssistedFactory
interface Factory : ChooseManagedTokensComponent.Factory {
override fun create(
context: AppComponentContext,
params: ChooseManagedTokensComponent.Params,
): DefaultChooseManagedTokensComponent
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.managetokens.choosetoken.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.managetokens.choosetoken.DefaultChooseManagedTokensComponent
import com.tangem.features.managetokens.choosetoken.model.ChooseManagedTokensModel
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@InstallIn(ModelComponent::class)
@Module
internal interface ChooseManagedTokensModule {
@Binds
@IntoMap
@ClassKey(ChooseManagedTokensModel::class)
fun provideChooseManagedTokensModel(impl: ChooseManagedTokensModel): Model
}
@Module
@InstallIn(SingletonComponent::class)
internal interface ChooseManagedTokensModuleBinds {
@Binds
@Singleton
fun provideChooseManagedTokensComponent(
impl: DefaultChooseManagedTokensComponent.Factory,
): ChooseManagedTokensComponent.Factory
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.managetokens.choosetoken.entity
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.serialization.Serializable
@Serializable
internal sealed class ChooseManageTokensBottomSheetConfig {
@Serializable
data class SwapTokensBottomSheetConfig(
val userWalletId: UserWalletId,
val initialCurrency: CryptoCurrency,
val token: ManagedCryptoCurrency.Token,
) : ChooseManageTokensBottomSheetConfig()
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.managetokens.choosetoken.entity
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM
internal data class ChooseManagedTokenUM(
val notificationUM: NotificationUM?,
val readContent: ManageTokensUM.ReadContent,
)

View file

@ -0,0 +1,270 @@
package com.tangem.features.managetokens.choosetoken.model
import androidx.annotation.StringRes
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.features.managetokens.choosetoken.entity.ChooseManageTokensBottomSheetConfig
import com.tangem.features.managetokens.choosetoken.entity.ChooseManagedTokenUM
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent.Source
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.list.ManageTokensListManager
import com.tangem.features.managetokens.utils.list.getLoadingItems
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.collections.isNotEmpty
@ModelScoped
internal class ChooseManagedTokensModel @Inject constructor(
private val router: Router,
override val dispatchers: CoroutineDispatcherProvider,
private val uiMessageSender: UiMessageSender,
paramsContainer: ParamsContainer,
manageTokensListManagerFactory: ManageTokensListManager.Factory,
) : Model() {
private val params: ChooseManagedTokensComponent.Params = paramsContainer.require()
private val manageTokensListManager = manageTokensListManagerFactory.create(
onCurrencySelect = { token ->
bottomSheetNavigation.activate(
ChooseManageTokensBottomSheetConfig.SwapTokensBottomSheetConfig(
userWalletId = params.userWalletId,
initialCurrency = params.initialCurrency,
token = token,
),
)
},
)
val bottomSheetNavigation: SlotNavigation<ChooseManageTokensBottomSheetConfig> = SlotNavigation()
val uiState: StateFlow<ChooseManagedTokenUM>
field = MutableStateFlow<ChooseManagedTokenUM>(createReadContentModel())
init {
manageTokensListManager.uiItems
.onEach { items -> updateItems(items) }
.launchIn(modelScope)
manageTokensListManager.paginationStatus
.onEach { status -> updatePaginationStatus(status) }
.launchIn(modelScope)
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(source = ManageTokensSource.SEND_VIA_SWAP, userWalletId = null)
}
}
private fun createReadContentModel(): ChooseManagedTokenUM {
return ChooseManagedTokenUM(
notificationUM = getNotification(),
readContent = ManageTokensUM.ReadContent(
popBack = router::pop,
isInitialBatchLoading = true,
isNextBatchLoading = false,
items = getLoadingItems(),
topBar = ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_choose_token),
onBackButtonClick = router::pop,
),
search = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
onQueryChange = ::searchCurrencies,
isActive = false,
onActiveChange = ::toggleSearchBar,
),
loadMore = ::loadMoreItems,
),
)
}
private fun getNotification(): NotificationUM? {
return when (params.source) {
Source.SendViaSwap -> ChooseManagedTokensNotificationUM.SendViaSwap(
onCloseClick = ::removeNotification,
)
}
}
private fun removeNotification() {
uiState.update {
it.copy(
notificationUM = null,
)
}
}
@OptIn(FlowPreview::class)
private fun observeSearchQueryChanges() {
uiState
.distinctUntilChanged { old, new ->
// It's also used to skip search activation to avoid searching an empty query
old.readContent.search.query == new.readContent.search.query && new.readContent.search.isActive
}
.transform { state ->
val query = state.readContent.search.query
if (state.readContent.search.isActive) {
emit(query)
}
}
.sample(periodMillis = 1_000)
.onEach { query -> manageTokensListManager.search(userWalletId = null, query = query) }
.launchIn(modelScope)
}
private fun updateItems(items: ImmutableList<CurrencyItemUM>) {
uiState.update { state ->
state.copy(
readContent = state.readContent.copy(items = items),
)
}
}
private fun consumeScrollToTopEvent() {
uiState.update { state ->
state.copy(
readContent = state.readContent.copy(scrollToTop = consumedEvent()),
)
}
}
private fun updatePaginationStatus(status: PaginationStatus<*>) {
uiState.update { state ->
val readContent = state.readContent
when (status) {
is PaginationStatus.None,
is PaginationStatus.InitialLoading,
-> {
if (readContent.search.isActive) {
state.copy(
readContent = readContent.copy(items = getLoadingItems()),
)
} else {
state.copy(
readContent = readContent.copy(
items = getLoadingItems(),
isInitialBatchLoading = true,
),
)
}
}
is PaginationStatus.NextBatchLoading -> state.copy(
readContent = readContent.copy(isNextBatchLoading = true),
)
is PaginationStatus.InitialLoadingError -> {
val message = SnackbarMessage(
message = status.throwable.localizedMessage
?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
uiMessageSender.send(message)
state.copy(
readContent = readContent.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
),
)
}
is PaginationStatus.Paginating -> {
(status.lastResult as? BatchFetchResult.Error)?.let { fetchError ->
Timber.e(fetchError.throwable)
}
state.copy(
readContent = readContent.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
scrollToTop = if (readContent.isInitialBatchLoading && readContent.items.isNotEmpty()) {
triggeredEvent(
data = Unit,
onConsume = ::consumeScrollToTopEvent,
)
} else {
readContent.scrollToTop
},
),
)
}
is PaginationStatus.EndOfPagination -> {
state.copy(
readContent = readContent.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
),
)
}
}
}
}
private fun searchCurrencies(query: String) {
uiState.update { state ->
state.copy(
readContent = state.readContent.copy(
search = state.readContent.search.copy(
query = query,
isActive = true,
),
),
)
}
}
private fun toggleSearchBar(isActive: Boolean) {
uiState.update { state ->
@StringRes val placeholderTextRes = if (isActive) {
R.string.manage_tokens_search_placeholder
} else {
R.string.common_search
}
state.copy(
readContent = state.readContent.copy(
search = state.readContent.search.copy(
placeholderText = resourceReference(placeholderTextRes),
isActive = isActive,
),
),
)
}
}
private fun loadMoreItems(): Boolean {
val state = uiState.value
if (state.readContent.isInitialBatchLoading || state.readContent.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(userWalletId = null, query = state.readContent.search.query)
}
return true
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.managetokens.choosetoken.model
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.managetokens.impl.R
internal object ChooseManagedTokensNotificationUM {
data class SendViaSwap(
val onCloseClick: () -> Unit,
) : NotificationUM.Info(
title = resourceReference(R.string.send_with_swap_title),
subtitle = resourceReference(R.string.send_with_swap_notification_text),
iconResId = R.drawable.ic_exchange_horizontal_24,
onCloseClick = onCloseClick,
)
}

View file

@ -0,0 +1,234 @@
package com.tangem.features.managetokens.choosetoken.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.rows.ChainRow
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.features.managetokens.choosetoken.entity.ChooseManagedTokenUM
import com.tangem.features.managetokens.choosetoken.model.ChooseManagedTokensNotificationUM
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.*
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@Composable
internal fun ChooseManagedTokenContent(state: ChooseManagedTokenUM, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.tertiary,
contentWindowInsets = WindowInsetsZero,
topBar = {
ManageTokensTopBar(
modifier = Modifier.statusBarsPadding(),
topBar = state.readContent.topBar,
search = state.readContent.search,
)
},
content = { innerPadding ->
Content(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
state = state,
)
},
)
}
@Composable
private fun Content(state: ChooseManagedTokenUM, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
Box(modifier = modifier) {
Currencies(
modifier = Modifier.fillMaxSize(),
listState = listState,
notificationUM = state.notificationUM,
items = state.readContent.items,
showLoadingItem = state.readContent.isNextBatchLoading,
onLoadMore = state.readContent.loadMore,
)
}
EventEffect(event = state.readContent.scrollToTop) {
listState.animateScrollToItem(index = 0)
}
}
@Composable
private fun Currencies(
listState: LazyListState,
notificationUM: NotificationUM?,
items: ImmutableList<CurrencyItemUM>,
showLoadingItem: Boolean,
onLoadMore: () -> Boolean,
modifier: Modifier = Modifier,
) {
val bottomBarHeight = with(LocalDensity.current) {
WindowInsets.systemBars.getBottom(density = this).toDp()
}
LazyColumn(
modifier = modifier.padding(horizontal = 16.dp),
state = listState,
contentPadding = PaddingValues(
bottom = TangemTheme.dimens.spacing76 + bottomBarHeight,
),
) {
if (notificationUM?.config != null) {
item(key = "notification_key") {
Notification(
config = notificationUM.config,
iconTint = TangemTheme.colors.icon.accent,
modifier = Modifier
.animateItem()
.padding(bottom = 12.dp),
)
}
}
contentItems(items)
if (showLoadingItem) {
item(key = "loading_item") {
ProgressIndicator(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
}
}
InfiniteListHandler(
listState = listState,
buffer = LOAD_ITEMS_BUFFER,
onLoadMore = onLoadMore,
)
}
private fun LazyListScope.contentItems(items: ImmutableList<CurrencyItemUM>) {
itemsIndexed(
items = items,
key = { index, item -> item.id.value },
) { index, item ->
when (item) {
is CurrencyItemUM.Basic -> {
ChainRow(
model = with(item) {
ChainRowUM(
name = name,
type = symbol,
icon = icon,
showCustom = false,
)
},
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
addDefaultPadding = false,
lastIndex = items.lastIndex,
)
.clickable(onClick = item.onExpandClick)
.background(TangemTheme.colors.background.action),
)
}
is CurrencyItemUM.Loading -> {
LoadingItem(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
addDefaultPadding = false,
lastIndex = items.lastIndex,
)
.fillMaxWidth()
.background(TangemTheme.colors.background.action),
)
}
is CurrencyItemUM.SearchNothingFound -> {
SearchNothingFoundText(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
addDefaultPadding = false,
lastIndex = items.lastIndex,
)
.background(TangemTheme.colors.background.action)
.fillParentMaxSize(),
)
}
else -> Unit
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun ChooseManagedTokenContent_Preview() {
TangemThemePreview {
ChooseManagedTokenContent(
state = ChooseManagedTokenUM(
notificationUM = ChooseManagedTokensNotificationUM.SendViaSwap({}),
readContent = ManageTokensUM.ReadContent(
popBack = {},
isInitialBatchLoading = false,
isNextBatchLoading = false,
items = buildList {
repeat(10) {
add(
CurrencyItemUM.Basic(
id = ManagedCryptoCurrency.ID(
value = "ID+$it",
),
name = "Bitcoin",
symbol = "BTC",
icon = CurrencyIconState.Loading,
networks = CurrencyItemUM.Basic.NetworksUM.Collapsed,
onExpandClick = {},
),
)
}
}.toPersistentList(),
topBar = ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_choose_token),
onBackButtonClick = {},
),
search = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = {},
),
loadMore = { true },
),
),
)
}
}
// endregion