Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-14 12:20:34 +07:00
parent c425d8640d
commit a5b29a4449
8 changed files with 321 additions and 35 deletions

View file

@ -70,5 +70,6 @@ dependencies {
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.turbine)
// endregion
}

View file

@ -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<Map<String, Set<AccountsExpandedDTO>>>(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes(valueTypes = setTypes<AccountsExpandedDTO>()),
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

View file

@ -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<Map<String, Set<AccountsExpandedDTO>>>,
) : AccountsExpandedRepository {
override val expandedAccounts: Flow<Map<UserWalletId, Set<AccountExpandedState>>> = 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<AccountId>) {
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,
)

View file

@ -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<Map<String, Set<AccountsExpandedDTO>>>(
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<Map<String, Set<AccountsExpandedDTO>>>(
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()
}
}
}

View file

@ -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,
)

View file

@ -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<Map<UserWalletId, Set<AccountExpandedState>>>
suspend fun syncStore(walletId: UserWalletId, existAccounts: Set<AccountId>)
suspend fun update(accountState: AccountExpandedState)
}

View file

@ -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(

View file

@ -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<Map<UserWalletId, Set<AccountId>>>(mapOf())
private val actionChannel = MutableSharedFlow<Pair<AccountId, Boolean>>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
fun expandedAccounts(userWallet: UserWallet): Flow<Set<AccountId>> = 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<AccountList> =
singleAccountListSupplier(SingleAccountListProducer.Params(userWallet.walletId))
private fun walletAccounts(walletId: UserWalletId): Flow<AccountList> = singleAccountListSupplier(walletId)
}