diff --git a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt index fda29040bc..90f6777934 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt @@ -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, + ) + } } \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCase.kt new file mode 100644 index 0000000000..bb0714b5b1 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCase.kt @@ -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): Either = + 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.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)) + } + } +} \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCaseTest.kt new file mode 100644 index 0000000000..8ca6c00f0e --- /dev/null +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/ApplyAccountListSortingUseCaseTest.kt @@ -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 { + return this.accounts.map { it.accountId } + } +} \ No newline at end of file diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountListSortingSaver.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountListSortingSaver.kt new file mode 100644 index 0000000000..f790aa8797 --- /dev/null +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/utils/AccountListSortingSaver.kt @@ -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?>(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) { + applySortingFlow.value = accountIds + } +} \ No newline at end of file