Updated on 2026-08-14

This commit is contained in:
Tangem 2024-10-04 17:29:01 +05:00
parent 7228c72553
commit 5b60d94e60
29 changed files with 1034 additions and 110 deletions

View file

@ -0,0 +1,12 @@
package com.tangem.features.managetokens.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWalletId
interface OnboardingManageTokensComponent : ComposableContentComponent {
data class Params(val userWalletId: UserWalletId)
interface Factory : ComponentFactory<Params, OnboardingManageTokensComponent>
}

View file

@ -23,8 +23,9 @@ dependencies {
implementation(projects.core.analytics)
/* Project - Domain */
implementation(projects.domain.manageTokens)
implementation(projects.domain.card)
implementation(projects.domain.legacy)
implementation(projects.domain.manageTokens)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
@ -49,4 +50,5 @@ dependencies {
implementation(deps.kotlin.immutable.collections)
implementation(deps.decompose.ext.compose)
implementation(deps.timber)
implementation(deps.reKotlin) // need for legacy onboarding
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.managetokens.component.impl
import androidx.compose.runtime.Composable
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.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.model.OnboardingManageTokensModel
import com.tangem.features.managetokens.ui.OnboardingManageTokensContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultOnboardingManageTokensComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: OnboardingManageTokensComponent.Params,
) : OnboardingManageTokensComponent, AppComponentContext by context {
private val model: OnboardingManageTokensModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
OnboardingManageTokensContent(modifier = modifier, state = state)
}
@AssistedFactory
interface Factory : OnboardingManageTokensComponent.Factory {
override fun create(
context: AppComponentContext,
params: OnboardingManageTokensComponent.Params,
): DefaultOnboardingManageTokensComponent
}
}

View file

@ -26,7 +26,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
internal class PreviewManageTokensComponent(
private val isLoading: Boolean = false,
private val isLoading: Boolean,
params: ManageTokensComponent.Params,
) : ManageTokensComponent {
private val changedItemsIds: MutableSet<String> = mutableSetOf()
@ -36,14 +37,21 @@ internal class PreviewManageTokensComponent(
value = ManageTokensUM.ManageContent(
popBack = {},
items = items,
topBar = ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = {},
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_plus_24,
onIconClicked = {},
),
),
topBar = if (params.userWalletId != null) {
ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = {},
endButton = TopAppBarButtonUM(
iconRes = R.drawable.ic_plus_24,
onIconClicked = {},
),
)
} else {
ManageTokensTopBarUM.ReadContent(
title = resourceReference(R.string.common_search_tokens),
onBackButtonClick = {},
)
},
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",

View file

@ -0,0 +1,114 @@
package com.tangem.features.managetokens.component.preview
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM
import com.tangem.features.managetokens.entity.managetokens.OnboardingManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.OnboardingManageTokensContent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
internal class PreviewOnboardingManageTokensComponent(
private val isLoading: Boolean = false,
) : OnboardingManageTokensComponent {
private val state = OnboardingManageTokensUM(
items = initItems(),
isInitialBatchLoading = false,
isNextBatchLoading = true,
loadMore = { false },
onBack = {},
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
),
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Later(
onClick = {},
showProgress = false,
),
)
@Composable
override fun Content(modifier: Modifier) {
OnboardingManageTokensContent(state = state, modifier = modifier)
}
private fun initItems() = List(size = 30) { index ->
if (isLoading) {
CurrencyItemUM.Loading(index)
} else {
if (index < 2) {
getCustomItem(index)
} else {
getBasicItem(index)
}
}
}.toPersistentList()
private fun getCustomItem(index: Int) = CurrencyItemUM.Custom(
id = ManagedCryptoCurrency.ID(index.toString()),
name = "Custom token $index",
symbol = "CT$index",
icon = CurrencyIconState.CustomTokenIcon(
tint = Color.White,
background = Color.Black,
topBadgeIconResId = R.drawable.img_eth_22,
isGrayscale = false,
showCustomBadge = true,
),
onRemoveClick = {},
)
private fun getBasicItem(index: Int) = CurrencyItemUM.Basic(
id = ManagedCryptoCurrency.ID(index.toString()),
name = "Currency $index",
symbol = "C$index",
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_btc_22,
isGrayscale = false,
showCustomBadge = false,
),
networks = if (index == 2) {
CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks())
} else {
CurrencyItemUM.Basic.NetworksUM.Collapsed
},
onExpandClick = {},
)
private fun getCurrencyNetworks() = List(size = 3) { networkIndex ->
CurrencyNetworkUM(
network = Network(
id = Network.ID(networkIndex.toString()),
backendId = networkIndex.toString(),
name = "Network $networkIndex",
currencySymbol = "N$networkIndex",
derivationPath = Network.DerivationPath.Card(""),
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = false,
canHandleTokens = false,
),
name = "NETWORK$networkIndex",
type = "N$networkIndex",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = networkIndex == 0,
isSelected = false,
onSelectedStateChange = {},
onLongClick = {},
)
}.toImmutableList()
}

View file

@ -16,6 +16,12 @@ internal interface ComponentModule {
@Singleton
fun bindManageTokensComponentFactory(factory: DefaultManageTokensComponent.Factory): ManageTokensComponent.Factory
@Binds
@Singleton
fun bindOnboardingManageTokensComponentFactory(
factory: DefaultOnboardingManageTokensComponent.Factory,
): OnboardingManageTokensComponent.Factory
@Binds
@Singleton
fun bindAddCustomTokenComponentFactory(

View file

@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.features.managetokens.model.CustomTokenFormModel
import com.tangem.features.managetokens.model.CustomTokenSelectorModel
import com.tangem.features.managetokens.model.ManageTokensModel
import com.tangem.features.managetokens.model.OnboardingManageTokensModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -20,6 +21,11 @@ internal interface ModelModule {
@ClassKey(ManageTokensModel::class)
fun provideManageTokensModel(model: ManageTokensModel): Model
@Binds
@IntoMap
@ClassKey(OnboardingManageTokensModel::class)
fun provideOnboardingManageTokensModel(model: OnboardingManageTokensModel): Model
@Binds
@IntoMap
@ClassKey(CustomTokenFormModel::class)

View file

@ -14,7 +14,7 @@ internal sealed class ManageTokensUM {
abstract val isInitialBatchLoading: Boolean
abstract val isNextBatchLoading: Boolean
abstract val items: ImmutableList<CurrencyItemUM>
abstract val topBar: ManageTokensTopBarUM
abstract val topBar: ManageTokensTopBarUM?
abstract val search: SearchBarUM
abstract val loadMore: () -> Boolean
abstract val scrollToTop: StateEvent<Unit>
@ -24,7 +24,7 @@ internal sealed class ManageTokensUM {
override val isInitialBatchLoading: Boolean,
override val isNextBatchLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val topBar: ManageTokensTopBarUM?,
override val search: SearchBarUM,
override val loadMore: () -> Boolean,
override val scrollToTop: StateEvent<Unit> = consumedEvent(),
@ -35,7 +35,7 @@ internal sealed class ManageTokensUM {
override val isInitialBatchLoading: Boolean,
override val isNextBatchLoading: Boolean,
override val items: ImmutableList<CurrencyItemUM>,
override val topBar: ManageTokensTopBarUM,
override val topBar: ManageTokensTopBarUM?,
override val search: SearchBarUM,
override val loadMore: () -> Boolean,
override val scrollToTop: StateEvent<Unit> = consumedEvent(),

View file

@ -0,0 +1,41 @@
package com.tangem.features.managetokens.entity.managetokens
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import kotlinx.collections.immutable.ImmutableList
internal data class OnboardingManageTokensUM(
val onBack: () -> Unit,
val isInitialBatchLoading: Boolean,
val isNextBatchLoading: Boolean,
val items: ImmutableList<CurrencyItemUM>,
val loadMore: () -> Boolean,
val search: SearchBarUM,
val scrollToTop: StateEvent<Unit> = consumedEvent(),
val actionButtonConfig: ActionButtonConfig,
) {
sealed class ActionButtonConfig {
abstract val onClick: () -> Unit
abstract val showProgress: Boolean
data class Continue(
override val onClick: () -> Unit,
override val showProgress: Boolean = false,
val showTangemIcon: Boolean,
) : ActionButtonConfig()
data class Later(
override val onClick: () -> Unit,
override val showProgress: Boolean = false,
) : ActionButtonConfig()
fun copySealed(showProgress: Boolean): ActionButtonConfig {
return when (this) {
is Continue -> copy(showProgress = showProgress)
is Later -> copy(showProgress = showProgress)
}
}
}
}

View file

@ -75,7 +75,7 @@ internal class ManageTokensModel @Inject constructor(
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(params)
manageTokensListManager.launchPagination(source = params.source, userWalletId = params.userWalletId)
}
}
@ -159,12 +159,7 @@ internal class ManageTokensModel @Inject constructor(
}
}
.sample(periodMillis = 1_000)
.onEach { query ->
manageTokensListManager.search(
userWalletId = params.userWalletId,
query = query,
)
}
.onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) }
.launchIn(modelScope)
}
@ -272,10 +267,7 @@ internal class ManageTokensModel @Inject constructor(
if (state.isInitialBatchLoading || state.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(
userWalletId = params.userWalletId,
query = state.search.query,
)
manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query)
}
return true

View file

@ -0,0 +1,297 @@
package com.tangem.features.managetokens.model
import arrow.core.flatten
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
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.domain.card.HasMissedDerivationsUseCase
import com.tangem.domain.managetokens.SaveManagedTokensUseCase
import com.tangem.domain.redux.OnboardingManageTokensAction
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.managetokens.OnboardingManageTokensUM
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.utils.list.ChangedCurrencies
import com.tangem.features.managetokens.utils.list.ManageTokensListManager
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ComponentScoped
internal class OnboardingManageTokensModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val manageTokensListManager: ManageTokensListManager,
private val messageSender: UiMessageSender,
private val reduxStateHolder: ReduxStateHolder,
private val saveManagedTokensUseCase: SaveManagedTokensUseCase,
private val hasMissedDerivationsUseCase: HasMissedDerivationsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer,
) : Model() {
private val params: OnboardingManageTokensComponent.Params = paramsContainer.require()
val state: MutableStateFlow<OnboardingManageTokensUM> = MutableStateFlow(getInitialState())
init {
manageTokensListManager.uiItems
.onEach { items -> updateItems(items) }
.launchIn(modelScope)
manageTokensListManager.paginationStatus
.onEach { status -> updatePaginationStatus(status) }
.launchIn(modelScope)
manageTokensListManager.currenciesToAdd
.onEach(::handleNewAddedCurrencies)
.launchIn(modelScope)
observeSearchQueryChanges()
modelScope.launch {
manageTokensListManager.launchPagination(
source = ManageTokensSource.ONBOARDING,
userWalletId = params.userWalletId,
)
}
}
private fun getInitialState(): OnboardingManageTokensUM {
analyticsEventHandler.send(ManageTokensAnalyticEvent.ScreenOpened(source = ManageTokensSource.ONBOARDING))
return OnboardingManageTokensUM(
isInitialBatchLoading = true,
isNextBatchLoading = false,
items = getLoadingItems(),
loadMore = ::loadMoreItems,
onBack = {},
search = SearchBarUM(
placeholderText = resourceReference(R.string.manage_tokens_search_placeholder),
query = "",
onQueryChange = ::searchCurrencies,
isActive = false,
onActiveChange = ::toggleSearchBar,
),
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Later(
onClick = ::onLaterClick,
showProgress = false,
),
)
}
@OptIn(FlowPreview::class)
private fun observeSearchQueryChanges() {
state
.distinctUntilChanged { old, new ->
// It's also used to skip search activation to avoid searching an empty query
old.search.query == new.search.query &&
(old.search.isActive == new.search.isActive || new.search.isActive)
}
.transform { state ->
val query = state.search.query
if (state.search.isActive) {
emit(query)
}
}
.sample(periodMillis = 1_000)
.onEach { query -> manageTokensListManager.search(userWalletId = params.userWalletId, query = query) }
.launchIn(modelScope)
}
private fun updateItems(items: ImmutableList<CurrencyItemUM>) {
val updatedState = state.updateAndGet { state -> state.copy(items = items) }
if (updatedState.items.isEmpty() && updatedState.search.isActive) {
val event = ManageTokensAnalyticEvent.TokensIsNotFound(
query = updatedState.search.query,
source = ManageTokensSource.ONBOARDING,
)
analyticsEventHandler.send(event)
}
}
private fun updatePaginationStatus(status: PaginationStatus<*>) {
state.update { state ->
when (status) {
is PaginationStatus.None,
is PaginationStatus.InitialLoading,
-> {
if (state.search.isActive) {
state.copy(items = getLoadingItems())
} else {
state.copy(items = getLoadingItems(), isInitialBatchLoading = true)
}
}
is PaginationStatus.NextBatchLoading -> state.copy(isNextBatchLoading = true)
is PaginationStatus.InitialLoadingError -> {
val message = SnackbarMessage(
message = status.throwable.localizedMessage
?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
state.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
}
is PaginationStatus.Paginating -> {
(status.lastResult as? BatchFetchResult.Error)?.let { fetchError ->
Timber.e(fetchError.throwable)
}
state.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
scrollToTop = if (state.isInitialBatchLoading && state.items.isNotEmpty()) {
triggeredEvent(
data = Unit,
onConsume = ::consumeScrollToTopEvent,
)
} else {
state.scrollToTop
},
)
}
is PaginationStatus.EndOfPagination -> state.copy(
isInitialBatchLoading = false,
isNextBatchLoading = false,
)
}
}
}
private fun getLoadingItems(): ImmutableList<CurrencyItemUM> {
return List(size = 10) { index ->
CurrencyItemUM.Loading(index)
}.toPersistentList()
}
private fun consumeScrollToTopEvent() {
state.update { state -> state.copy(scrollToTop = consumedEvent()) }
}
private suspend fun handleNewAddedCurrencies(currenciesToAdd: ChangedCurrencies) {
if (currenciesToAdd.isEmpty()) {
state.update { state ->
state.copy(
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Later(onClick = ::onLaterClick),
)
}
} else {
val hasMissedDerivations = hasMissedDerivationsUseCase.invoke(
userWalletId = params.userWalletId,
networksWithDerivationPath = currenciesToAdd.values
.flatten()
.toSet()
.associate { it.backendId to null },
)
state.update { state ->
state.copy(
actionButtonConfig = OnboardingManageTokensUM.ActionButtonConfig.Continue(
onClick = ::saveChanges,
showTangemIcon = hasMissedDerivations,
),
)
}
}
}
private fun loadMoreItems(): Boolean {
val state = state.value
if (state.isInitialBatchLoading || state.isNextBatchLoading) return false
modelScope.launch {
manageTokensListManager.loadMore(userWalletId = params.userWalletId, query = state.search.query)
}
return true
}
private fun saveChanges() = resource(
acquire = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = true))
}
},
release = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = false))
}
},
) {
val event = ManageTokensAnalyticEvent.TokenAdded(
tokensCount = manageTokensListManager.currenciesToAdd.value.values.sumOf { it.size },
source = ManageTokensSource.ONBOARDING,
)
analyticsEventHandler.send(event)
saveManagedTokensUseCase(
userWalletId = requireNotNull(params.userWalletId),
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,
currenciesToRemove = manageTokensListManager.currenciesToRemove.value,
).getOrElse {
Timber.e(it, "Failed to save changes")
return@resource
}
reduxStateHolder.dispatch(OnboardingManageTokensAction.CurrenciesSaved)
}
private fun onLaterClick() = resource(
acquire = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = true))
}
},
release = {
state.update { state ->
state.copy(actionButtonConfig = state.actionButtonConfig.copySealed(showProgress = false))
}
},
) {
reduxStateHolder.dispatch(OnboardingManageTokensAction.CurrenciesSaved)
}
private fun searchCurrencies(query: String) {
state.update { state ->
state.copy(
search = state.search.copy(
query = query,
isActive = true,
),
)
}
}
private fun toggleSearchBar(isActive: Boolean) {
state.update { state ->
state.copy(
search = state.search.copy(
isActive = isActive,
),
)
}
}
}

View file

@ -59,7 +59,9 @@ import com.tangem.core.ui.res.LocalSnackbarHostState
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.wallets.models.UserWalletId
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.component.preview.PreviewManageTokensComponent
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.item.CurrencyItemUM.Basic.NetworksUM
@ -128,19 +130,21 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi
}
@Composable
private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM, search: SearchBarUM, modifier: Modifier = Modifier) {
private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM?, search: SearchBarUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.background(TangemTheme.colors.background.primary),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
TangemTopAppBar(
title = topBar.title.resolveReference(),
startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick),
endButton = when (topBar) {
is ManageTokensTopBarUM.ManageContent -> topBar.endButton
is ManageTokensTopBarUM.ReadContent -> null
},
)
if (topBar != null) {
TangemTopAppBar(
title = topBar.title.resolveReference(),
startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick),
endButton = when (topBar) {
is ManageTokensTopBarUM.ManageContent -> topBar.endButton
is ManageTokensTopBarUM.ReadContent -> null
},
)
}
SearchBar(
modifier = Modifier
.padding(bottom = TangemTheme.dimens.spacing12)
@ -196,7 +200,7 @@ private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) {
}
@Composable
private fun Currencies(
internal fun Currencies(
listState: LazyListState,
items: ImmutableList<CurrencyItemUM>,
showLoadingItem: Boolean,
@ -466,8 +470,24 @@ private fun Preview_ManageTokens(
private class PreviewManageTokensComponentProvider : PreviewParameterProvider<ManageTokensComponent> {
override val values: Sequence<ManageTokensComponent>
get() = sequenceOf(
PreviewManageTokensComponent(),
PreviewManageTokensComponent(isLoading = true),
PreviewManageTokensComponent(
isLoading = true,
params = ManageTokensComponent.Params(
source = ManageTokensSource.ONBOARDING,
userWalletId = UserWalletId("wallet_id"),
),
),
PreviewManageTokensComponent(
isLoading = false,
params = ManageTokensComponent.Params(source = ManageTokensSource.ONBOARDING, userWalletId = null),
),
PreviewManageTokensComponent(
isLoading = false,
params = ManageTokensComponent.Params(
source = ManageTokensSource.ONBOARDING,
userWalletId = UserWalletId("wallet_id"),
),
),
)
}
// endregion Preview

View file

@ -0,0 +1,167 @@
package com.tangem.features.managetokens.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.snackbar.TangemSnackbarHost
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.res.LocalSnackbarHostState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.managetokens.component.OnboardingManageTokensComponent
import com.tangem.features.managetokens.component.preview.PreviewOnboardingManageTokensComponent
import com.tangem.features.managetokens.entity.managetokens.OnboardingManageTokensUM
import com.tangem.features.managetokens.impl.R
@Composable
internal fun OnboardingManageTokensContent(state: OnboardingManageTokensUM, modifier: Modifier = Modifier) {
val keyboardController = LocalSoftwareKeyboardController.current
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
keyboardController?.hide()
return super.onPreScroll(available, source)
}
}
}
Scaffold(
modifier = modifier.nestedScroll(nestedScrollConnection),
containerColor = TangemTheme.colors.background.primary,
contentWindowInsets = WindowInsetsZero,
topBar = {
SearchBar(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing12, horizontal = TangemTheme.dimens.spacing16),
state = state.search,
)
},
content = { innerPadding ->
Content(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
state = state,
)
},
snackbarHost = {
TangemSnackbarHost(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
hostState = LocalSnackbarHostState.current,
)
},
floatingActionButtonPosition = FabPosition.Center,
floatingActionButton = {
FloatingActionButton(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
config = state.actionButtonConfig,
)
},
)
}
@Composable
private fun Content(state: OnboardingManageTokensUM, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
Box(modifier = modifier) {
Currencies(
modifier = Modifier.fillMaxSize(),
listState = listState,
items = state.items,
showLoadingItem = state.isNextBatchLoading,
onLoadMore = state.loadMore,
isEditable = true,
)
BottomFade(modifier = Modifier.align(Alignment.BottomCenter))
}
EventEffect(event = state.scrollToTop) {
listState.animateScrollToItem(index = 0)
}
}
@Composable
private fun FloatingActionButton(config: OnboardingManageTokensUM.ActionButtonConfig, modifier: Modifier = Modifier) {
when (config) {
is OnboardingManageTokensUM.ActionButtonConfig.Continue -> ContinueButton(config = config, modifier = modifier)
is OnboardingManageTokensUM.ActionButtonConfig.Later -> SecondaryButton(
modifier = modifier,
text = stringResource(id = R.string.common_later),
showProgress = config.showProgress,
onClick = config.onClick,
)
}
}
@Composable
private fun ContinueButton(
config: OnboardingManageTokensUM.ActionButtonConfig.Continue,
modifier: Modifier = Modifier,
) {
if (config.showTangemIcon) {
PrimaryButtonIconEnd(
modifier = modifier,
text = stringResource(id = R.string.common_continue),
iconResId = R.drawable.ic_tangem_24,
showProgress = config.showProgress,
onClick = config.onClick,
)
} else {
PrimaryButton(
modifier = modifier,
text = stringResource(id = R.string.common_continue),
showProgress = config.showProgress,
onClick = config.onClick,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360, heightDp = 800)
@Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ManageTokens(
@PreviewParameter(PreviewOnboardingManageTokensComponentProvider::class) component: OnboardingManageTokensComponent,
) {
TangemThemePreview {
component.Content(Modifier.fillMaxWidth())
}
}
private class PreviewOnboardingManageTokensComponentProvider :
CollectionPreviewParameterProvider<PreviewOnboardingManageTokensComponent>(
collection = listOf(
PreviewOnboardingManageTokensComponent(isLoading = false),
PreviewOnboardingManageTokensComponent(isLoading = true),
),
)
// endregion

View file

@ -16,7 +16,6 @@ import com.tangem.domain.managetokens.model.*
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.impl.R
@ -81,9 +80,9 @@ internal class ManageTokensListManager @Inject constructor(
.distinctUntilChanged()
val uiItems: Flow<ImmutableList<CurrencyItemUM>> = uiManager.items
suspend fun launchPagination(params: ManageTokensComponent.Params) = coroutineScope {
suspend fun launchPagination(source: ManageTokensSource, userWalletId: UserWalletId?) = coroutineScope {
scope = this
source = params.source
this@ManageTokensListManager.source = source
val batchFlow = getManagedTokensUseCase(
context = ManageTokensListBatchingContext(
@ -93,13 +92,13 @@ internal class ManageTokensListManager @Inject constructor(
)
batchFlow.state
.onEach { state -> updateState(state, params.userWalletId) }
.onEach { state -> updateState(state, userWalletId) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(jobHolder)
// Initial load
reload(params.userWalletId)
reload(userWalletId)
}
suspend fun reload(userWalletId: UserWalletId?) {

View file

@ -195,7 +195,7 @@ internal class MarketsPortfolioModel @Inject constructor(
hasMissedDerivationsUseCase.invoke(
userWalletId = selectedWalletId,
networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty()
.associate { Network.ID(it.networkId) to null },
.associate { it.networkId to null },
)
} else {
false