Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-21 10:38:56 +02:00
parent beeb77543b
commit 15daf08afe
4 changed files with 200 additions and 23 deletions

View file

@ -100,8 +100,9 @@ internal class EditContactModel @Inject constructor(
.stateIn(modelScope, SharingStarted.Eagerly, null)
/**
* The wallet the contact is saved to. For an existing contact it is fixed to the contact's wallet; for a new
* contact it follows the selector pick and falls back to the app's currently selected wallet.
* The wallet the contact is saved to. A user pick in the selector always wins (this is how an existing contact
* is moved to another wallet). With no pick yet it defaults to the existing contact's own wallet, or for a new
* contact the app's currently selected wallet.
*/
private val selectedWallet: StateFlow<UserWallet?> = combine(
pickedWallet,
@ -110,8 +111,8 @@ internal class EditContactModel @Inject constructor(
userWalletsListRepository.userWallets,
) { picked, contact, currentSelected, wallets ->
when {
contact != null -> wallets?.firstOrNull { it.walletId == contact.walletId }
picked != null -> picked
contact != null -> wallets?.firstOrNull { it.walletId == contact.walletId }
else -> currentSelected
}
}.stateIn(modelScope, SharingStarted.Eagerly, null)
@ -304,19 +305,26 @@ internal class EditContactModel @Inject constructor(
val ui = stateController.uiState.value
val addresses = ContactAddressEntriesConverter().convert(ui.addresses)
val existing = loadedContact.value
val isWalletChanged = existing != null && existing.walletId != userWallet.walletId
saveJob = modelScope.launch {
val result = if (existing != null) {
saveContactInteractor.updateContact(
val result = when {
existing == null -> saveContactInteractor.createContact(
userWallet = userWallet,
name = ui.name,
iconColor = ui.colors.selected.name,
addresses = addresses,
)
isWalletChanged -> saveContactInteractor.moveContact(
targetWallet = userWallet,
contact = existing,
name = ui.name,
iconColor = ui.colors.selected.name,
addresses = addresses,
)
} else {
saveContactInteractor.createContact(
else -> saveContactInteractor.updateContact(
userWallet = userWallet,
contact = existing,
name = ui.name,
iconColor = ui.colors.selected.name,
addresses = addresses,
@ -426,13 +434,13 @@ internal class EditContactModel @Inject constructor(
}
private fun isWalletChangeable(wallets: List<UserWallet>?): Boolean {
val unlockedWalletsCount = wallets.orEmpty().count { !it.isLocked }
return params.contactId == null && unlockedWalletsCount > 1
return wallets.orEmpty().count { !it.isLocked } > 1
}
private suspend fun validateName(name: String, walletId: UserWalletId): ContactNameValidationError? {
if (name.isBlank()) return null
if (name == loadedContact.value?.name?.value) return null
val loaded = loadedContact.value
if (loaded != null && name == loaded.name.value && walletId == loaded.walletId) return null
val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
return error
@ -538,8 +546,9 @@ internal class EditContactModel @Inject constructor(
/** Dirty when the current editor differs from its baseline — the loaded contact, or the empty new contact. */
private fun isDirty(): Boolean {
val baseline = loadedContact.value?.toSnapshot() ?: newContactBaseline
return currentSnapshot() != baseline
val loaded = loadedContact.value ?: return currentSnapshot() != newContactBaseline
val isWalletChanged = selectedWallet.value?.walletId?.let { it != loaded.walletId } == true
return isWalletChanged || currentSnapshot() != loaded.toSnapshot()
}
private fun currentSnapshot(): EditSnapshot {

View file

@ -658,6 +658,69 @@ internal class EditContactModelTest {
coVerify(exactly = 0) { saveContactInteractor.createContact(any(), any(), any(), any()) }
}
@Test
fun `GIVEN existing contact AND multiple unlocked wallets WHEN created THEN wallet block changeable`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
val walletB = createWallet(id = "bb", name = "Wallet B")
setupWallets(wallets = listOf(walletA, walletB), selected = walletA)
every { getContactByIdUseCase(ContactId("c-1")) } returns
MutableStateFlow(existingContact(walletId = "aa", name = "Alice", address = "0xABC"))
// Act
val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
advanceUntilIdle()
// Assert — an existing contact can now be moved, so its wallet block is changeable.
assertThat(model.state.value.walletBlock.isChangeable).isTrue()
}
@Test
fun `GIVEN existing contact AND wallet changed WHEN save clicked THEN moveContact called`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
val walletB = createWallet(id = "bb", name = "Wallet B")
setupWallets(wallets = listOf(walletA, walletB), selected = walletA)
val contact = existingContact(walletId = "aa", name = "Alice", address = "0xABC")
every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(contact)
val moved = mockk<Contact> { every { id } returns ContactId(value = "moved-1") }
coEvery { saveContactInteractor.moveContact(any(), any(), any(), any(), any()) } returns moved.right()
val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
advanceUntilIdle()
// Act — pick wallet B in the selector, then save.
selectedWalletData.tryEmit(walletB to mockk())
advanceUntilIdle()
model.state.value.saveButton.onClick()
advanceUntilIdle()
// Assert — the contact is moved to wallet B; plain update/create are not used.
coVerify(exactly = 1) { saveContactInteractor.moveContact(walletB, contact, "Alice", any(), any()) }
coVerify(exactly = 0) { saveContactInteractor.updateContact(any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { saveContactInteractor.createContact(any(), any(), any(), any()) }
}
@Test
fun `GIVEN existing contact AND wallet unchanged WHEN save clicked THEN updateContact used not move`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
val walletB = createWallet(id = "bb", name = "Wallet B")
setupWallets(wallets = listOf(walletA, walletB), selected = walletA)
val contact = existingContact(walletId = "aa", name = "Alice", address = "0xABC")
every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(contact)
coEvery { saveContactInteractor.updateContact(any(), any(), any(), any(), any()) } returns contact.right()
val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1")))
advanceUntilIdle()
// Act — no wallet pick, so it stays in its own wallet.
model.state.value.saveButton.onClick()
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { saveContactInteractor.updateContact(walletA, contact, "Alice", any(), any()) }
coVerify(exactly = 0) { saveContactInteractor.moveContact(any(), any(), any(), any(), any()) }
}
@Test
fun `GIVEN new contact saved WHEN success THEN contact-added snackbar shown`() = runTest {
// Arrange