Updated on 2026-08-14
This commit is contained in:
parent
98d0d9c75e
commit
b1c19eff9e
21 changed files with 538 additions and 52 deletions
|
|
@ -33,6 +33,7 @@ dependencies {
|
|||
api(projects.core.decompose)
|
||||
api(projects.core.ui)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.navigation)
|
||||
|
||||
/** Compose */
|
||||
api(deps.compose.foundation)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.navigation.url.AppStoreOpener
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
|
|
@ -21,6 +22,7 @@ import javax.inject.Inject
|
|||
internal class AddressBookChildFactory @Inject constructor(
|
||||
private val addressSelectorFactory: AddressSelectorComponent.Factory,
|
||||
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
|
||||
private val appStoreOpener: AppStoreOpener,
|
||||
) {
|
||||
|
||||
fun createChild(
|
||||
|
|
@ -36,6 +38,7 @@ internal class AddressBookChildFactory @Inject constructor(
|
|||
onAddContactClick = clickIntents::onAddContactClick,
|
||||
),
|
||||
addressSelectorFactory = addressSelectorFactory,
|
||||
appStoreOpener = appStoreOpener,
|
||||
)
|
||||
is AddressBookRoute.EditContact -> DefaultEditContactComponent(
|
||||
appComponentContext = context,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.resourceReference
|
|||
import com.tangem.core.ui.message.DialogMessage
|
||||
import com.tangem.core.ui.message.EventMessageAction
|
||||
import com.tangem.core.ui.message.SnackbarMessage
|
||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
import com.tangem.domain.addressbook.interactor.SaveContactInteractor
|
||||
|
|
@ -451,10 +452,12 @@ internal class EditContactModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun handleSaveError(error: SaveContactError) {
|
||||
when (error) {
|
||||
is SaveContactError.Name -> stateController.update(
|
||||
when {
|
||||
error is SaveContactError.Name -> stateController.update(
|
||||
UpdateNameErrorTransformer(ContactNameErrorConverter().convert(error.error)),
|
||||
)
|
||||
error is SaveContactError.Backend && error.error is AddressBookSyncError.VersionMismatch ->
|
||||
showUpdateAppDialog()
|
||||
else -> messageSender.send(
|
||||
DialogMessage(
|
||||
title = resourceReference(R.string.common_something_went_wrong),
|
||||
|
|
@ -469,6 +472,14 @@ internal class EditContactModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
private fun showUpdateAppDialog() {
|
||||
messageSender.send(
|
||||
DialogMessage(
|
||||
title = resourceReference(R.string.force_update_warning_title),
|
||||
message = resourceReference(R.string.force_update_warning_message),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteContact(fromLastAddressRemoval: Boolean) {
|
||||
val contactId = params.contactId ?: return
|
||||
|
|
@ -479,7 +490,9 @@ internal class EditContactModel @Inject constructor(
|
|||
}
|
||||
modelScope.launch {
|
||||
deleteContactUseCase(contactId).fold(
|
||||
ifLeft = { showDeleteError() },
|
||||
ifLeft = { error ->
|
||||
if (error is AddressBookSyncError.VersionMismatch) showUpdateAppDialog() else showDeleteError()
|
||||
},
|
||||
ifRight = {
|
||||
if (walletId != null) {
|
||||
analyticsSender.sendContactDeleted(walletId = walletId, contactId = contactId.value)
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import com.arkivanov.decompose.router.slot.dismiss
|
|||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.navigation.url.AppStoreOpener
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
||||
import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen
|
||||
import com.tangem.features.addressbook.list.ui.AddressBookIncompatibleScreen
|
||||
import com.tangem.features.addressbook.list.ui.AddressBookListScreen
|
||||
import com.tangem.features.addressbook.list.ui.AddressBookListShimmer
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
|
|
@ -25,6 +27,7 @@ internal class DefaultAddressBookListComponent(
|
|||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
addressSelectorFactory: AddressSelectorComponent.Factory,
|
||||
private val appStoreOpener: AppStoreOpener,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: AddressBookListModel = getOrCreateModel(params)
|
||||
|
|
@ -55,6 +58,11 @@ internal class DefaultAddressBookListComponent(
|
|||
onBackClick = router::pop,
|
||||
modifier = modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
is AddressBookListUM.Incompatible -> AddressBookIncompatibleScreen(
|
||||
onBackClick = router::pop,
|
||||
modifier = modifier.background(TangemTheme.colors3.bg.primary),
|
||||
onUpdateClick = appStoreOpener::openStorePage,
|
||||
)
|
||||
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
|
||||
onAddContactClick = addressBookListUM.onAddClick,
|
||||
onBackClick = router::pop,
|
||||
|
|
|
|||
|
|
@ -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.Contact
|
||||
import com.tangem.domain.addressbook.usecase.IsAddressBookCompatibleUseCase
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
|
@ -20,6 +21,7 @@ import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListSc
|
|||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
||||
import com.tangem.features.addressbook.list.state.transformers.SetAddressBookIncompatibleTransformer
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListQueryTransformer
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
|
|
@ -48,6 +50,7 @@ internal class AddressBookListModel @Inject constructor(
|
|||
private val contactSelectionTrigger: ContactSelectionTrigger,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
|
||||
private val isAddressBookCompatibleUseCase: IsAddressBookCompatibleUseCase,
|
||||
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
|
||||
private val getWalletsUseCase: GetWalletsUseCase,
|
||||
) : Model() {
|
||||
|
|
@ -76,31 +79,39 @@ internal class AddressBookListModel @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun observeContacts() {
|
||||
val matchedContacts = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) {
|
||||
allContacts
|
||||
} else {
|
||||
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
|
||||
isAddressBookCompatibleUseCase()
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { isCompatible ->
|
||||
if (isCompatible) {
|
||||
val matchedContacts = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) {
|
||||
allContacts
|
||||
} else {
|
||||
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
|
||||
}
|
||||
}
|
||||
combine(
|
||||
allContacts,
|
||||
matchedContacts,
|
||||
searchQuery,
|
||||
selectedWalletId,
|
||||
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
|
||||
) { all, matched, query, selected, wallets ->
|
||||
ListInputs(
|
||||
allContacts = all,
|
||||
matchedContacts = matched,
|
||||
query = query,
|
||||
selectedWalletId = selected,
|
||||
wallets = wallets,
|
||||
)
|
||||
}
|
||||
.onEach(::updateState)
|
||||
.flowOn(dispatchers.default)
|
||||
.collect()
|
||||
} else {
|
||||
stateController.update(SetAddressBookIncompatibleTransformer())
|
||||
}
|
||||
}
|
||||
}
|
||||
combine(
|
||||
allContacts,
|
||||
matchedContacts,
|
||||
searchQuery,
|
||||
selectedWalletId,
|
||||
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
|
||||
) { all, matched, query, selected, wallets ->
|
||||
ListInputs(
|
||||
allContacts = all,
|
||||
matchedContacts = matched,
|
||||
query = query,
|
||||
selectedWalletId = selected,
|
||||
wallets = wallets,
|
||||
)
|
||||
}
|
||||
.onEach(::updateState)
|
||||
.flowOn(dispatchers.default)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fun deliverSelection(contact: SelectedContact) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Replaces the whole list state with [AddressBookListUM.Incompatible] — used when a stored book uses a
|
||||
* contract version newer than this build supports.
|
||||
*/
|
||||
internal class SetAddressBookIncompatibleTransformer : Transformer<AddressBookListUM> {
|
||||
|
||||
override fun transform(prevState: AddressBookListUM): AddressBookListUM = AddressBookListUM.Incompatible
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ internal class UpdateAddressBookListQueryTransformer(
|
|||
)
|
||||
is AddressBookListUM.Empty,
|
||||
AddressBookListUM.Loading,
|
||||
AddressBookListUM.Incompatible,
|
||||
-> prevState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.tangem.features.addressbook.list.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_info_24
|
||||
|
||||
/**
|
||||
* Shown when a stored address book uses a contract version newer than this build supports: the book can be
|
||||
* neither read nor edited, so the user is asked to update the app. No contacts, no add button.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddressBookIncompatibleScreen(
|
||||
onUpdateClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.windowInsetsPadding(WindowInsets.navigationBars),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TangemTopNavigation(
|
||||
title = resourceReference(R.string.address_book_title),
|
||||
contentAlign = TangemTopNavigation.ContentAlign.Center,
|
||||
blurBackground = false,
|
||||
onBack = onBackClick,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.background(color = TangemTheme.colors3.bg.status.infoSubtle, shape = CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.ic_info_24,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.status.info,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 32.dp),
|
||||
text = stringResourceSafe(R.string.force_update_warning_title),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
text = stringResourceSafe(R.string.force_update_warning_message),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.subheading.medium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
TangemButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
size = TangemButton.Size.X12,
|
||||
onClick = onUpdateClick,
|
||||
text = resourceReference(R.string.force_update_required_action),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_AddressBookIncompatibleScreen() {
|
||||
TangemThemePreviewRedesign {
|
||||
AddressBookIncompatibleScreen(onBackClick = {}, onUpdateClick = {})
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,12 @@ internal sealed interface AddressBookListUM {
|
|||
/** Initial state while the address books are being (re-)synced on open — rendered as shimmer placeholders. */
|
||||
data object Loading : AddressBookListUM
|
||||
|
||||
/**
|
||||
* A stored book uses a contract version newer than this build supports, so it can be neither read nor
|
||||
* safely edited. The screen shows an "update the app" stub instead of the list — no contacts, no add button.
|
||||
*/
|
||||
data object Incompatible : AddressBookListUM
|
||||
|
||||
data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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.IsAddressBookCompatibleUseCase
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -20,11 +21,7 @@ import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
|||
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 io.mockk.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
|
@ -48,6 +45,7 @@ internal class AddressBookListModelTest {
|
|||
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk()
|
||||
private val getWalletsUseCase: GetWalletsUseCase = mockk()
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true)
|
||||
private val isAddressBookCompatibleUseCase: IsAddressBookCompatibleUseCase = mockk()
|
||||
|
||||
private var model: AddressBookListModel? = null
|
||||
|
||||
|
|
@ -59,9 +57,11 @@ internal class AddressBookListModelTest {
|
|||
analyticsSender,
|
||||
contactSelectionTrigger,
|
||||
syncAddressBooksUseCase,
|
||||
isAddressBookCompatibleUseCase,
|
||||
)
|
||||
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
|
||||
flowOf(linkedMapOf())
|
||||
every { isAddressBookCompatibleUseCase() } returns flowOf(true)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
|
@ -121,6 +121,21 @@ internal class AddressBookListModelTest {
|
|||
assertThat(state.contacts.map { it.name }).containsExactly("Alice", "Bob")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a book newer than supported WHEN created THEN incompatible state shown`() = runTest {
|
||||
// Arrange
|
||||
every { isAddressBookCompatibleUseCase() } returns flowOf(false)
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
|
||||
flowOf(listOf(contact(id = "1", name = "Alice")))
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — the "update the app" stub replaces the list even though contacts are cached.
|
||||
assertThat(model.state.value).isEqualTo(AddressBookListUM.Incompatible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN default mode AND no contacts WHEN created THEN empty state`() = runTest {
|
||||
// Arrange
|
||||
|
|
@ -243,6 +258,7 @@ internal class AddressBookListModelTest {
|
|||
contactSelectionTrigger = contactSelectionTrigger,
|
||||
analyticsSender = analyticsSender,
|
||||
syncAddressBooksUseCase = syncAddressBooksUseCase,
|
||||
isAddressBookCompatibleUseCase = isAddressBookCompatibleUseCase,
|
||||
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
|
||||
getWalletsUseCase = getWalletsUseCase,
|
||||
).also { model = it }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue