Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-17 10:59:26 +02:00
parent 98d0d9c75e
commit b1c19eff9e
21 changed files with 538 additions and 52 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ internal class UpdateAddressBookListQueryTransformer(
)
is AddressBookListUM.Empty,
AddressBookListUM.Loading,
AddressBookListUM.Incompatible,
-> prevState
}
}

View file

@ -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 = {})
}
}

View file

@ -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
/**

View file

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

View file

@ -18,6 +18,7 @@ import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAdd
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
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.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.account.AccountStatus
@ -88,6 +89,7 @@ internal class SendDestinationModel @Inject constructor(
private val addressBookSendAnalytics: AddressBookSendAnalytics,
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
private val addressBookFeatureToggles: AddressBookFeatureToggles,
isAddressBookCompatibleUseCase: IsAddressBookCompatibleUseCase,
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
contactSelectionListener: ContactSelectionListener,
) : Model(), SendDestinationClickIntents {
@ -105,6 +107,11 @@ internal class SendDestinationModel @Inject constructor(
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
private val isAddressBookCompatible: StateFlow<Boolean> =
isAddressBookCompatibleUseCase(userWalletId)
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = true)
val addressSelectorNavigation = SlotNavigation<MatchedContact>()
val addressQuery: StateFlow<String> = uiState
.map { (it as? DestinationUM.Content)?.addressTextField?.value.orEmpty() }
@ -118,7 +125,8 @@ internal class SendDestinationModel @Inject constructor(
*/
val showAddContact: StateFlow<Boolean> =
if ((params as? DestinationBlockParams)?.isAddContactAvailable == true) {
combine(uiState, contacts) { state, contactList ->
combine(uiState, contacts, isAddressBookCompatible) { state, contactList, isCompatible ->
if (!isCompatible) return@combine false
val address = (state as? DestinationUM.Content)?.addressTextField ?: return@combine false
if (address.contactName != null) return@combine false // sent via a contact
val networkId = cryptoCurrency.network.rawId

View file

@ -16,6 +16,7 @@ import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAdd
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
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.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.currency.CryptoCurrency
@ -94,6 +95,7 @@ internal class SendDestinationModelTest {
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk(relaxed = true)
private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true)
private val isAddressBookCompatibleUseCase: IsAddressBookCompatibleUseCase = mockk(relaxed = true)
private val addressBookFeatureToggles: AddressBookFeatureToggles = mockk(relaxed = true)
private val contactSelectionListener: ContactSelectionListener = mockk(relaxed = true)
private val addressBookSendAnalytics: AddressBookSendAnalytics = mockk(relaxed = true)
@ -490,6 +492,25 @@ internal class SendDestinationModelTest {
assertThat(sut.showAddContact.value).isEqualTo(model.expectedShown)
}
@Test
fun `GIVEN book newer than supported WHEN fresh address entered THEN add-contact hidden`() = runTest {
// Arrange
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
every { getVerifiedContactsInteractor.getVerifiedContacts(any(), any()) } returns flowOf(emptyList())
every { isAddressBookCompatibleUseCase(testUserWalletId) } returns flowOf(false)
val sut = buildBlockModel(isAddContactAvailable = true)
advanceUntilIdle()
// Act
sut.onRecipientAddressValueChange("0xFresh", EnterAddressSource.InputField)
advanceUntilIdle()
// Assert — a newer book can't be written, so saving the recipient is never offered.
assertThat(sut.showAddContact.value).isFalse()
}
private fun provideTestModels() = listOf(
// not available -> never shown, even for a fresh valid address
AddContactModel(
@ -667,6 +688,7 @@ internal class SendDestinationModelTest {
addressBookSendAnalytics = addressBookSendAnalytics,
syncAddressBooksUseCase = syncAddressBooksUseCase,
addressBookFeatureToggles = addressBookFeatureToggles,
isAddressBookCompatibleUseCase = isAddressBookCompatibleUseCase,
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
contactSelectionListener = contactSelectionListener,
)