Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-10 14:31:16 +04:00
parent 50c633e873
commit 939dcf8405
4 changed files with 367 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.account.usecase.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -86,4 +87,16 @@ internal object AccountDomainModule {
accountsFeatureToggles = accountsFeatureToggles,
)
}
@Provides
@Singleton
fun provideApplyAccountListSortingUseCase(
accountsCRUDRepository: AccountsCRUDRepository,
dispatchers: CoroutineDispatcherProvider,
): ApplyAccountListSortingUseCase {
return ApplyAccountListSortingUseCase(
accountsCRUDRepository = accountsCRUDRepository,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,102 @@
package com.tangem.domain.account.usecase
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.*
import arrow.core.toNonEmptyListOrNull
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.core.utils.eitherOn
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
/**
* Use case to apply a specific sorting order to a list of accounts within a user's wallet.
*
* @property accountsCRUDRepository Repository for CRUD operations on accounts.
* @property dispatchers Coroutine dispatcher provider for managing threading.
*
[REDACTED_AUTHOR]
*/
class ApplyAccountListSortingUseCase(
private val accountsCRUDRepository: AccountsCRUDRepository,
private val dispatchers: CoroutineDispatcherProvider,
) {
/**
* Applies the sorting order of the provided list of accounts to the account list
* associated with the user wallet ID of the first account in the list.
*
* @param accountIds List of AccountId representing the desired order.
* @return Either an Error or Unit on successful completion.
*/
suspend operator fun invoke(accountIds: List<AccountId>): Either<Error, Unit> =
eitherOn(dispatcher = dispatchers.default) {
val nonEmptyIds = accountIds.toNonEmptyListOrNull()
ensureNotNull(nonEmptyIds) { Error.EmptyList }
ensure(nonEmptyIds.size > 1) { Error.UnableToSortSingleAccount }
val userWalletId = nonEmptyIds.first().userWalletId
ensureNotNull(userWalletId) {
Error.DataOperationFailed("UserWalletId is null in the accounts list")
}
val accountList = getAccountList(userWalletId)
ensure(accountList.activeAccounts > 1) { Error.UnableToSortSingleAccount }
val positionByAccountId = nonEmptyIds.withIndex().associate { it.value to it.index }
val sortedAccounts = accountList.accounts.sortedBy {
positionByAccountId[it.accountId] ?: raise(Error.SomeAccountsNotFound)
}
val updatedAccountList = withError(
transform = { Error.DataOperationFailed("Unable to create AccountList: $it") },
) {
AccountList(
userWalletId = accountList.userWalletId,
accounts = sortedAccounts,
totalAccounts = accountList.totalAccounts,
sortType = accountList.sortType,
groupType = accountList.groupType,
)
.bind()
}
if (updatedAccountList == accountList) {
return@eitherOn
}
accountsCRUDRepository.saveAccounts(accountList = updatedAccountList)
}
private suspend fun Raise<Error>.getAccountList(userWalletId: UserWalletId): AccountList {
return catch(
block = { accountsCRUDRepository.getAccountListSync(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse {
raise(Error.DataOperationFailed("Account list not found for wallet $userWalletId"))
}
}
/**
* Sealed interface representing possible errors that can occur during the application
* of account list sorting.
*/
sealed interface Error {
data object EmptyList : Error
data object UnableToSortSingleAccount : Error
data object SomeAccountsNotFound : Error
data class DataOperationFailed(val cause: Throwable) : Error {
constructor(message: String) : this(cause = IllegalStateException(message))
}
}
}

View file

@ -0,0 +1,199 @@
package com.tangem.domain.account.usecase
import arrow.core.left
import arrow.core.right
import arrow.core.some
import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.usecase.ApplyAccountListSortingUseCase.Error
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ApplyAccountListSortingUseCaseTest {
private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = ApplyAccountListSortingUseCase(
accountsCRUDRepository = accountsCRUDRepository,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("011")
@AfterEach
fun tearDown() {
clearMocks(accountsCRUDRepository)
}
@Test
fun `invoke returns EmptyList when accountIds is empty`() = runTest {
// Act
val actual = useCase(accountIds = emptyList())
// Assert
val expected = Error.EmptyList.left()
Truth.assertThat(actual).isEqualTo(expected)
coVerify(inverse = true) {
accountsCRUDRepository.getAccountListSync(any())
accountsCRUDRepository.saveAccounts(any())
}
}
@Test
fun `invoke returns UnableToSortSingleAccount when accountIds contain one ID`() = runTest {
// Act
val actual = useCase(accountIds = listOf(mockk(relaxed = true)))
// Assert
val expected = Error.UnableToSortSingleAccount.left()
Truth.assertThat(actual).isEqualTo(expected)
coVerify(inverse = true) {
accountsCRUDRepository.getAccountListSync(any())
accountsCRUDRepository.saveAccounts(any())
}
}
@Test
fun `invoke returns DataOperationFailed when getAccountList returns error`() = runTest {
// Arrange
val accountList = createAccountList()
val accountIds = accountList.toAccountIds()
val exception = Exception("Test error")
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } throws exception
// Act
val actual = useCase(accountIds)
// Assert
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) }
coVerify(inverse = true) { accountsCRUDRepository.saveAccounts(any()) }
}
@Test
fun `invoke returns UnableToSortSingleAccount when saved accountList contain one account`() = runTest {
// Arrange
val accountIds = createAccountList().toAccountIds()
coEvery {
accountsCRUDRepository.getAccountListSync(userWalletId)
} returns AccountList.empty(userWalletId).some()
// Act
val actual = useCase(accountIds)
// Assert
val expected = Error.UnableToSortSingleAccount.left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) }
coVerify(inverse = true) { accountsCRUDRepository.saveAccounts(any()) }
}
@Test
fun `invoke returns SomeAccountsNotFound when saved AccountList doesn't contain unknown ID`() = runTest {
// Arrange
val unknownAccountId = AccountId.forMainCryptoPortfolio(
userWalletId = UserWalletId("012"),
)
val accountList = createAccountList()
val accountIds = listOf(accountList.mainAccount.accountId, unknownAccountId)
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some()
// Act
val actual = useCase(accountIds)
// Assert
val expected = Error.SomeAccountsNotFound.left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) }
coVerify(inverse = true) { accountsCRUDRepository.saveAccounts(any()) }
}
@Test
fun `invoke returns Right without accounts saving when nothing to change`() = runTest {
// Arrange
val accountList = createAccountList()
val accountIds = accountList.toAccountIds()
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some()
// Act
val actual = useCase(accountIds)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) }
coVerify(inverse = true) { accountsCRUDRepository.saveAccounts(any()) }
}
@Test
fun `invoke returns Right`() = runTest {
// Arrange
val accountList = createAccountList()
val accountIds = accountList.toAccountIds().reversed()
val updatedAccountList = AccountList(
userWalletId = accountList.userWalletId,
accounts = accountList.accounts.reversed(),
totalAccounts = accountList.totalAccounts,
sortType = accountList.sortType,
groupType = accountList.groupType,
).getOrNull()!!
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some()
// Act
val actual = useCase(accountIds)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
accountsCRUDRepository.getAccountListSync(userWalletId)
accountsCRUDRepository.saveAccounts(updatedAccountList)
}
}
private fun createAccountList(): AccountList {
val accountList = AccountList.empty(userWalletId)
val account1 = Account.CryptoPortfolio(
accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(1).getOrNull()!!),
name = "Account 1",
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = 1,
)
.getOrNull()!!
return (accountList + account1).getOrNull()!!
}
private fun AccountList.toAccountIds(): List<AccountId> {
return this.accounts.map { it.accountId }
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.feature.walletsettings.utils
import com.tangem.domain.account.usecase.ApplyAccountListSortingUseCase
import com.tangem.domain.models.account.AccountId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
/**
* Saves the sorting order of accounts with a debounce to prevent frequent updates.
*
* @property applyAccountListSortingUseCase Use case to apply the account list sorting.
* @param dispatchers Coroutine dispatcher provider for managing threading.
*
[REDACTED_AUTHOR]
*/
@Singleton
@OptIn(FlowPreview::class)
internal class AccountListSortingSaver @Inject constructor(
private val applyAccountListSortingUseCase: ApplyAccountListSortingUseCase,
dispatchers: CoroutineDispatcherProvider,
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.io)
private val applySortingFlow = MutableStateFlow<List<AccountId>?>(value = null)
init {
applySortingFlow
.filterNotNull()
.debounce { 3.seconds }
.onEach { accountIds ->
applyAccountListSortingUseCase.invoke(accountIds).onLeft {
Timber.e("Error while saving account list sorting: $it")
}
}
.launchIn(coroutineScope)
}
/**
* Saves the provided list of account IDs to apply the sorting order.
*
* @param accountIds List of AccountId representing the desired order.
*/
fun save(accountIds: List<AccountId>) {
applySortingFlow.value = accountIds
}
}