diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c9f4634459..c307bb3f9b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -112,6 +112,7 @@ dependencies {
implementation(projects.libs.blockchainSdk)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
+ implementation(projects.domain.addressBook)
implementation(projects.domain.models)
implementation(projects.domain.core)
api(projects.domain.common)
diff --git a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt
new file mode 100644
index 0000000000..a06a3e002a
--- /dev/null
+++ b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt
@@ -0,0 +1,27 @@
+package com.tangem.tap.di.domain
+
+import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
+import com.tangem.domain.tokens.GetNetworkAddressesUseCase
+import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AddressBookDomainModule {
+
+ @Provides
+ @Singleton
+ fun provideValidateContactAddressUseCase(
+ validateWalletAddressUseCase: ValidateWalletAddressUseCase,
+ getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
+ ): ValidateContactAddressUseCase {
+ return ValidateContactAddressUseCase(
+ validateWalletAddressUseCase = validateWalletAddressUseCase,
+ getNetworkAddressesUseCase = getNetworkAddressesUseCase,
+ )
+ }
+}
\ No newline at end of file
diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml
index 3c128bf724..aefe8cba59 100644
--- a/core/res/src/main/res/values/strings.xml
+++ b/core/res/src/main/res/values/strings.xml
@@ -105,22 +105,23 @@
Contact
Contact name
Copy address
- We couldn’t create contact. Please try again later.
- This contact will be deleted from all your address book
- We couldn’t delete contact. Please try again later.
+ Couldn\'t create contact. Please try again later.
+ This contact will be deleted from all your address books
+ Couldn\'t delete contact. Please try again later.
Manage contacts & addresses
Discard
Edit address
Enter address
+ Invalid address
Keep editing
New contact
No contacts yet
- Contacts you add will appear here
+ Contacts added will appear here
Remove address
This contact will be linked to this wallet’s address book.
Select network
Address book
- Unsaved Changes
+ Unsaved changes
Are you sure you want to discard edits?
Send only %1$s (%2$s) from %3$s network to this address. Using other tokens and networks may result in loss of funds.
Default
diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts
index 4711602801..2869945728 100644
--- a/features/address-book/impl/build.gradle.kts
+++ b/features/address-book/impl/build.gradle.kts
@@ -16,8 +16,9 @@ dependencies {
implementation(projects.features.addressBook.api)
/** Domain */
- implementation(projects.domain.models)
+ implementation(projects.domain.account)
implementation(projects.domain.addressBook)
+ implementation(projects.domain.models)
/** Common */
implementation(projects.common.ui)
@@ -45,6 +46,12 @@ dependencies {
/** Other */
implementation(deps.kotlin.immutable.collections)
+ /** Utils */
+ implementation(projects.libs.blockchainSdk)
+ implementation(tangemDeps.blockchain)
+
/** Tests */
testImplementation(projects.test.core)
+ testImplementation(projects.test.mock)
+ testImplementation(projects.common.test)
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt
new file mode 100644
index 0000000000..445dfa6c4a
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt
@@ -0,0 +1,15 @@
+package com.tangem.features.addressbook.addaddress
+
+import com.tangem.core.decompose.factory.ComponentFactory
+import com.tangem.core.ui.decompose.ComposableContentComponent
+import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
+
+internal interface AddAddressComponent : ComposableContentComponent {
+
+ interface Factory : ComponentFactory
+
+ data class Params(
+ val onBackClick: () -> Unit,
+ val onConfirm: (ValidatedAddress) -> Unit,
+ )
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt
new file mode 100644
index 0000000000..374e522918
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt
@@ -0,0 +1,40 @@
+package com.tangem.features.addressbook.addaddress
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.tangem.core.decompose.context.AppComponentContext
+import com.tangem.core.decompose.model.getOrCreateModel
+import com.tangem.features.addressbook.addaddress.model.AddAddressModel
+import com.tangem.features.addressbook.addaddress.ui.AddAddressContent
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+
+internal class DefaultAddAddressComponent @AssistedInject constructor(
+ @Assisted context: AppComponentContext,
+ @Assisted params: AddAddressComponent.Params,
+) : AddAddressComponent, AppComponentContext by context {
+
+ private val model: AddAddressModel = getOrCreateModel(params)
+
+ @Composable
+ override fun Content(modifier: Modifier) {
+ val state by model.state.collectAsStateWithLifecycle()
+ AddAddressContent(
+ state = state,
+ modifier = modifier,
+ )
+ BackHandler(onBack = state.onBackClick)
+ }
+
+ @AssistedFactory
+ interface Factory : AddAddressComponent.Factory {
+ override fun create(
+ context: AppComponentContext,
+ params: AddAddressComponent.Params,
+ ): DefaultAddAddressComponent
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddAddressUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddAddressUM.kt
new file mode 100644
index 0000000000..d54c5e1716
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddAddressUM.kt
@@ -0,0 +1,34 @@
+package com.tangem.features.addressbook.addaddress.contract
+
+import androidx.annotation.DrawableRes
+import androidx.compose.runtime.Immutable
+import com.tangem.core.ui.ds.button.TangemButtonUM
+import com.tangem.domain.models.network.Network
+import kotlinx.collections.immutable.ImmutableList
+
+internal data class AddAddressUM(
+ val addressField: AddressFieldUM,
+ val availableNetworks: ImmutableList,
+ val buttonUM: TangemButtonUM,
+ val chosenNetworkStateUM: ChosenNetworkStateUM,
+ val onAddressChange: (String) -> Unit,
+ val onAddressClear: () -> Unit,
+ val onPasteClick: () -> Unit,
+ val onQrClick: () -> Unit,
+ val onBackClick: () -> Unit,
+) {
+ @Immutable
+ sealed class ChosenNetworkStateUM {
+ data object Loading : ChosenNetworkStateUM()
+ data object Empty : ChosenNetworkStateUM()
+ data class Result(
+ val networkUMList: ImmutableList,
+ ) : ChosenNetworkStateUM() {
+
+ data class NetworkUM(
+ val networkName: String,
+ @DrawableRes val iconResId: Int,
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddressFieldUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddressFieldUM.kt
new file mode 100644
index 0000000000..ea8e324bd7
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddressFieldUM.kt
@@ -0,0 +1,13 @@
+package com.tangem.features.addressbook.addaddress.contract
+
+import com.tangem.core.ui.extensions.TextReference
+
+internal data class AddressFieldUM(
+ val value: String,
+ val placeholder: TextReference,
+ val label: TextReference,
+ val isError: Boolean = false,
+ val error: TextReference? = null,
+ val isValuePasted: Boolean = false,
+ val blockchainAddress: String? = null,
+)
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt
new file mode 100644
index 0000000000..c5ca0ebc21
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt
@@ -0,0 +1,149 @@
+package com.tangem.features.addressbook.addaddress.model
+
+import com.tangem.blockchainsdk.utils.toBlockchain
+import com.tangem.common.ui.extensions.iconResId
+import com.tangem.core.decompose.di.ModelScoped
+import com.tangem.core.decompose.model.Model
+import com.tangem.core.decompose.model.ParamsContainer
+import com.tangem.core.ui.R
+import com.tangem.core.ui.clipboard.ClipboardManager
+import com.tangem.core.ui.ds.button.TangemButtonType
+import com.tangem.core.ui.ds.button.TangemButtonUM
+import com.tangem.core.ui.extensions.TextReference
+import com.tangem.core.ui.extensions.resourceReference
+import com.tangem.domain.account.supplier.MultiAccountListSupplier
+import com.tangem.domain.models.currency.CryptoCurrency
+import com.tangem.domain.models.network.Network
+import com.tangem.features.addressbook.addaddress.AddAddressComponent
+import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
+import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
+import com.tangem.utils.coroutines.CoroutineDispatcherProvider
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.*
+import javax.inject.Inject
+import kotlin.collections.map
+
+@OptIn(FlowPreview::class)
+@ModelScoped
+internal class AddAddressModel @Inject constructor(
+ paramsContainer: ParamsContainer,
+ override val dispatchers: CoroutineDispatcherProvider,
+ multiAccountListSupplier: MultiAccountListSupplier,
+ private val clipboardManager: ClipboardManager,
+) : Model() {
+
+ private val params: AddAddressComponent.Params = paramsContainer.require()
+
+ val state: StateFlow
+ field = MutableStateFlow(getInitialState())
+
+ private val availableCoins: StateFlow> = multiAccountListSupplier()
+ .map { accountLists ->
+ accountLists
+ .flatMap { it.flattenCurrencies() }
+ .filterIsInstance()
+ .distinctBy { it.network.id }
+ }
+ .stateIn(modelScope, SharingStarted.Eagerly, emptyList())
+
+ private val addressInput = state
+ .map { it.addressField.value }
+ .distinctUntilChanged()
+ .debounce(ADD_ADDRESS_DEBOUNCE)
+
+ init {
+ subscribeToAddressInput()
+ }
+
+ private fun onAddressChange(value: String, isPasted: Boolean = false) {
+ state.update { oldState ->
+ oldState.copy(
+ addressField = oldState.addressField.copy(
+ value = value,
+ isValuePasted = isPasted,
+ isError = false,
+ error = null,
+ ),
+ chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading,
+ )
+ }
+ }
+
+ private fun subscribeToAddressInput() {
+ combine(addressInput, availableCoins) { input, coins ->
+ getUniqueNetworks(input, coins)
+ }
+ .onEach { availableNetworks ->
+ state.update { oldState ->
+ oldState.copy(
+ availableNetworks = availableNetworks,
+ chosenNetworkStateUM = createChosenNetworkState(availableNetworks),
+ )
+ }
+ }
+ .launchIn(modelScope)
+ }
+
+ private fun createChosenNetworkState(availableNetworks: ImmutableList): AddAddressUM.ChosenNetworkStateUM {
+ return if (availableNetworks.isEmpty()) {
+ AddAddressUM.ChosenNetworkStateUM.Empty
+ } else {
+ AddAddressUM.ChosenNetworkStateUM.Result(
+ networkUMList = availableNetworks
+ .map { network ->
+ AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM(
+ networkName = network.name,
+ iconResId = network.iconResId,
+ )
+ }
+ .toImmutableList(),
+ )
+ }
+ }
+
+ private fun getUniqueNetworks(input: String, coins: List): ImmutableList {
+ return coins
+ .filter { it.network.toBlockchain().validateAddress(input) }
+ .map { it.network }
+ .toImmutableList()
+ }
+
+ private fun onPaste() {
+ onAddressChange(value = clipboardManager.getText().orEmpty(), isPasted = true)
+ }
+
+ private fun validateAndConfirm() {
+ // TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm
+ }
+
+ private fun getInitialState(): AddAddressUM = AddAddressUM(
+ addressField = AddressFieldUM(
+ value = "",
+ placeholder = resourceReference(R.string.common_address),
+ label = resourceReference(R.string.address_book_enter_address),
+ isError = false,
+ error = null,
+ isValuePasted = false,
+ ),
+ availableNetworks = persistentListOf(),
+ buttonUM = TangemButtonUM(
+ text = TextReference.Res(R.string.address_book_add_address),
+ type = TangemButtonType.Primary,
+ isEnabled = false,
+ onClick = ::validateAndConfirm,
+ ),
+ chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
+ onAddressChange = { onAddressChange(value = it) },
+ onAddressClear = { onAddressChange("") },
+ onPasteClick = ::onPaste,
+ onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
+ onBackClick = params.onBackClick,
+ )
+
+ companion object {
+ private const val ADD_ADDRESS_DEBOUNCE = 500L
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt
new file mode 100644
index 0000000000..9cf8321120
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt
@@ -0,0 +1,103 @@
+package com.tangem.features.addressbook.addaddress.ui
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.tangem.core.ui.R
+import com.tangem.core.ui.components.SpacerH12
+import com.tangem.core.ui.ds.button.PrimaryTangemButton
+import com.tangem.core.ui.ds.button.TangemButtonType
+import com.tangem.core.ui.ds.button.TangemButtonUM
+import com.tangem.core.ui.ds.image.TangemIconUM
+import com.tangem.core.ui.ds.topbar.TangemTopBar
+import com.tangem.core.ui.ds2.button.TangemButton
+import com.tangem.core.ui.extensions.TextReference
+import com.tangem.core.ui.extensions.resourceReference
+import com.tangem.core.ui.res.TangemTheme
+import com.tangem.core.ui.res.TangemThemePreviewRedesign
+import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
+import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(color = TangemTheme.colors3.bg.primary)
+ .systemBarsPadding(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ TangemTopBar(
+ modifier = Modifier.statusBarsPadding(),
+ title = resourceReference(R.string.address_book_add_address),
+ startContent = {
+ TangemButton(
+ iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
+ onClick = state.onBackClick,
+ size = TangemButton.Size.X11,
+ variant = TangemButton.Variant.Material,
+ )
+ },
+ )
+
+ RecipientRow(
+ addressField = state.addressField,
+ onValueChange = state.onAddressChange,
+ onAddressClear = state.onAddressClear,
+ onQrClick = state.onQrClick,
+ onPasteClick = state.onPasteClick,
+ )
+ SpacerH12()
+ NetworkBlock(state.chosenNetworkStateUM)
+ PrimaryButton(state.buttonUM)
+ }
+}
+
+@Composable
+private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ PrimaryTangemButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
+ buttonUM = buttonUM,
+ )
+}
+
+@Preview(showBackground = true, widthDp = 360)
+@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun Preview_AddAddressContent() {
+ TangemThemePreviewRedesign {
+ AddAddressContent(
+ state = AddAddressUM(
+ addressField = AddressFieldUM(
+ value = "",
+ placeholder = resourceReference(R.string.address_book_enter_address),
+ label = resourceReference(R.string.common_address),
+ ),
+ availableNetworks = persistentListOf(),
+ buttonUM = TangemButtonUM(
+ text = TextReference.Res(R.string.address_book_add_address),
+ type = TangemButtonType.Primary,
+ isEnabled = false,
+ onClick = { },
+ ),
+ chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
+ onAddressChange = {},
+ onAddressClear = {},
+ onPasteClick = {},
+ onQrClick = {},
+ onBackClick = {},
+ ),
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt
new file mode 100644
index 0000000000..dc91b1d8b7
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt
@@ -0,0 +1,207 @@
+package com.tangem.features.addressbook.addaddress.ui
+
+import android.content.res.Configuration
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.tangem.core.ui.R
+import com.tangem.core.ui.components.SpacerH12
+import com.tangem.core.ui.ds2.loader.TangemLoader
+import com.tangem.core.ui.ds2.row.TangemRow
+import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
+import com.tangem.core.ui.extensions.stringResourceSafe
+import com.tangem.core.ui.res.TangemTheme
+import com.tangem.core.ui.res.TangemThemePreviewRedesign
+import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
+import com.tangem.features.addressbook.addaddress.contract.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+private const val MAX_VISIBLE_NETWORKS = 3
+private val NetworkIconSize = 24.dp
+
+// Horizontal advance per icon. Smaller than the icon size so icons overlap; the bg-colored ring on
+// the icon drawn on top carves the crescent cut-out from the icon below.
+private val NetworkIconStep = 18.dp
+
+@Composable
+internal fun NetworkBlock(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
+ TangemRow(
+ verticalAlignment = TangemRowVerticalAlignment.Center,
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .fillMaxWidth()
+ .background(color = TangemTheme.colors3.bg.secondary)
+ .padding(horizontal = 4.dp),
+ titleSlot = {
+ Text(
+ text = stringResourceSafe(R.string.common_network),
+ style = TangemTheme.typography.body2,
+ color = TangemTheme.colors3.text.primary,
+ )
+ },
+ endSlot = {
+ SelectNetworkButton(chosenNetworkStateUM)
+ },
+ )
+}
+
+@Composable
+private fun SelectNetworkButton(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ when (chosenNetworkStateUM) {
+ is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList)
+ AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader()
+ AddAddressUM.ChosenNetworkStateUM.Empty -> {
+ Text(
+ modifier = Modifier.padding(start = 8.dp),
+ text = stringResourceSafe(R.string.address_book_select_network),
+ style = TangemTheme.typography.body2,
+ color = TangemTheme.colors3.text.secondary,
+ )
+ ChevronIcon()
+ }
+ }
+ }
+}
+
+@Composable
+private fun NetworkIconsResolver(networks: ImmutableList) {
+ when (networks.size) {
+ 0 -> Unit
+ 1 -> {
+ val network = networks.first()
+ Image(
+ painter = painterResource(id = network.iconResId),
+ contentDescription = null,
+ )
+ Text(
+ modifier = Modifier.padding(start = 8.dp),
+ text = network.networkName,
+ style = TangemTheme.typography.body2,
+ color = TangemTheme.colors3.text.secondary,
+ )
+ ChevronIcon()
+ }
+ // 3 and any larger count share the same rendering: up to MAX_VISIBLE_NETWORKS overlapping
+ // icons, plus a "+N" badge that appears only when there are more than that.
+ else -> {
+ OverlappingNetworkIcons(networks)
+ ChevronIcon()
+ }
+ }
+}
+
+@Composable
+private fun OverlappingNetworkIcons(networks: ImmutableList) {
+ val visible = networks.take(MAX_VISIBLE_NETWORKS)
+ val remaining = networks.size - visible.size
+
+ Box(modifier = Modifier.wrapContentWidth()) {
+ visible.forEachIndexed { index, network ->
+ Image(
+ painter = painterResource(id = network.iconResId),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .padding(start = NetworkIconStep * index)
+ .networkIconRing()
+ .size(NetworkIconSize),
+ )
+ }
+ if (remaining > 0) {
+ Box(
+ modifier = Modifier
+ .padding(start = NetworkIconStep * visible.size)
+ .networkIconRing()
+ .background(color = TangemTheme.colors3.bg.tertiary)
+ .size(NetworkIconSize),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "+$remaining",
+ style = TangemTheme.typography.caption1,
+ color = TangemTheme.colors3.text.secondary,
+ )
+ }
+ }
+ }
+}
+
+// bg-colored ring + clip applied to every overlapping element so the one drawn on top carves a
+// crescent out of the one below it. The ring color must match the surface the icons sit on.
+@Composable
+private fun Modifier.networkIconRing(): Modifier = this
+ .border(width = 2.dp, color = TangemTheme.colors3.bg.secondary, shape = CircleShape)
+ .padding(2.dp)
+ .clip(CircleShape)
+
+@Composable
+private fun ChevronIcon() {
+ Image(
+ modifier = Modifier.padding(start = 8.dp),
+ painter = painterResource(id = R.drawable.ic_select_18_24),
+ contentDescription = null,
+ )
+}
+
+@Preview
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun Preview_NetworkBlock() {
+ TangemThemePreviewRedesign {
+ Column {
+ NetworkBlock(
+ chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
+ networkUMList = persistentListOf(
+ NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
+ ),
+ ),
+ )
+ SpacerH12()
+ NetworkBlock(
+ chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
+ networkUMList = persistentListOf(
+ NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
+ NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22),
+ NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22),
+ ),
+ ),
+ )
+ SpacerH12()
+ NetworkBlock(
+ chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
+ networkUMList = List(15) {
+ NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
+ }.toImmutableList(),
+ ),
+ )
+ SpacerH12()
+ NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading)
+ SpacerH12()
+ NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty)
+ }
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/RecipientRow.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/RecipientRow.kt
new file mode 100644
index 0000000000..8ebe82cc0f
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/RecipientRow.kt
@@ -0,0 +1,143 @@
+package com.tangem.features.addressbook.addaddress.ui
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.tangem.core.ui.R
+import com.tangem.core.ui.components.SpacerH12
+import com.tangem.core.ui.components.SpacerW8
+import com.tangem.core.ui.components.fields.SimpleTextField
+import com.tangem.core.ui.ds.image.TangemIcon
+import com.tangem.core.ui.ds.image.TangemIconUM
+import com.tangem.core.ui.ds2.button.TangemButton
+import com.tangem.core.ui.ds2.row.TangemRow
+import com.tangem.core.ui.ds2.row.TangemRowContentLead
+import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
+import com.tangem.core.ui.extensions.TextReference
+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_cross_circle_20_filled
+import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
+
+@Composable
+internal fun RecipientRow(
+ addressField: AddressFieldUM,
+ onValueChange: (String) -> Unit,
+ onAddressClear: () -> Unit,
+ onQrClick: () -> Unit,
+ onPasteClick: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .fillMaxWidth()
+ .background(TangemTheme.colors3.bg.secondary),
+ ) {
+ Text(
+ modifier = Modifier.padding(start = 16.dp, top = 16.dp),
+ text = stringResourceSafe(R.string.common_address),
+ style = TangemTheme.typography.caption1,
+ color = TangemTheme.colors3.text.secondary,
+ )
+ TangemRow(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = TangemRowVerticalAlignment.Center,
+ contentLead = TangemRowContentLead.Start,
+ startSlot = {
+ TangemIcon(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .background(TangemTheme.colors3.bg.tertiary),
+ tangemIconUM = TangemIconUM.Ident(text = addressField.value),
+ )
+ },
+ titleSlot = {
+ SimpleTextField(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 12.dp),
+ value = addressField.value,
+ onValueChange = onValueChange,
+ placeholder = TextReference.Res(R.string.address_book_enter_address),
+ singleLine = false,
+ )
+ },
+ endSlot = {
+ if (addressField.value.isNotEmpty()) {
+ Icon(
+ modifier = Modifier.clickable(onClick = onAddressClear),
+ imageVector = Icons.ic_cross_circle_20_filled,
+ tint = TangemTheme.colors3.icon.tertiary,
+ contentDescription = null,
+ )
+ } else {
+ Row {
+ TangemButton(
+ variant = TangemButton.Variant.Secondary,
+ iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_qrcode_scaner_24),
+ onClick = onQrClick,
+ )
+ SpacerW8()
+ TangemButton(
+ variant = TangemButton.Variant.Primary,
+ text = TextReference.Res(id = R.string.common_paste),
+ onClick = onPasteClick,
+ )
+ }
+ }
+ },
+ )
+ }
+}
+
+@Preview
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun Preview_RecipientRow() {
+ TangemThemePreviewRedesign {
+ Column {
+ RecipientRow(
+ addressField = AddressFieldUM(
+ value = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359",
+ placeholder = resourceReference(R.string.address_book_enter_address),
+ label = resourceReference(R.string.common_address),
+ ),
+ onValueChange = {},
+ onAddressClear = {},
+ onQrClick = {},
+ onPasteClick = {},
+ )
+ SpacerH12()
+ RecipientRow(
+ addressField = AddressFieldUM(
+ value = "",
+ placeholder = resourceReference(R.string.address_book_enter_address),
+ label = resourceReference(R.string.common_address),
+ ),
+ onValueChange = {},
+ onAddressClear = {},
+ onQrClick = {},
+ onPasteClick = {},
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt
index 49e12ab00f..815ebd2498 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt
@@ -15,4 +15,7 @@ internal sealed class AddressBookRoute {
data class EditContact(
val contactId: String? = null,
) : AddressBookRoute()
+
+ @Serializable
+ data object AddAddress : AddressBookRoute()
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt
index 4b4ee9140a..e843ffd769 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt
@@ -16,8 +16,10 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.AddressBookComponent
-import com.tangem.features.addressbook.list.AddressBookListComponent
+import com.tangem.features.addressbook.addaddress.AddAddressComponent
import com.tangem.features.addressbook.editcontact.EditContactComponent
+import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
+import com.tangem.features.addressbook.list.AddressBookListComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -27,10 +29,18 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted private val params: AddressBookComponent.Params,
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
private val editContactComponentFactory: EditContactComponent.Factory,
+ private val addAddressComponentFactory: AddAddressComponent.Factory,
) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation()
+ /**
+ * Consumer for the address entered on the [AddressBookRoute.AddAddress] screen, registered by the EditContact
+ * screen when it requests adding an address and invoked when AddAddress confirms. Transient by design — the
+ * entered addresses live only in EditContact's in-memory state until the contact is saved.
+ */
+ private var pendingAddressSink: ((ValidatedAddress) -> Unit)? = null
+
private val contentStack = childStack(
key = "address_book_stack",
source = navigation,
@@ -66,6 +76,24 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
params = EditContactComponent.Params(
contactId = config.contactId?.let(::ContactId),
onBackClick = { navigation.pop() },
+ onAddAddressClick = { onResult ->
+ pendingAddressSink = onResult
+ navigation.pushNew(AddressBookRoute.AddAddress)
+ },
+ ),
+ )
+ AddressBookRoute.AddAddress -> addAddressComponentFactory.create(
+ context = childByContext(componentContext),
+ params = AddAddressComponent.Params(
+ onBackClick = {
+ pendingAddressSink = null
+ navigation.pop()
+ },
+ onConfirm = { address ->
+ pendingAddressSink?.invoke(address)
+ pendingAddressSink = null
+ navigation.pop()
+ },
),
)
}
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt
index 188884bab3..493422e7c2 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt
@@ -1,6 +1,8 @@
package com.tangem.features.addressbook.di
import com.tangem.features.addressbook.AddressBookComponent
+import com.tangem.features.addressbook.addaddress.AddAddressComponent
+import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
import com.tangem.features.addressbook.component.DefaultAddressBookComponent
import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
@@ -29,4 +31,8 @@ internal interface AddressBookComponentModule {
@Binds
@Singleton
fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory
+
+ @Binds
+ @Singleton
+ fun bindAddAddressComponentFactory(factory: DefaultAddAddressComponent.Factory): AddAddressComponent.Factory
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt
index 0fb085f06d..8d81fd327a 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt
@@ -2,6 +2,7 @@ package com.tangem.features.addressbook.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
+import com.tangem.features.addressbook.addaddress.model.AddAddressModel
import com.tangem.features.addressbook.list.model.AddressBookListModel
import com.tangem.features.addressbook.editcontact.model.EditContactModel
import dagger.Binds
@@ -23,4 +24,9 @@ internal interface AddressBookModelModule {
@IntoMap
@ClassKey(EditContactModel::class)
fun bindEditContactModel(model: EditContactModel): Model
+
+ @Binds
+ @IntoMap
+ @ClassKey(AddAddressModel::class)
+ fun bindAddAddressModel(model: AddAddressModel): Model
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt
index ede28263b7..2e93f5ff24 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt
@@ -3,6 +3,7 @@ package com.tangem.features.addressbook.editcontact
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
+import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
internal interface EditContactComponent : ComposableContentComponent {
@@ -11,5 +12,6 @@ internal interface EditContactComponent : ComposableContentComponent {
data class Params(
val contactId: ContactId?,
val onBackClick: () -> Unit,
+ val onAddAddressClick: (onResult: (ValidatedAddress) -> Unit) -> Unit,
)
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt
index 2a54242a81..9efd8db0e2 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt
@@ -1,23 +1,22 @@
package com.tangem.features.addressbook.editcontact.contract
-import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList
-@Immutable
internal data class EditContactUM(
val title: TextReference,
val name: String,
val namePlaceholder: TextReference,
val portfolioIcon: AccountIconUM.CryptoPortfolio,
val colors: Colors,
+ val addresses: ImmutableList,
val onNameChange: (String) -> Unit,
val onCloseClick: () -> Unit,
+ val onAddAddressClick: () -> Unit,
) {
- @Immutable
data class Colors(
val selected: CryptoPortfolioIcon.Color,
val list: ImmutableList,
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt
new file mode 100644
index 0000000000..87a094ee60
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt
@@ -0,0 +1,14 @@
+package com.tangem.features.addressbook.editcontact.contract
+
+import com.tangem.domain.models.network.Network
+
+/**
+ * A recipient address that has been validated and resolved to a [Network] on the AddAddress screen.
+ *
+ * This is the in-progress (pre-save) representation accumulated in [EditContactUM]. It is converted to a domain
+ * `AddressEntry` only when the contact is persisted, since the entry's id and signature are produced at save time.
+ */
+data class ValidatedAddress(
+ val address: String,
+ val network: Network,
+)
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt
index 1f1035618b..d4bcdf27ab 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt
@@ -9,7 +9,9 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
+import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -40,6 +42,14 @@ internal class EditContactModel @Inject constructor(
}
}
+ private fun requestAddAddress() {
+ params.onAddAddressClick(::addAddress)
+ }
+
+ private fun addAddress(address: ValidatedAddress) {
+ state.update { it.copy(addresses = (it.addresses + address).toImmutableList()) }
+ }
+
private fun getInitialState(): EditContactUM {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val selectedColor = colors.first()
@@ -61,8 +71,10 @@ internal class EditContactModel @Inject constructor(
list = colors,
onColorSelect = ::onColorSelect,
),
+ addresses = persistentListOf(),
onNameChange = ::onNameChange,
onCloseClick = params.onBackClick,
+ onAddAddressClick = ::requestAddAddress,
)
}
}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt
index 2183d33c43..e6d2f4a0ab 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt
@@ -7,11 +7,14 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+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.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
@@ -28,9 +31,12 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
-import com.tangem.core.ui.res.TangemThemePreview
+import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
+import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Composable
@@ -63,6 +69,83 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
) {
ContactSummary(state = state)
ContactColor(colors = state.colors)
+ ContactAddresses(addresses = state.addresses)
+ AddAddressRow(onClick = state.onAddAddressClick)
+ }
+ }
+}
+
+@Composable
+private fun ContactAddresses(addresses: ImmutableList) {
+ if (addresses.isEmpty()) return
+ Column(
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .fillMaxWidth()
+ .background(TangemTheme.colors3.bg.secondary),
+ ) {
+ addresses.fastForEach { entry ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = entry.network.name,
+ style = TangemTheme.typography3.caption.medium,
+ color = TangemTheme.colors3.text.tertiary,
+ )
+ Text(
+ text = entry.address,
+ style = TangemTheme.typography3.body.medium,
+ color = TangemTheme.colors3.text.primary,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AddAddressRow(onClick: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .fillMaxWidth()
+ .background(TangemTheme.colors3.bg.secondary)
+ .clickable(onClick = onClick)
+ .padding(horizontal = 12.dp, vertical = 15.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .background(TangemTheme.colors3.bg.status.infoSubtle),
+ ) {
+ Icon(
+ modifier = Modifier.size(18.dp),
+ imageVector = ImageVector.vectorResource(R.drawable.ic_plus_24),
+ tint = TangemTheme.colors3.text.status.info,
+ contentDescription = null,
+ )
+ }
+ Column(
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = stringResourceSafe(R.string.address_book_add_address),
+ style = TangemTheme.typography3.body.medium,
+ color = TangemTheme.colors3.text.primary,
+ )
+ Text(
+ text = stringResourceSafe(R.string.address_book_add_address_description),
+ style = TangemTheme.typography3.caption.medium,
+ color = TangemTheme.colors3.text.tertiary,
+ )
}
}
}
@@ -162,7 +245,7 @@ private fun ContactColor(colors: EditContactUM.Colors) {
@Composable
private fun Preview_EditContactContent() {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
- TangemThemePreview {
+ TangemThemePreviewRedesign {
EditContactContent(
state = EditContactUM(
title = stringReference("New contact"),
@@ -177,8 +260,10 @@ private fun Preview_EditContactContent() {
list = colors,
onColorSelect = {},
),
+ addresses = persistentListOf(),
onNameChange = {},
onCloseClick = {},
+ onAddAddressClick = {},
),
)
}
diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt
new file mode 100644
index 0000000000..c58bb25f8d
--- /dev/null
+++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt
@@ -0,0 +1,271 @@
+package com.tangem.features.addressbook.addaddress.model
+
+import com.google.common.truth.Truth.assertThat
+import com.tangem.blockchain.common.Blockchain
+import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
+import com.tangem.common.ui.extensions.iconResId
+import com.tangem.core.decompose.model.MutableParamsContainer
+import com.tangem.core.decompose.model.ParamsContainer
+import com.tangem.core.ui.clipboard.ClipboardManager
+import com.tangem.domain.account.models.AccountList
+import com.tangem.domain.account.supplier.MultiAccountListSupplier
+import com.tangem.domain.models.account.Account
+import com.tangem.domain.models.currency.CryptoCurrency
+import com.tangem.domain.models.network.Network
+import com.tangem.features.addressbook.addaddress.AddAddressComponent
+import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
+import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
+import com.tangem.test.mock.MockAccounts
+import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
+import io.mockk.clearMocks
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+internal class AddAddressModelTest {
+
+ private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
+ private val clipboardManager: ClipboardManager = mockk()
+
+ private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
+ private val ethereum = cryptoCurrencyFactory.createCoin(Blockchain.Ethereum)
+ private val bitcoin = cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin)
+
+ private var model: AddAddressModel? = null
+
+ @BeforeEach
+ fun resetMocks() {
+ clearMocks(multiAccountListSupplier, clipboardManager)
+ // Default: no accounts, so no coins are available unless a test overrides it.
+ every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
+ }
+
+ @AfterEach
+ fun tearDown() {
+ // Cancels modelScope, stopping the long-lived availableCoins / address-input collectors.
+ model?.onDestroy()
+ model = null
+ }
+
+ @Nested
+ @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ inner class AddressField {
+
+ @Test
+ fun `WHEN model created THEN field is empty AND button disabled`() = runTest {
+ // Act
+ val model = createModel(testScope = this)
+ val state = model.state.value
+
+ // Assert
+ assertThat(state.addressField.value).isEmpty()
+ assertThat(state.addressField.isValuePasted).isFalse()
+ assertThat(state.buttonUM.isEnabled).isFalse()
+ }
+
+ @Test
+ fun `GIVEN empty field WHEN onAddressChange THEN value updated`() = runTest {
+ // Arrange
+ val model = createModel(testScope = this)
+ val address = "0xABC"
+
+ // Act
+ model.state.value.onAddressChange(address)
+
+ // Assert
+ val field = model.state.value.addressField
+ assertThat(field.value).isEqualTo(address)
+ assertThat(field.isValuePasted).isFalse()
+ }
+
+ @Test
+ fun `GIVEN empty field WHEN onPasteClick THEN value marked as pasted`() = runTest {
+ // Arrange
+ val model = createModel(testScope = this)
+ val address = "0xABC"
+ every { clipboardManager.getText() } returns address
+
+ // Act
+ model.state.value.onPasteClick()
+
+ // Assert
+ val field = model.state.value.addressField
+ assertThat(field.value).isEqualTo(address)
+ assertThat(field.isValuePasted).isTrue()
+ }
+
+ // validateAndConfirm() is an unimplemented seam — the button click must NOT emit a result yet.
+ // This guards the foundation and will fail (prompting an update) once validation is wired in.
+ @Test
+ fun `GIVEN typed address WHEN button clicked THEN onConfirm not called yet`() = runTest {
+ // Arrange
+ var confirmed: ValidatedAddress? = null
+ val model = createModel(testScope = this, onConfirm = { confirmed = it })
+ model.state.value.onAddressChange("0xABC")
+
+ // Act
+ model.state.value.buttonUM.onClick()
+
+ // Assert
+ assertThat(confirmed).isNull()
+ }
+ }
+
+ @Nested
+ @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ inner class AddressInput {
+
+ @Test
+ fun `GIVEN coins available WHEN valid address typed THEN matching network chosen`() = runTest {
+ // Arrange
+ every { multiAccountListSupplier.invoke() } returns
+ flowOf(listOf(accountListWith(ethereum, bitcoin)))
+ val model = createModel(testScope = this)
+ advanceUntilIdle()
+
+ // Act
+ model.state.value.onAddressChange(VALID_ETH_ADDRESS)
+ advanceUntilIdle()
+
+ // Assert
+ val state = model.state.value
+ assertThat(state.availableNetworks).containsExactly(ethereum.network)
+ assertThat(state.chosenNetworkStateUM)
+ .isEqualTo(resultOf(ethereum.network))
+ }
+
+ @Test
+ fun `GIVEN coins available WHEN address matches no network THEN empty state`() = runTest {
+ // Arrange
+ every { multiAccountListSupplier.invoke() } returns
+ flowOf(listOf(accountListWith(ethereum, bitcoin)))
+ val model = createModel(testScope = this)
+ advanceUntilIdle()
+
+ // Act
+ model.state.value.onAddressChange("not-an-address")
+ advanceUntilIdle()
+
+ // Assert
+ val state = model.state.value
+ assertThat(state.availableNetworks).isEmpty()
+ assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
+ }
+
+ @Test
+ fun `GIVEN no coins available WHEN valid address typed THEN empty state`() = runTest {
+ // Arrange — supplier emits no accounts.
+ every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
+ val model = createModel(testScope = this)
+ advanceUntilIdle()
+
+ // Act
+ model.state.value.onAddressChange(VALID_ETH_ADDRESS)
+ advanceUntilIdle()
+
+ // Assert
+ val state = model.state.value
+ assertThat(state.availableNetworks).isEmpty()
+ assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
+ }
+
+ // Covers the "not initialized yet" case: the address is typed before coins load, and the
+ // chosen network must resolve reactively once the supplier emits them.
+ @Test
+ fun `GIVEN address typed before coins load WHEN coins emitted THEN network resolved reactively`() = runTest {
+ // Arrange
+ val accountsFlow = MutableStateFlow>(emptyList())
+ every { multiAccountListSupplier.invoke() } returns accountsFlow
+ val model = createModel(testScope = this)
+ advanceUntilIdle()
+
+ // Act — type while coins are still empty
+ model.state.value.onAddressChange(VALID_ETH_ADDRESS)
+ advanceUntilIdle()
+ // Assert intermediate: nothing to match yet
+ assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
+
+ // Act — coins arrive later
+ accountsFlow.value = listOf(accountListWith(ethereum, bitcoin))
+ advanceUntilIdle()
+
+ // Assert
+ assertThat(model.state.value.chosenNetworkStateUM)
+ .isEqualTo(resultOf(ethereum.network))
+ }
+ }
+
+ private fun resultOf(vararg networks: Network) = AddAddressUM.ChosenNetworkStateUM.Result(
+ networkUMList = networks
+ .map { network ->
+ AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM(
+ networkName = network.name,
+ iconResId = network.iconResId,
+ )
+ }
+ .toImmutableList(),
+ )
+
+ private fun accountListWith(vararg currencies: CryptoCurrency): AccountList {
+ val walletId = MockAccounts.userWalletId
+ val accounts = listOf(
+ Account.CryptoPortfolio.createMainAccount(
+ userWalletId = walletId,
+ cryptoCurrencies = currencies.toList(),
+ ),
+ )
+ return AccountList(
+ userWalletId = walletId,
+ accounts = accounts,
+ totalAccounts = accounts.size,
+ totalArchivedAccounts = 0,
+ ).getOrNull()!!
+ }
+
+ private fun createModel(
+ testScope: TestScope,
+ onConfirm: (ValidatedAddress) -> Unit = {},
+ params: AddAddressComponent.Params = AddAddressComponent.Params(
+ onBackClick = {},
+ onConfirm = onConfirm,
+ ),
+ paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
+ ): AddAddressModel {
+ return AddAddressModel(
+ paramsContainer = paramsContainer,
+ dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
+ multiAccountListSupplier = multiAccountListSupplier,
+ clipboardManager = clipboardManager,
+ ).also { model = it }
+ }
+
+ private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
+ val testDispatcher = StandardTestDispatcher(testScheduler)
+ return TestingCoroutineDispatcherProvider(
+ main = testDispatcher,
+ mainImmediate = testDispatcher,
+ io = testDispatcher,
+ default = testDispatcher,
+ single = testDispatcher,
+ )
+ }
+
+ private companion object {
+ // EIP-55 checksummed address from the spec — guaranteed to pass Ethereum validation.
+ const val VALID_ETH_ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt
index d44b437435..ebf9acafd1 100644
--- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt
+++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt
@@ -8,9 +8,13 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.models.account.CryptoPortfolioIcon
+import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
+import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
+import io.mockk.mockk
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -42,8 +46,10 @@ internal class EditContactModelTest {
list = expectedColors,
onColorSelect = state.colors.onColorSelect,
),
+ addresses = persistentListOf(),
onNameChange = state.onNameChange,
onCloseClick = state.onCloseClick,
+ onAddAddressClick = state.onAddAddressClick,
)
assertThat(state).isEqualTo(expected)
}
@@ -54,6 +60,7 @@ internal class EditContactModelTest {
val params = EditContactComponent.Params(
contactId = ContactId(value = "contact-id"),
onBackClick = {},
+ onAddAddressClick = {},
)
// Act
@@ -86,11 +93,32 @@ internal class EditContactModelTest {
assertThat(state.portfolioIcon.color).isEqualTo(newColor)
}
+ @Test
+ fun `GIVEN add address requested WHEN result delivered THEN address appended to state`() = runTest {
+ // Arrange
+ var capturedSink: ((ValidatedAddress) -> Unit)? = null
+ val params = EditContactComponent.Params(
+ contactId = null,
+ onBackClick = {},
+ onAddAddressClick = { onResult -> capturedSink = onResult },
+ )
+ val model = createModel(testScope = this, params = params)
+ val validatedAddress = ValidatedAddress(address = "0xABC", network = mockk())
+
+ // Act
+ model.state.value.onAddAddressClick()
+ capturedSink?.invoke(validatedAddress)
+
+ // Assert
+ assertThat(model.state.value.addresses).containsExactly(validatedAddress)
+ }
+
private fun createModel(
testScope: TestScope,
params: EditContactComponent.Params = EditContactComponent.Params(
contactId = null,
onBackClick = {},
+ onAddAddressClick = {},
),
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
): EditContactModel {