Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-29 18:25:34 +04:00
parent 64a40de825
commit 4a25e38ccc
12 changed files with 151 additions and 28 deletions

View file

@ -46,14 +46,36 @@ abstract class Model : InstanceKeeper.Instance {
progressFlow: MutableSharedFlow<Boolean>,
dispatcher: CoroutineDispatcher = dispatchers.mainImmediate,
crossinline block: suspend () -> Unit,
): Job = resource(
acquire = { progressFlow.emit(true) },
release = { progressFlow.emit(false) },
dispatcher = dispatcher,
block = block,
)
/**
* Launches [block] in the model's scope and acquires a resource before executing the block and releases it after.
*
* @param acquire The block of code to acquire the resource.
* @param release The block of code to release the resource.
* @param dispatcher The [CoroutineDispatcher] to launch the coroutine. Default is [Dispatchers.Main.immediate].
* @param block The block of code to execute.
*
* @return The [Job] of the launched coroutine.
* */
protected inline fun resource(
crossinline acquire: suspend () -> Unit,
crossinline release: suspend () -> Unit,
dispatcher: CoroutineDispatcher = dispatchers.mainImmediate,
crossinline block: suspend () -> Unit,
): Job = modelScope.launch(dispatcher) {
progressFlow.emit(value = true)
acquire()
try {
block()
} finally {
withContext(NonCancellable) {
progressFlow.emit(value = false)
release()
}
}
}

View file

@ -44,11 +44,24 @@ inline fun <T> MutableList<T>.replaceBy(item: T, predicate: (T) -> Boolean): Boo
*/
inline fun <T> List<T>.addOrReplace(item: T, predicate: (T) -> Boolean): List<T> {
val mutableList = this.toMutableList()
val isReplaced = mutableList.replaceBy(item, predicate)
if (!isReplaced) {
mutableList.add(item)
}
mutableList.addOrReplace(item, predicate)
return mutableList
}
/**
* Adds the specified element to the mutable list or replaces an existing element.
*
* !!!This function is not thread-safe!!!
*
* @param item The element to be added or replace the existing one.
* @param predicate The condition to replace an existing element.
*/
inline fun <T> MutableList<T>.addOrReplace(item: T, predicate: (T) -> Boolean) {
val isReplaced = replaceBy(item, predicate)
if (!isReplaced) {
add(item)
}
}

View file

@ -9,6 +9,7 @@ interface AddCustomTokenComponent : ComposableBottomSheetComponent {
data class Params(
val userWalletId: UserWalletId,
val onDismiss: () -> Unit,
val onCurrencyAdded: () -> Unit,
)
interface Factory : ComponentFactory<Params, AddCustomTokenComponent>

View file

@ -23,6 +23,7 @@ dependencies {
/* Project - Domain */
implementation(projects.domain.manageTokens)
implementation(projects.domain.card)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)

View file

@ -16,6 +16,7 @@ internal interface CustomTokenFormComponent : ComposableContentComponent {
val formValues: CustomTokenFormValues,
val onSelectNetworkClick: (CustomTokenFormValues) -> Unit,
val onSelectDerivationPathClick: (CustomTokenFormValues) -> Unit,
val onCurrencyAdded: () -> Unit,
)
interface Factory : ComponentFactory<Params, CustomTokenFormComponent>

View file

@ -131,6 +131,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor(
formValues = config.formValues,
onSelectNetworkClick = ::showNetworkSelector,
onSelectDerivationPathClick = ::showDerivationPathSelector,
onCurrencyAdded = ::dismissAndNotify,
),
)
}
@ -170,6 +171,11 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor(
navigation.replaceAll(config)
}
private fun dismissAndNotify() {
dismiss()
params.onCurrencyAdded()
}
@AssistedFactory
interface Factory : AddCustomTokenComponent.Factory {
override fun create(

View file

@ -62,6 +62,7 @@ internal class DefaultManageTokensComponent @AssistedInject constructor(
params = AddCustomTokenComponent.Params(
userWalletId = config.userWalletId,
onDismiss = model.bottomSheetNavigation::dismiss,
onCurrencyAdded = model::reloadList,
),
)
}

View file

@ -2,6 +2,8 @@ package com.tangem.features.managetokens.entity.managetokens
import androidx.compose.runtime.Immutable
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
@ -15,6 +17,7 @@ internal sealed class ManageTokensUM {
abstract val topBar: ManageTokensTopBarUM
abstract val search: SearchBarUM
abstract val loadMore: () -> Boolean
abstract val scrollToTop: StateEvent<Unit>
data class ReadContent(
override val popBack: () -> Unit,
@ -24,6 +27,7 @@ internal sealed class ManageTokensUM {
override val topBar: ManageTokensTopBarUM,
override val search: SearchBarUM,
override val loadMore: () -> Boolean,
override val scrollToTop: StateEvent<Unit> = consumedEvent(),
) : ManageTokensUM()
data class ManageContent(
@ -34,6 +38,7 @@ internal sealed class ManageTokensUM {
override val topBar: ManageTokensTopBarUM,
override val search: SearchBarUM,
override val loadMore: () -> Boolean,
override val scrollToTop: StateEvent<Unit> = consumedEvent(),
val saveChanges: () -> Unit,
val hasChanges: Boolean,
val isSavingInProgress: Boolean,
@ -46,6 +51,7 @@ internal sealed class ManageTokensUM {
isInitialBatchLoading: Boolean = this.isInitialBatchLoading,
isNextBatchLoading: Boolean = this.isNextBatchLoading,
isSavingInProgress: Boolean = this is ManageContent && this.isSavingInProgress,
scrollToTop: StateEvent<Unit> = this.scrollToTop,
): ManageTokensUM {
return when (this) {
is ManageContent -> copy(
@ -55,12 +61,14 @@ internal sealed class ManageTokensUM {
isInitialBatchLoading = isInitialBatchLoading,
isNextBatchLoading = isNextBatchLoading,
isSavingInProgress = isSavingInProgress,
scrollToTop = scrollToTop,
)
is ReadContent -> copy(
search = search,
items = items,
isInitialBatchLoading = isInitialBatchLoading,
isNextBatchLoading = isNextBatchLoading,
scrollToTop = scrollToTop,
)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.managetokens.model
import androidx.compose.ui.res.stringResource
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ComponentScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -9,7 +10,9 @@ import com.tangem.core.ui.components.SimpleOkDialog
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.ContentMessage
import com.tangem.domain.card.DerivePublicKeysUseCase
import com.tangem.domain.managetokens.model.exceptoin.CustomTokenFormValidationException
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.managetokens.component.CustomTokenFormComponent
import com.tangem.features.managetokens.entity.customtoken.ClickableFieldUM
@ -31,11 +34,14 @@ import javax.inject.Inject
internal class CustomTokenFormModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val customCurrencyValidator: CustomCurrencyValidator,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
private val messageSender: UiMessageSender,
paramsContainer: ParamsContainer,
) : Model() {
private val params: CustomTokenFormComponent.Params = paramsContainer.require()
private var createdCurrency: CryptoCurrency? = null
val state: MutableStateFlow<CustomTokenFormUM> = MutableStateFlow(
value = getInitialState(),
@ -78,17 +84,7 @@ internal class CustomTokenFormModel @Inject constructor(
},
onClick = ::selectDerivationPath,
),
saveToken = {
// TODO: Save token: [REDACTED_JIRA]
val dialog = ContentMessage { onDismiss ->
SimpleOkDialog(
message = "Not yet implemented",
onDismissDialog = onDismiss,
)
}
messageSender.send(dialog)
},
saveToken = ::addCurrency,
)
}
@ -117,6 +113,8 @@ internal class CustomTokenFormModel @Inject constructor(
private fun observeValidatorUpdates() = modelScope.launch {
customCurrencyValidator.consumeUpdates { validatorState ->
createdCurrency = null
when (validatorState) {
is CustomCurrencyValidator.State.NotStarted -> Unit
is CustomCurrencyValidator.State.SearchingToken -> updateStateWithSearching()
@ -125,12 +123,16 @@ internal class CustomTokenFormModel @Inject constructor(
exceptions = validatorState.exceptions,
)
is CustomCurrencyValidator.State.TokenNotFound -> updateStateWithNotFoundNotification()
is CustomCurrencyValidator.State.Validated -> updateStateWithCurrency(
currency = validatorState.currency,
fillForm = validatorState.fillForm,
isAlreadyAdded = validatorState.isAlreadyAdded,
isCustom = validatorState.isCustom,
)
is CustomCurrencyValidator.State.Validated -> {
createdCurrency = validatorState.currency
updateStateWithCurrency(
currency = validatorState.currency,
fillForm = validatorState.fillForm,
isAlreadyAdded = validatorState.isAlreadyAdded,
isCustom = validatorState.isCustom,
)
}
}
}
}
@ -296,6 +298,40 @@ internal class CustomTokenFormModel @Inject constructor(
}
}
private fun addCurrency() = resource(
acquire = {
state.update { state ->
state.updateWithProgress(showProgress = true)
}
},
release = {
state.update { state ->
state.updateWithProgress(showProgress = false)
}
},
) {
val currency = createdCurrency
if (currency == null) {
Timber.e("Trying to add currency without validation")
showErrorDialog()
return@resource
}
derivePublicKeysUseCase(params.userWalletId, listOf(currency)).getOrElse {
Timber.e(it, "Failed to derive public keys")
showErrorDialog()
return@resource
}
addCryptoCurrenciesUseCase(params.userWalletId, currency).getOrElse {
Timber.e(it, "Failed to add currency")
showErrorDialog()
return@resource
}
params.onCurrencyAdded()
}
private fun selectNetwork() {
params.onSelectNetworkClick(CustomTokenFormValues(state.value.tokenForm))
}

View file

@ -9,6 +9,8 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
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
@ -67,6 +69,12 @@ internal class ManageTokensModel @Inject constructor(
}
}
fun reloadList() {
modelScope.launch {
manageTokensListManager.reload(params.userWalletId)
}
}
private fun getInitialState(userWalletId: UserWalletId?): ManageTokensUM {
return if (userWalletId == null) {
createReadContentModel()
@ -166,9 +174,18 @@ internal class ManageTokensModel @Inject constructor(
(status.lastResult as? BatchFetchResult.Error)?.let { fetchError ->
Timber.e(fetchError.throwable)
}
state.copySealed(
isInitialBatchLoading = false,
isNextBatchLoading = false,
scrollToTop = if (state.isInitialBatchLoading && state.items.isNotEmpty()) {
triggeredEvent(
data = Unit,
onConsume = ::consumeScrollToTopEvent,
)
} else {
state.scrollToTop
},
)
}
is PaginationStatus.EndOfPagination -> state.copySealed(
@ -179,6 +196,14 @@ internal class ManageTokensModel @Inject constructor(
}
}
private fun consumeScrollToTopEvent() {
this.state.update { state ->
state.copySealed(
scrollToTop = consumedEvent(),
)
}
}
private fun updateChangedItems(currenciesToAdd: ChangedCurrencies, currenciesToRemove: ChangedCurrencies) {
state.update { state ->
state.copySealed(

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
@ -45,6 +46,7 @@ import com.tangem.core.ui.components.rows.ChainRow
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.components.rows.model.ChainRowUM
import com.tangem.core.ui.components.snackbar.TangemSnackbarHost
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalSnackbarHostState
@ -166,9 +168,12 @@ private fun SaveChangesButton(
@Composable
private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
Box(modifier = modifier) {
Currencies(
modifier = Modifier.fillMaxSize(),
listState = listState,
items = state.items,
showLoadingItem = state.isNextBatchLoading,
onLoadMore = state.loadMore,
@ -185,10 +190,15 @@ private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) {
)
}
}
EventEffect(event = state.scrollToTop) {
listState.animateScrollToItem(index = 0)
}
}
@Composable
private fun Currencies(
listState: LazyListState,
items: ImmutableList<CurrencyItemUM>,
showLoadingItem: Boolean,
isEditable: Boolean,
@ -198,7 +208,6 @@ private fun Currencies(
val bottomBarHeight = with(LocalDensity.current) {
WindowInsets.systemBars.getBottom(density = this).toDp()
}
val listState = rememberLazyListState()
LazyColumn(
modifier = modifier,

View file

@ -13,6 +13,7 @@ import com.tangem.features.managetokens.utils.ui.update
import com.tangem.pagination.Batch
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@ -49,8 +50,9 @@ internal class ManageTokensUiManager(
newCurrencyBatches.forEach { (key, data) ->
val indexToUpdate = currentUiBatches.indexOfFirst { it.key == key }
val currencyBatch = state.value.currencyBatches.getOrNull(indexToUpdate)
if (indexToUpdate == -1) {
if (indexToUpdate == -1 || currencyBatch == null) {
val newBatch = Batch(
key = key,
data = data.map { item ->
@ -62,7 +64,7 @@ internal class ManageTokensUiManager(
},
)
batches.add(newBatch)
batches.addOrReplace(newBatch) { it.key == key }
} else {
val uiBatchToUpdate = currentUiBatches[indexToUpdate]
@ -70,8 +72,6 @@ internal class ManageTokensUiManager(
return@forEach
}
val currentCurrencyBatches = state.value.currencyBatches
val currencyBatch = currentCurrencyBatches[indexToUpdate]
val updatedBatch = uiBatchToUpdate.copy(
data = data.mapIndexed { index, item ->
if (item == currencyBatch.data[index]) {