Updated on 2026-08-14
This commit is contained in:
commit
b3dfa28319
6 changed files with 149 additions and 8 deletions
|
|
@ -9,10 +9,15 @@ sealed interface AddressBookOpenMode {
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Default : AddressBookOpenMode
|
data object Default : AddressBookOpenMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opened to create a contact for an already-known [address] on [networkId]. [memo] carries the destination tag /
|
||||||
|
* memo that went with it, when the network supports transaction extras and one was entered.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class WithContactCreation(
|
data class WithContactCreation(
|
||||||
val address: String,
|
val address: String,
|
||||||
val networkId: String,
|
val networkId: String,
|
||||||
|
val memo: String? = null,
|
||||||
) : AddressBookOpenMode
|
) : AddressBookOpenMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,18 @@ internal class AddressBookChildFactory @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */
|
/**
|
||||||
|
* Builds the address attached up-front in WithContactCreation mode, when both the address and network are known.
|
||||||
|
* The memo rides along when one was provided; `ContactAddressEntriesConverter` drops it on save if the network has
|
||||||
|
* no transaction extras.
|
||||||
|
*/
|
||||||
private fun buildPredefinedAddress(route: AddressBookRoute.EditContact): ValidatedAddress? {
|
private fun buildPredefinedAddress(route: AddressBookRoute.EditContact): ValidatedAddress? {
|
||||||
val address = route.predefinedAddress ?: return null
|
val address = route.predefinedAddress ?: return null
|
||||||
val networkId = route.predefinedNetworkId ?: return null
|
val networkId = route.predefinedNetworkId ?: return null
|
||||||
return ValidatedAddress(address = address, networkIds = persistentListOf(networkId))
|
return ValidatedAddress(
|
||||||
|
address = address,
|
||||||
|
networkIds = persistentListOf(networkId),
|
||||||
|
memo = route.predefinedMemo,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +139,7 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||||
AddressBookRoute.EditContact(
|
AddressBookRoute.EditContact(
|
||||||
predefinedAddress = mode.address,
|
predefinedAddress = mode.address,
|
||||||
predefinedNetworkId = mode.networkId,
|
predefinedNetworkId = mode.networkId,
|
||||||
|
predefinedMemo = mode.memo,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,17 @@ internal sealed class AddressBookRoute {
|
||||||
/**
|
/**
|
||||||
* if [contactId] is not null we should fetch existing contact.
|
* if [contactId] is not null we should fetch existing contact.
|
||||||
*
|
*
|
||||||
* [predefinedAddress] and [predefinedNetworkId] are set only when the feature is opened in
|
* [predefinedAddress], [predefinedNetworkId] and [predefinedMemo] are set only when the feature is opened in
|
||||||
* [com.tangem.common.routing.entity.AddressBookOpenMode.WithContactCreation] mode — the address and its
|
* [com.tangem.common.routing.entity.AddressBookOpenMode.WithContactCreation] mode — the address and its
|
||||||
* network are already known, so the new contact is opened with that address already attached.
|
* network are already known, so the new contact is opened with that address already attached. [predefinedMemo]
|
||||||
|
* stays null when the network has no transaction extras or no memo was entered.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EditContact(
|
data class EditContact(
|
||||||
val contactId: String? = null,
|
val contactId: String? = null,
|
||||||
val predefinedAddress: String? = null,
|
val predefinedAddress: String? = null,
|
||||||
val predefinedNetworkId: String? = null,
|
val predefinedNetworkId: String? = null,
|
||||||
|
val predefinedMemo: String? = null,
|
||||||
) : AddressBookRoute()
|
) : AddressBookRoute()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -183,12 +183,16 @@ internal class SendDestinationModel @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens the contact editor pre-filled with the sent address/network to save the recipient (success screen). */
|
/** Opens the contact editor pre-filled with the sent address/network/memo to save the recipient (success screen). */
|
||||||
fun onAddContactClick() {
|
fun onAddContactClick() {
|
||||||
val address = (uiState.value as? DestinationUM.Content)?.addressTextField?.actualAddress ?: return
|
val content = uiState.value as? DestinationUM.Content ?: return
|
||||||
router.push(
|
router.push(
|
||||||
AppRoute.AddressBook(
|
AppRoute.AddressBook(
|
||||||
AddressBookOpenMode.WithContactCreation(address = address, networkId = cryptoCurrency.network.rawId),
|
AddressBookOpenMode.WithContactCreation(
|
||||||
|
address = content.addressTextField.actualAddress,
|
||||||
|
networkId = cryptoCurrency.network.rawId,
|
||||||
|
memo = content.memoTextField?.value?.takeIf(String::isNotBlank),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||||
import arrow.core.left
|
import arrow.core.left
|
||||||
import arrow.core.right
|
import arrow.core.right
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.common.routing.AppRoute
|
||||||
|
import com.tangem.common.routing.entity.AddressBookOpenMode
|
||||||
import com.tangem.common.ui.account.AccountIconUM
|
import com.tangem.common.ui.account.AccountIconUM
|
||||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||||
|
|
@ -105,7 +107,14 @@ internal class SendDestinationModelTest {
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
MockKAnnotations.init(this)
|
MockKAnnotations.init(this)
|
||||||
// PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows.
|
// PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows.
|
||||||
clearMocks(callback, validateWalletAddressUseCase, answers = false, recordedCalls = true, childMocks = false)
|
clearMocks(
|
||||||
|
callback,
|
||||||
|
validateWalletAddressUseCase,
|
||||||
|
router,
|
||||||
|
answers = false,
|
||||||
|
recordedCalls = true,
|
||||||
|
childMocks = false,
|
||||||
|
)
|
||||||
coEvery { getNetworkAddressesUseCase.invokeSync(any(), any<Network.RawID>()) } returns emptyList()
|
coEvery { getNetworkAddressesUseCase.invokeSync(any(), any<Network.RawID>()) } returns emptyList()
|
||||||
every { getWalletsUseCase() } returns flowOf(emptyList())
|
every { getWalletsUseCase() } returns flowOf(emptyList())
|
||||||
every { multiAccountStatusListSupplier() } returns flowOf(emptyList())
|
every { multiAccountStatusListSupplier() } returns flowOf(emptyList())
|
||||||
|
|
@ -536,6 +545,74 @@ internal class SendDestinationModelTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
inner class OnAddContactClick {
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ProvideTestModels
|
||||||
|
fun `WHEN onAddContactClick THEN address book opened with the sent address and memo`(
|
||||||
|
model: AddContactRouteModel,
|
||||||
|
) = runTest {
|
||||||
|
// Arrange
|
||||||
|
val sut = buildModel(
|
||||||
|
initialState = contentState(
|
||||||
|
address = "0xDave",
|
||||||
|
blockchainAddress = model.blockchainAddress,
|
||||||
|
memo = model.memoFieldValue,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
sut.onAddContactClick()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
verify(exactly = 1) {
|
||||||
|
router.push(
|
||||||
|
AppRoute.AddressBook(
|
||||||
|
AddressBookOpenMode.WithContactCreation(
|
||||||
|
address = model.expectedAddress,
|
||||||
|
networkId = networkRawId,
|
||||||
|
memo = model.expectedMemo,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provideTestModels() = listOf(
|
||||||
|
// memo entered -> carried over to the new contact
|
||||||
|
AddContactRouteModel(
|
||||||
|
memoFieldValue = "12345",
|
||||||
|
blockchainAddress = null,
|
||||||
|
expectedAddress = "0xDave",
|
||||||
|
expectedMemo = "12345",
|
||||||
|
),
|
||||||
|
// memo field shown but left blank -> nothing to carry
|
||||||
|
AddContactRouteModel(
|
||||||
|
memoFieldValue = " ",
|
||||||
|
blockchainAddress = null,
|
||||||
|
expectedAddress = "0xDave",
|
||||||
|
expectedMemo = null,
|
||||||
|
),
|
||||||
|
// network without transaction extras -> no memo field at all
|
||||||
|
AddContactRouteModel(
|
||||||
|
memoFieldValue = null,
|
||||||
|
blockchainAddress = null,
|
||||||
|
expectedAddress = "0xDave",
|
||||||
|
expectedMemo = null,
|
||||||
|
),
|
||||||
|
// ENS-resolved recipient -> the canonical address is saved, not the typed name
|
||||||
|
AddContactRouteModel(
|
||||||
|
memoFieldValue = "42",
|
||||||
|
blockchainAddress = "0xCanonical",
|
||||||
|
expectedAddress = "0xCanonical",
|
||||||
|
expectedMemo = "42",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
inner class SyncAddressBooks {
|
inner class SyncAddressBooks {
|
||||||
|
|
||||||
|
|
@ -741,8 +818,52 @@ internal class SendDestinationModelTest {
|
||||||
private fun content(model: SendDestinationModel): DestinationUM.Content =
|
private fun content(model: SendDestinationModel): DestinationUM.Content =
|
||||||
model.uiState.value as DestinationUM.Content
|
model.uiState.value as DestinationUM.Content
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ready-made [DestinationUM.Content] with `isInitialized = true`, so `SendDestinationInitialStateTransformer`
|
||||||
|
* leaves it alone and the test fully controls the address / memo fields. A null [memo] means the network has no
|
||||||
|
* transaction extras and therefore no memo field.
|
||||||
|
*/
|
||||||
|
private fun contentState(
|
||||||
|
address: String,
|
||||||
|
blockchainAddress: String? = null,
|
||||||
|
memo: String? = null,
|
||||||
|
): DestinationUM.Content = DestinationUM.Content(
|
||||||
|
isPrimaryButtonEnabled = false,
|
||||||
|
isInitialized = true,
|
||||||
|
addressTextField = DestinationTextFieldUM.RecipientAddress(
|
||||||
|
value = address,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Text),
|
||||||
|
placeholder = stringReference(""),
|
||||||
|
label = stringReference(""),
|
||||||
|
isValuePasted = false,
|
||||||
|
blockchainAddress = blockchainAddress,
|
||||||
|
),
|
||||||
|
memoTextField = memo?.let {
|
||||||
|
DestinationTextFieldUM.RecipientMemo(
|
||||||
|
value = it,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Text),
|
||||||
|
placeholder = stringReference(""),
|
||||||
|
label = stringReference(""),
|
||||||
|
disabledText = stringReference(""),
|
||||||
|
isEnabled = true,
|
||||||
|
isValuePasted = false,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
recent = persistentListOf(),
|
||||||
|
wallets = persistentListOf(),
|
||||||
|
networkName = "Ethereum",
|
||||||
|
isRecentHidden = false,
|
||||||
|
)
|
||||||
|
|
||||||
data class AutoNextModel(val addressValidation: AddressValidationResult, val expectedNextClicks: Int)
|
data class AutoNextModel(val addressValidation: AddressValidationResult, val expectedNextClicks: Int)
|
||||||
|
|
||||||
|
data class AddContactRouteModel(
|
||||||
|
val memoFieldValue: String?,
|
||||||
|
val blockchainAddress: String?,
|
||||||
|
val expectedAddress: String,
|
||||||
|
val expectedMemo: String?,
|
||||||
|
)
|
||||||
|
|
||||||
data class AddContactModel(
|
data class AddContactModel(
|
||||||
val isAddContactAvailable: Boolean,
|
val isAddContactAvailable: Boolean,
|
||||||
val savedAddresses: List<String>,
|
val savedAddresses: List<String>,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue