Updated on 2026-08-14
This commit is contained in:
parent
6bc50e8fe7
commit
d0d268e59a
11 changed files with 110 additions and 22 deletions
|
|
@ -4,6 +4,7 @@ import com.tangem.core.decompose.di.ModelScoped
|
|||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
|
|
@ -15,15 +16,18 @@ import com.tangem.features.addressbook.common.ContactMatcher
|
|||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@ModelScoped
|
||||
@Suppress("LongParameterList")
|
||||
internal class ContactsBlockModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: ContactsBlockStateController,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
|
||||
getContactsUseCase: GetContactsUseCase,
|
||||
getWalletsUseCase: GetWalletsUseCase,
|
||||
) : Model() {
|
||||
|
|
@ -33,6 +37,8 @@ internal class ContactsBlockModel @Inject constructor(
|
|||
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
modelScope.launch { syncAddressBooksUseCase() }
|
||||
|
||||
combine(
|
||||
params.queryFlow.flatMapLatest { query ->
|
||||
getContactsUseCase(query = query, userWalletId = null)
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ internal class DefaultAddressBookListComponent(
|
|||
|
||||
/**
|
||||
* @property mode Default (management) or Selector (pick a contact for a network)
|
||||
* @property onContactClick management mode — opens the contact editor (TODO [REDACTED_TASK_KEY])
|
||||
* @property onContactClick management mode — opens the contact editor
|
||||
* @property onAddContactClick opens the new-contact editor
|
||||
*/
|
||||
data class Params(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.tangem.core.decompose.model.ParamsContainer
|
|||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.model.VerifiedContact
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
|
|
@ -26,12 +27,13 @@ import com.tangem.features.addressbook.route.AddressBookRoute
|
|||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Backs the contacts list. The list content is the same however the address book was opened — the open
|
||||
* [AddressBookRoute.ListMode] only decides what tapping a contact does:
|
||||
* - [AddressBookRoute.ListMode.Default]: browse / manage contacts (editor is TODO [REDACTED_TASK_KEY]).
|
||||
* - [AddressBookRoute.ListMode.Default]: browse / manage contacts
|
||||
* - [AddressBookRoute.ListMode.Selector]: pick a recipient for the given network — a single matching address is
|
||||
* returned right away, several open the address selector first.
|
||||
*/
|
||||
|
|
@ -45,6 +47,7 @@ internal class AddressBookListModel @Inject constructor(
|
|||
private val router: Router,
|
||||
private val contactSelectionTrigger: ContactSelectionTrigger,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
|
||||
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
|
||||
getWalletsUseCase: GetWalletsUseCase,
|
||||
) : Model() {
|
||||
|
|
@ -60,11 +63,22 @@ internal class AddressBookListModel @Inject constructor(
|
|||
private val searchActive = MutableStateFlow(value = false)
|
||||
private val selectedWalletId = MutableStateFlow<String?>(value = null)
|
||||
|
||||
// We keep skeletons during stale state
|
||||
private val isInitialSyncDone = MutableStateFlow(value = false)
|
||||
|
||||
private val allContacts: SharedFlow<List<VerifiedContact>> =
|
||||
getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null)
|
||||
.shareIn(modelScope, SharingStarted.Lazily, replay = 1)
|
||||
|
||||
init {
|
||||
modelScope.launch {
|
||||
try {
|
||||
syncAddressBooksUseCase()
|
||||
} finally {
|
||||
isInitialSyncDone.value = true
|
||||
}
|
||||
}
|
||||
|
||||
val matchedContacts = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) {
|
||||
allContacts
|
||||
|
|
@ -72,7 +86,7 @@ internal class AddressBookListModel @Inject constructor(
|
|||
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
|
||||
}
|
||||
}
|
||||
combine(
|
||||
val listInputs = combine(
|
||||
allContacts,
|
||||
matchedContacts,
|
||||
searchQuery,
|
||||
|
|
@ -87,7 +101,8 @@ internal class AddressBookListModel @Inject constructor(
|
|||
wallets = wallets,
|
||||
)
|
||||
}
|
||||
.onEach(::updateState)
|
||||
combine(listInputs, isInitialSyncDone) { inputs, syncDone -> inputs to syncDone }
|
||||
.onEach { (inputs, syncDone) -> if (syncDone) updateState(inputs) }
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat
|
|||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
|
@ -37,6 +38,7 @@ internal class ContactsBlockModelTest {
|
|||
|
||||
private val getContactsUseCase: GetContactsUseCase = mockk()
|
||||
private val getWalletsUseCase: GetWalletsUseCase = mockk()
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true)
|
||||
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
|
||||
private val network: Network = mockk { every { rawId } returns ETHEREUM }
|
||||
|
||||
|
|
@ -134,6 +136,7 @@ internal class ContactsBlockModelTest {
|
|||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||
stateController = ContactsBlockStateController(),
|
||||
analyticsSender = analyticsSender,
|
||||
syncAddressBooksUseCase = syncAddressBooksUseCase,
|
||||
getContactsUseCase = getContactsUseCase,
|
||||
getWalletsUseCase = getWalletsUseCase,
|
||||
).also { model = it }
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
package com.tangem.features.addressbook.list.model
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
|
|
@ -20,9 +21,11 @@ import com.tangem.features.addressbook.list.ui.state.ContentMode
|
|||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -44,14 +47,21 @@ internal class AddressBookListModelTest {
|
|||
private val analyticsSender: AddressBookAnalyticsSender = mockk(relaxed = true)
|
||||
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk()
|
||||
private val getWalletsUseCase: GetWalletsUseCase = mockk()
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true)
|
||||
|
||||
private var model: AddressBookListModel? = null
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(getVerifiedContactsInteractor, getWalletsUseCase, analyticsSender, contactSelectionTrigger)
|
||||
clearMocks(
|
||||
getVerifiedContactsInteractor,
|
||||
getWalletsUseCase,
|
||||
analyticsSender,
|
||||
contactSelectionTrigger,
|
||||
syncAddressBooksUseCase,
|
||||
)
|
||||
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
|
||||
flowOf(linkedMapOf<UserWalletId, UserWallet>())
|
||||
flowOf(linkedMapOf())
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
|
@ -72,6 +82,29 @@ internal class AddressBookListModelTest {
|
|||
assertThat(model.state.value).isEqualTo(AddressBookListUM.Loading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached contacts AND sync in progress WHEN created THEN stays Loading until sync completes`() = runTest {
|
||||
// Arrange — contacts are already cached locally, but the open-time sync has not returned yet.
|
||||
val syncGate = CompletableDeferred<Unit>()
|
||||
coEvery { syncAddressBooksUseCase() } coAnswers { syncGate.await(); Unit.right() }
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
|
||||
flowOf(listOf(verifiedContact(id = "1", name = "Alice")))
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — the possibly-stale cache is not revealed while the sync is still running.
|
||||
assertThat(model.state.value).isEqualTo(AddressBookListUM.Loading)
|
||||
|
||||
// Act — the sync finishes.
|
||||
syncGate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — the list is revealed only after the sync completed.
|
||||
assertThat(model.state.value).isInstanceOf(AddressBookListUM.Content::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest {
|
||||
// Arrange
|
||||
|
|
@ -212,6 +245,7 @@ internal class AddressBookListModelTest {
|
|||
router = router,
|
||||
contactSelectionTrigger = contactSelectionTrigger,
|
||||
analyticsSender = analyticsSender,
|
||||
syncAddressBooksUseCase = syncAddressBooksUseCase,
|
||||
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
|
||||
getWalletsUseCase = getWalletsUseCase,
|
||||
).also { model = it }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue