diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 7e38a27c0c..a07d7fac8a 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -70,5 +70,6 @@ dependencies { testImplementation(projects.common.test) testImplementation(projects.test.core) testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.turbine) // endregion } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index 5dc4c61679..42eb66d25b 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -1,11 +1,16 @@ package com.tangem.data.account.di import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher +import com.tangem.data.account.repository.AccountsExpandedDTO import com.tangem.data.account.repository.DefaultAccountsCRUDRepository +import com.tangem.data.account.repository.DefaultAccountsExpandedRepository import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStoreFactory import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration @@ -13,11 +18,16 @@ import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.accounts.AccountTokenMigrationStore import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.mapWithStringKeyTypes +import com.tangem.datasource.utils.setTypes import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -25,6 +35,8 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -63,6 +75,28 @@ internal object AccountDataModule { ) } + @Provides + @Singleton + fun provideAccountsExpandedRepository( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + dispatchers: CoroutineDispatcherProvider, + ): AccountsExpandedRepository { + val store = DataStoreFactory.create>>( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(valueTypes = setTypes()), + defaultValue = emptyMap(), + ), + produceFile = { context.dataStoreFile(fileName = "account_expanded_store") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + + return DefaultAccountsExpandedRepository( + store = store, + ) + } + @Provides @Singleton fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt new file mode 100644 index 0000000000..6fab883946 --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt @@ -0,0 +1,66 @@ +package com.tangem.data.account.repository + +import androidx.datastore.core.DataStore +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.data.account.converter.toAccountId +import com.tangem.domain.account.models.AccountExpandedState +import com.tangem.domain.account.repository.AccountsExpandedRepository +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class DefaultAccountsExpandedRepository( + private val store: DataStore>>, +) : AccountsExpandedRepository { + + override val expandedAccounts: Flow>> = store.data + .map { stored -> + stored.map { (rawWalletId, setOfDto) -> + val walletId = UserWalletId(rawWalletId) + val setOfState = setOfDto.mapTo(mutableSetOf()) { dto -> + AccountExpandedState( + accountId = dto.accountId.toAccountId(walletId), + isExpanded = dto.isExpanded, + ) + } + walletId to setOfState + }.toMap() + } + + override suspend fun syncStore(walletId: UserWalletId, existAccounts: Set) { + store.updateData { map -> + val setOfDto = map[walletId.stringValue] ?: return@updateData map + val existingAccountIds = existAccounts.map { it.value }.toSet() + val syncedSet = setOfDto + .filterTo(mutableSetOf()) { (accountId, _) -> existingAccountIds.contains(accountId) } + + map.plus(walletId.stringValue to syncedSet) + } + } + + override suspend fun update(accountState: AccountExpandedState) { + store.updateData { map -> + val walletId = accountState.accountId.userWalletId + val setOfDto = map[walletId.stringValue].orEmpty() + val newDto = AccountsExpandedDTO( + accountId = accountState.accountId.value, + isExpanded = accountState.isExpanded, + ) + val updatedSet = setOfDto + .filterTo(mutableSetOf()) { it.accountId != accountState.accountId.value } + .plus(newDto) + + map.plus(walletId.stringValue to updatedSet) + } + } +} + +@JsonClass(generateAdapter = true) +internal data class AccountsExpandedDTO( + @Json(name = "accountId") + val accountId: String, + @Json(name = "isExpanded") + val isExpanded: Boolean, +) \ No newline at end of file diff --git a/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsExpandedRepositoryTest.kt b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsExpandedRepositoryTest.kt new file mode 100644 index 0000000000..78c1e93de4 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/repository/DefaultAccountsExpandedRepositoryTest.kt @@ -0,0 +1,125 @@ +package com.tangem.data.account.repository + +import app.cash.turbine.test +import com.google.common.truth.Truth +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.domain.account.models.AccountExpandedState +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultAccountsExpandedRepositoryTest { + + private val walletId = UserWalletId("011") + private val mainAccountId = AccountId.forMainCryptoPortfolio(walletId) + private val secondAccountId = AccountId.forCryptoPortfolio(walletId, DerivationIndex(1).getOrNull()!!) + + @Test + fun `expandedAccounts emits updated state when store changes`() = runTest { + val dataStore = MockStateDataStore>>( + default = emptyMap() + ) + + val repository = DefaultAccountsExpandedRepository(dataStore) + + repository.expandedAccounts.test { + // initial emission + val initial = awaitItem() + Truth.assertThat(initial.isEmpty()).isTrue() + + // update store + dataStore.updateData { + mapOf( + walletId.stringValue to setOf( + AccountsExpandedDTO( + accountId = mainAccountId.value, + isExpanded = true + ) + ) + ) + } + + // next emission + val updated = awaitItem() + val states = updated[walletId]!! + + Truth.assertThat(states.size).isEqualTo(1) + val state = states.first() + + Truth.assertThat(state.accountId).isEqualTo(mainAccountId) + Truth.assertThat(state.isExpanded).isTrue() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `expandedAccounts emits when update is called`() = runTest { + val dataStore = MockStateDataStore>>( + default = emptyMap() + ) + + val repository = DefaultAccountsExpandedRepository(dataStore) + + val state = AccountExpandedState( + accountId = mainAccountId, + isExpanded = true + ) + + repository.expandedAccounts.test { + // initial + awaitItem() + + // when + repository.update(state) + + // then + val updated = awaitItem() + val states = updated[walletId]!! + + Truth.assertThat(states.size).isEqualTo(1) + Truth.assertThat(states.first().isExpanded).isTrue() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `expandedAccounts emits synced state after syncStore`() = runTest { + val dataStore = MockStateDataStore( + mapOf( + walletId.stringValue to setOf( + AccountsExpandedDTO(mainAccountId.value, true), + AccountsExpandedDTO(secondAccountId.value, false) + ) + ) + ) + + val repository = DefaultAccountsExpandedRepository(dataStore) + + repository.expandedAccounts.test { + // initial + val initial = awaitItem() + Truth.assertThat(initial[walletId]!!.size).isEqualTo(2) + + // when + repository.syncStore( + walletId = walletId, + existAccounts = setOf(mainAccountId) // without secondAccountId + ) + + // then + val synced = awaitItem() + val states = synced[walletId]!! + + Truth.assertThat(states.size).isEqualTo(1) + Truth.assertThat(states.first().accountId.value).isEqualTo(mainAccountId.value) + + cancelAndIgnoreRemainingEvents() + } + } +} \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/AccountExpandedState.kt b/domain/account/src/main/java/com/tangem/domain/account/models/AccountExpandedState.kt new file mode 100644 index 0000000000..5da580f8eb --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/models/AccountExpandedState.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.account.models + +import com.tangem.domain.models.account.AccountId + +data class AccountExpandedState( + val accountId: AccountId, + val isExpanded: Boolean, +) \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt new file mode 100644 index 0000000000..79e58ae8c3 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.account.repository + +import com.tangem.domain.account.models.AccountExpandedState +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +interface AccountsExpandedRepository { + + val expandedAccounts: Flow>> + + suspend fun syncStore(walletId: UserWalletId, existAccounts: Set) + suspend fun update(accountState: AccountExpandedState) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 2fa16a3f5f..5b3b279808 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -233,15 +233,13 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } override fun onAccountExpandClick(account: Account) { - val userWalletId = stateHolder.getSelectedWalletId() analyticsEventHandler.send(MainScreenAnalyticsEvent.AccountShowTokens()) - accountDependencies.expandedAccountsHolder.expandAccount(userWalletId, account.accountId) + accountDependencies.expandedAccountsHolder.expandAccount(account.accountId) } override fun onAccountCollapseClick(account: Account) { - val userWalletId = stateHolder.getSelectedWalletId() analyticsEventHandler.send(MainScreenAnalyticsEvent.AccountHideTokens()) - accountDependencies.expandedAccountsHolder.collapseAccount(userWalletId, account.accountId) + accountDependencies.expandedAccountsHolder.collapseAccount(account.accountId) } private fun openYieldSupply( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt index 47735160d8..e0f0d2f4bf 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/ExpandedAccountsHolder.kt @@ -1,60 +1,100 @@ package com.tangem.feature.wallet.presentation.account import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.account.models.AccountExpandedState import com.tangem.domain.account.models.AccountList -import com.tangem.domain.account.producer.SingleAccountListProducer +import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject @ModelScoped internal class ExpandedAccountsHolder @Inject constructor( private val singleAccountListSupplier: SingleAccountListSupplier, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val accountsExpandedRepository: AccountsExpandedRepository, + private val dispatchers: CoroutineDispatcherProvider, ) { - private val expandedAccounts = MutableStateFlow>>(mapOf()) + private val actionChannel = MutableSharedFlow>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) fun expandedAccounts(userWallet: UserWallet): Flow> = channelFlow { - combine( - flow = walletAccounts(userWallet), - flow2 = isAccountsModeEnabledUseCase.invoke(), - transform = { accountList, isAccountsMode -> - val isSingleAccount = accountList.accounts.size == 1 - val defaultExpanded = when { - !isAccountsMode -> setOf() - isSingleAccount -> setOf(accountList.mainAccount.accountId) - else -> setOf() + val walletId = userWallet.walletId + + val storedState = accountsExpandedRepository.expandedAccounts + .map { it[walletId].orEmpty() } + .stateIn(this) + + val isAccountsMode = isAccountsModeEnabledUseCase.invoke() + .stateIn(this) + + val initExpandedState = storedState.value + .mapNotNull { it.takeIf { state -> state.isExpanded }?.accountId } + .toSet() + // main state holder + val expandedAccounts = MutableStateFlow(initExpandedState) + + actionChannel + .filter { (accountId, _) -> accountId.userWalletId == walletId } + .onEach { (accountId, isExpand) -> + val newState = AccountExpandedState(accountId, isExpand) + launch { accountsExpandedRepository.update(newState) } + if (isExpand) { + expandedAccounts.update { it.plus(accountId) } + } else { + expandedAccounts.update { it.minus(accountId) } } - expandedAccounts.update { map -> - var expandedSet = map[userWallet.walletId] ?: defaultExpanded - // force expand for single account - if (isSingleAccount || !isAccountsMode) expandedSet = defaultExpanded - map.plus(userWallet.walletId to expandedSet) + } + .launchIn(this) + + walletAccounts(walletId).onEach { accountList -> + val idsSet = accountList.accounts.mapTo(mutableSetOf()) { it.accountId } + accountsExpandedRepository.syncStore(walletId, idsSet) + + if (!isAccountsMode.value) return@onEach + + val isSingleAccount = accountList.accounts.size == 1 + val storedMainAccountState = storedState.value + .find { it.accountId == accountList.mainAccount.accountId } + + if (isSingleAccount && storedMainAccountState == null) { + // force expand for single and not stored account + expandedAccounts.update { setOf(accountList.mainAccount.accountId) } + } + }.launchIn(this) + + combine( + flow = expandedAccounts, + flow2 = isAccountsMode, + transform = { expanded, isAccountMode -> + if (isAccountMode) { + channel.send(expanded) + } else { + channel.send(setOf()) } }, - ).launchIn(this) + ).collect() + } + .flowOn(dispatchers.default) + .distinctUntilChanged() - expandedAccounts - .mapNotNull { map -> map[userWallet.walletId] } - .onEach { expanded -> channel.send(expanded) } - .collect() + fun expandAccount(accountId: AccountId) { + actionChannel.tryEmit(accountId to true) } - fun expandAccount(userWalletId: UserWalletId, accountId: AccountId) = expandedAccounts.update { map -> - val expandedSet = map[userWalletId]?.plus(accountId) ?: return@update map - map.plus(userWalletId to expandedSet) + fun collapseAccount(accountId: AccountId) { + actionChannel.tryEmit(accountId to false) } - fun collapseAccount(userWalletId: UserWalletId, accountId: AccountId) = expandedAccounts.update { map -> - val expandedSet = map[userWalletId]?.minus(accountId) ?: return@update map - map.plus(userWalletId to expandedSet) - } - - private fun walletAccounts(userWallet: UserWallet): Flow = - singleAccountListSupplier(SingleAccountListProducer.Params(userWallet.walletId)) + private fun walletAccounts(walletId: UserWalletId): Flow = singleAccountListSupplier(walletId) } \ No newline at end of file