Updated on 2026-08-14
This commit is contained in:
parent
20484baaf1
commit
bee4019e8b
49 changed files with 1256 additions and 72 deletions
|
|
@ -180,6 +180,7 @@ sealed class AppRoute(val path: String) : Route {
|
|||
path = when (addressBookOpenMode) {
|
||||
is AddressBookOpenMode.WithContactCreation ->
|
||||
"/address_book/${addressBookOpenMode.address}-${addressBookOpenMode.networkId}"
|
||||
is AddressBookOpenMode.ContactSelection -> "/address_book/select/${addressBookOpenMode.networkId}"
|
||||
AddressBookOpenMode.Default -> "/address_book"
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,4 +14,13 @@ sealed interface AddressBookOpenMode {
|
|||
val address: String,
|
||||
val networkId: String,
|
||||
) : AddressBookOpenMode
|
||||
|
||||
/**
|
||||
* Opened from the Send flow to pick a recipient. The list is filtered by [networkId] (the current send network),
|
||||
* and the chosen contact's address is delivered back via `ContactSelectionTrigger` rather than navigation.
|
||||
*/
|
||||
@Serializable
|
||||
data class ContactSelection(
|
||||
val networkId: String,
|
||||
) : AddressBookOpenMode
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
package com.tangem.common.ui.account
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon.Color
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon.Icon
|
||||
|
||||
@Immutable
|
||||
sealed class AccountIconUM {
|
||||
data class CryptoPortfolio(val value: Icon, val color: Color) : AccountIconUM()
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@
|
|||
<item quantity="one">%d address</item>
|
||||
<item quantity="other">%d addresses</item>
|
||||
</plurals>
|
||||
<string name="address_book_choose_address">Choose address</string>
|
||||
<string name="address_book_contact">Contact</string>
|
||||
<string name="address_book_contact_name">Contact name</string>
|
||||
<string name="address_book_copy_address">Copy address</string>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import com.tangem.core.ui.res.TangemThemePreview
|
|||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
||||
enum class AccountIconSize {
|
||||
Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall, RedesignLarge
|
||||
Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall, RedesignLarge, Contact
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -133,6 +133,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M
|
|||
AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28
|
||||
AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11
|
||||
AccountIconSize.RedesignLarge -> TangemTheme.typography3.heading.medium
|
||||
AccountIconSize.Contact -> TangemTheme.typography3.body.medium
|
||||
}
|
||||
|
||||
val textSize by animateFloatAsState(
|
||||
|
|
@ -168,6 +169,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) {
|
|||
AccountIconSize.RedesignedDefault -> 20.dp
|
||||
AccountIconSize.RedesignExtraSmall -> 8.dp
|
||||
AccountIconSize.RedesignLarge -> 32.dp
|
||||
AccountIconSize.Contact -> 20.dp
|
||||
}
|
||||
|
||||
fun AccountIconSize.toBoxSize(): Dp = when (this) {
|
||||
|
|
@ -179,6 +181,7 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) {
|
|||
AccountIconSize.RedesignedDefault -> 40.dp
|
||||
AccountIconSize.RedesignExtraSmall -> 16.dp
|
||||
AccountIconSize.RedesignLarge -> 80.dp
|
||||
AccountIconSize.Contact -> 40.dp
|
||||
}
|
||||
|
||||
private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
|
||||
|
|
@ -190,6 +193,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
|
|||
AccountIconSize.RedesignedDefault -> 12.dp
|
||||
AccountIconSize.RedesignExtraSmall -> 6.dp
|
||||
AccountIconSize.RedesignLarge -> 80.dp
|
||||
AccountIconSize.Contact -> 100.dp
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
|
|
@ -233,7 +237,8 @@ private fun Sample() {
|
|||
AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault
|
||||
AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall
|
||||
AccountIconSize.RedesignExtraSmall -> AccountIconSize.RedesignLarge
|
||||
AccountIconSize.RedesignLarge -> AccountIconSize.Default
|
||||
AccountIconSize.RedesignLarge -> AccountIconSize.Contact
|
||||
AccountIconSize.Contact -> AccountIconSize.Default
|
||||
}
|
||||
}) { Text("Change") }
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ data class AddressEntry(
|
|||
val id: AddressEntryId,
|
||||
val address: String,
|
||||
val networkId: Network.RawID,
|
||||
val networkName: String,
|
||||
val memo: String?,
|
||||
val signature: String,
|
||||
)
|
||||
|
|
@ -259,6 +259,7 @@ internal class AddressBookCipherTest {
|
|||
networkId = Network.RawID("ethereum"),
|
||||
memo = memo,
|
||||
signature = "",
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1)
|
||||
|
|
|
|||
|
|
@ -5,23 +5,14 @@ import arrow.core.right
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.error.SignHashesError
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
|
|
@ -57,6 +48,7 @@ class CreateContactUseCaseTest {
|
|||
networkId = networkRawId,
|
||||
memo = "memo",
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -165,6 +157,7 @@ class CreateContactUseCaseTest {
|
|||
networkId = networkRawId,
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ class GetContactsUseCaseTest {
|
|||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,7 @@ package com.tangem.domain.addressbook.usecase
|
|||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.model.AddressEntriesVerification
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.model.VerifiedContact
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
|
@ -103,6 +97,7 @@ class GetVerifiedContactsUseCaseTest {
|
|||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "sig-$id",
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private fun contact(name: String, entries: List<AddressEntry>): Contact = Contact(
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ class SignAddressEntriesUseCaseTest {
|
|||
networkId = Network.RawID("ethereum"),
|
||||
memo = memo,
|
||||
signature = "",
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class UpdateContactUseCaseTest {
|
|||
networkId = networkRawId,
|
||||
memo = "memo",
|
||||
signature = "sig2",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -137,6 +138,7 @@ class UpdateContactUseCaseTest {
|
|||
networkId = networkRawId,
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class ValidateContactNameUseCaseTest {
|
|||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ class VerifyAddressEntriesUseCaseTest {
|
|||
networkId = Network.RawID("ethereum"),
|
||||
memo = memo,
|
||||
signature = signature,
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private fun expectedPayload(contact: Contact, entry: AddressEntry): String =
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ dependencies {
|
|||
|
||||
/* Project - Common */
|
||||
api(projects.common.routing)
|
||||
implementation(projects.common.ui)
|
||||
|
||||
/* Project - Domain */
|
||||
implementation(projects.domain.models)
|
||||
|
|
@ -22,4 +23,7 @@ dependencies {
|
|||
|
||||
/* Compose */
|
||||
implementation(deps.compose.runtime)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The contacts block shown on the Send address-entry screen, below the "recent" block. Lists up to five contacts that
|
||||
* have an address in [Params.network], filtered live by [Params.queryFlow] (the recipient-input text, matched against
|
||||
* contact name or address). Hidden when there are no matching contacts.
|
||||
*/
|
||||
interface AddressBookContactsBlockComponent : ComposableContentComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, AddressBookContactsBlockComponent>
|
||||
|
||||
/**
|
||||
* @property userWalletId the sending wallet whose address book is shown
|
||||
* @property network the current send network; only contacts with an address in this network are shown
|
||||
* @property queryFlow the live recipient-input text used to filter the block
|
||||
* @property onContactClick invoked with the tapped contact and its network-matching entries; the host decides
|
||||
* whether to apply it directly (single entry) or open the address selector (multiple entries)
|
||||
* @property onSeeAllClick invoked when the user taps "See all" to open the full address book in selection mode
|
||||
*/
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val network: Network,
|
||||
val queryFlow: StateFlow<String>,
|
||||
val onContactClick: (MatchedContact) -> Unit,
|
||||
val onSeeAllClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
|
||||
/**
|
||||
* Bottom sheet shown when a picked contact has more than one address in the target network. Lets the user choose a
|
||||
* concrete address; the chosen one is returned via [Params.onAddressSelected] as a [SelectedContact].
|
||||
*/
|
||||
interface AddressSelectorComponent : ComposableBottomSheetComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, AddressSelectorComponent>
|
||||
|
||||
data class Params(
|
||||
val contact: MatchedContact,
|
||||
val onAddressSelected: (SelectedContact) -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
/**
|
||||
* Delivers a contact picked in the full address-book list (opened in selection mode) back to whatever feature
|
||||
* requested the selection. The picker and the requesting feature live in independent model scopes, so a one-shot
|
||||
* [SharedFlow] is used instead of a retained holder: nothing is kept after emission, so there is nothing to clear.
|
||||
*
|
||||
* Mirrors the `SwapChooseTokenNetworkTrigger`/`Listener` pattern.
|
||||
*/
|
||||
interface ContactSelectionTrigger {
|
||||
|
||||
fun trigger(contact: SelectedContact)
|
||||
}
|
||||
|
||||
interface ContactSelectionListener {
|
||||
|
||||
val resultFlow: SharedFlow<SelectedContact>
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* A contact together with its address entries that match a given network. Emitted when a contact is tapped during
|
||||
* selection; the host decides what to do with it:
|
||||
* - exactly one [entries] item → build a [SelectedContact] and proceed straight away;
|
||||
* - more than one → open the address selector so the user picks a concrete address first.
|
||||
*/
|
||||
|
||||
data class MatchedContact(
|
||||
val contactId: String,
|
||||
val name: String,
|
||||
val icon: AccountIconUM.CryptoPortfolio,
|
||||
val networkId: String,
|
||||
val entries: ImmutableList<ContactAddress>,
|
||||
) {
|
||||
|
||||
/** Resolves this contact to a concrete pick using one of its [entries]. */
|
||||
fun toSelectedContact(entry: ContactAddress): SelectedContact = SelectedContact(
|
||||
contactId = contactId,
|
||||
name = name,
|
||||
icon = icon,
|
||||
address = entry.address,
|
||||
networkId = networkId,
|
||||
memo = entry.memo,
|
||||
)
|
||||
|
||||
/** A single network-matching address of the contact. */
|
||||
data class ContactAddress(
|
||||
val address: String,
|
||||
val memo: String?,
|
||||
val networkName: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
|
||||
/**
|
||||
* A single resolved address-book pick. Produced once the concrete address within a contact is known — either directly
|
||||
* (the contact has a single matching-network address) or after the user chose one in the address selector.
|
||||
*
|
||||
* Feature-agnostic: any feature that opens the address book for selection receives this result.
|
||||
*
|
||||
* @property contactId the id of the source [com.tangem.domain.addressbook.model.Contact]
|
||||
* @property name the contact name to display
|
||||
* @property icon the contact avatar (initials + color), reusing the account icon UI model
|
||||
* @property address the chosen on-chain address
|
||||
* @property networkId raw id of the network the address belongs to
|
||||
* @property memo optional memo/destination tag (only meaningful for networks that support it)
|
||||
*/
|
||||
data class SelectedContact(
|
||||
val contactId: String,
|
||||
val name: String,
|
||||
val icon: AccountIconUM.CryptoPortfolio,
|
||||
val address: String,
|
||||
val networkId: String,
|
||||
val memo: String?,
|
||||
)
|
||||
|
|
@ -11,19 +11,17 @@ import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
|
|||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class AddAddressStateController @Inject constructor() {
|
||||
|
||||
private val mutableUiState: MutableStateFlow<AddAddressUM> = MutableStateFlow(value = getInitialState())
|
||||
|
||||
val uiState: StateFlow<AddAddressUM> get() = mutableUiState.asStateFlow()
|
||||
val uiState: StateFlow<AddAddressUM>
|
||||
field = MutableStateFlow(value = getInitialState())
|
||||
|
||||
fun update(transformer: Transformer<AddAddressUM>) {
|
||||
mutableUiState.update(function = transformer::transform)
|
||||
uiState.update(function = transformer::transform)
|
||||
}
|
||||
|
||||
private fun getInitialState(): AddAddressUM = AddAddressUM(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.features.addressbook.addressselector
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.addressselector.ui.AddressSelectorBottomSheet
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultAddressSelectorComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: AddressSelectorComponent.Params,
|
||||
) : AddressSelectorComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
override fun dismiss() = params.onDismiss()
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
AddressSelectorBottomSheet(
|
||||
contact = params.contact,
|
||||
onAddressClick = { entry -> params.onAddressSelected(params.contact.toSelectedContact(entry)) },
|
||||
onDismiss = ::dismiss,
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressSelectorComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressSelectorComponent.Params,
|
||||
): DefaultAddressSelectorComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package com.tangem.features.addressbook.addressselector.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBarType
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowText
|
||||
import com.tangem.core.ui.ds2.row.TangemRowTextRole
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.impl.R
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
internal fun AddressSelectorBottomSheet(
|
||||
contact: MatchedContact,
|
||||
onAddressClick: (MatchedContact.ContactAddress) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = onDismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
),
|
||||
containerColor = TangemTheme.colors3.bg.primary,
|
||||
title = {
|
||||
TangemTopBar(
|
||||
title = resourceReference(R.string.address_book_choose_address),
|
||||
type = TangemTopBarType.BottomSheet,
|
||||
endContent = {
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
|
||||
onClick = onDismiss,
|
||||
size = TangemButton.Size.X11,
|
||||
variant = TangemButton.Variant.Material,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { AddressSelectorList(contact = contact, onAddressClick = onAddressClick) },
|
||||
footer = {
|
||||
TangemButton(
|
||||
onClick = onDismiss,
|
||||
text = resourceReference(R.string.common_cancel),
|
||||
variant = TangemButton.Variant.Secondary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressSelectorList(
|
||||
contact: MatchedContact,
|
||||
onAddressClick: (MatchedContact.ContactAddress) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = TangemTheme.colors3.bg.secondary,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
contact.entries.fastForEach { entry ->
|
||||
AddressRow(entry = entry, onClick = { onAddressClick(entry) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressRow(entry: MatchedContact.ContactAddress, onClick: () -> Unit) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
onClick = onClick,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Ident(entry.address),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = entry.address,
|
||||
role = TangemRowTextRole.Title,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = entry.networkName,
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_AddressSelectorList() {
|
||||
TangemThemePreviewRedesign {
|
||||
AddressSelectorList(
|
||||
contact = MatchedContact(
|
||||
contactId = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkId = "ethereum",
|
||||
entries = persistentListOf(
|
||||
MatchedContact.ContactAddress(
|
||||
address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
|
||||
memo = null,
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
MatchedContact.ContactAddress(
|
||||
address = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
|
||||
memo = "12345",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
),
|
||||
onAddressClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.features.addressbook.block
|
||||
|
||||
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.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.block.model.ContactsBlockModel
|
||||
import com.tangem.features.addressbook.block.ui.ContactsBlock
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultAddressBookContactsBlockComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted params: AddressBookContactsBlockComponent.Params,
|
||||
) : AddressBookContactsBlockComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: ContactsBlockModel = getOrCreateModel(params)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
ContactsBlock(state = state, modifier = modifier)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressBookContactsBlockComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressBookContactsBlockComponent.Params,
|
||||
): DefaultAddressBookContactsBlockComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.tangem.features.addressbook.block.model
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.block.state.ContactsBlockStateController
|
||||
import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.features.addressbook.common.ContactMatcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@ModelScoped
|
||||
internal class ContactsBlockModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: ContactsBlockStateController,
|
||||
getContactsUseCase: GetContactsUseCase,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
|
||||
|
||||
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
params.queryFlow
|
||||
.flatMapLatest { query -> getContactsUseCase(query = query, userWalletId = params.userWalletId) }
|
||||
.onEach { contacts ->
|
||||
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
|
||||
stateController.update(
|
||||
UpdateContactsBlockStateTransformer(
|
||||
matched = matched,
|
||||
onSeeAllClick = params.onSeeAllClick,
|
||||
onContactClick = params.onContactClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.addressbook.block.state
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class ContactsBlockStateController @Inject constructor() {
|
||||
|
||||
val uiState: StateFlow<ContactsBlockUM>
|
||||
field = MutableStateFlow<ContactsBlockUM>(value = ContactsBlockUM.Hidden)
|
||||
|
||||
fun update(transformer: Transformer<ContactsBlockUM>) {
|
||||
uiState.update(function = transformer::transform)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.tangem.features.addressbook.block.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/** Builds the Send contacts block from the network-matching contacts; an empty result hides the block. */
|
||||
internal class UpdateContactsBlockStateTransformer(
|
||||
private val matched: List<MatchedContact>,
|
||||
private val onSeeAllClick: () -> Unit,
|
||||
private val onContactClick: (MatchedContact) -> Unit,
|
||||
) : Transformer<ContactsBlockUM> {
|
||||
|
||||
override fun transform(prevState: ContactsBlockUM): ContactsBlockUM {
|
||||
return if (matched.isEmpty()) {
|
||||
ContactsBlockUM.Hidden
|
||||
} else {
|
||||
ContactsBlockUM.Content(
|
||||
contacts = matched
|
||||
.take(MAX_CONTACTS)
|
||||
.map { it.toRowUM() }.toImmutableList(),
|
||||
onSeeAllClick = onSeeAllClick,
|
||||
shouldShowSeeAll = matched.size > MAX_CONTACTS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MatchedContact.toRowUM(): ContactUM = ContactUM(
|
||||
id = contactId,
|
||||
name = name,
|
||||
icon = icon,
|
||||
networkAddressCount = entries.size,
|
||||
onClick = { onContactClick(this) },
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MAX_CONTACTS = 5
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package com.tangem.features.addressbook.block.ui
|
||||
|
||||
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.shape.RoundedCornerShape
|
||||
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.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.features.addressbook.common.ui.ContactRow
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
internal fun ContactsBlock(state: ContactsBlockUM, modifier: Modifier = Modifier) {
|
||||
if (state !is ContactsBlockUM.Content) return
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
Header(onSeeAllClick = state.onSeeAllClick, shouldShowSeeAll = state.shouldShowSeeAll)
|
||||
state.contacts.forEach { contact ->
|
||||
ContactRow(contact = contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(onSeeAllClick: () -> Unit, shouldShowSeeAll: Boolean) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 16.dp, bottom = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_title),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (shouldShowSeeAll) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.common_view_all),
|
||||
color = TangemTheme.colors3.text.brand,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
modifier = Modifier.clickable(onClick = onSeeAllClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_ContactsBlock() {
|
||||
TangemThemePreviewRedesign {
|
||||
ContactsBlock(
|
||||
state = ContactsBlockUM.Content(
|
||||
contacts = persistentListOf(
|
||||
ContactUM(
|
||||
id = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = 1,
|
||||
onClick = {},
|
||||
),
|
||||
ContactUM(
|
||||
id = "2",
|
||||
name = "Alice",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.UFOGreen,
|
||||
),
|
||||
networkAddressCount = 3,
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
onSeeAllClick = {},
|
||||
shouldShowSeeAll = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.features.addressbook.block.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/** UI state of the Send contacts block. [Hidden] is rendered as nothing (no matching contacts / feature off). */
|
||||
@Immutable
|
||||
internal sealed interface ContactsBlockUM {
|
||||
|
||||
data object Hidden : ContactsBlockUM
|
||||
|
||||
data class Content(
|
||||
val shouldShowSeeAll: Boolean,
|
||||
val contacts: ImmutableList<ContactUM>,
|
||||
val onSeeAllClick: () -> Unit,
|
||||
) : ContactsBlockUM
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.tangem.features.addressbook.common
|
|||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
|
|
@ -15,19 +16,23 @@ import javax.inject.Inject
|
|||
* Builds the child screens of the address book feature for a given [AddressBookRoute], wiring their callbacks to the
|
||||
* container's [AddressBookClickIntents]. Mirrors the `FeedEntryChildFactory` pattern used by the feed feature.
|
||||
*/
|
||||
internal class AddressBookChildFactory @Inject constructor() {
|
||||
internal class AddressBookChildFactory @Inject constructor(
|
||||
private val addressSelectorFactory: AddressSelectorComponent.Factory,
|
||||
) {
|
||||
|
||||
fun createChild(
|
||||
route: AddressBookRoute,
|
||||
context: AppComponentContext,
|
||||
clickIntents: AddressBookClickIntents,
|
||||
): ComposableContentComponent = when (route) {
|
||||
AddressBookRoute.List -> DefaultAddressBookListComponent(
|
||||
is AddressBookRoute.List -> DefaultAddressBookListComponent(
|
||||
appComponentContext = context,
|
||||
params = DefaultAddressBookListComponent.Params(
|
||||
mode = route.mode,
|
||||
onContactClick = { clickIntents.onContactClick(ContactId(it)) },
|
||||
onAddContactClick = clickIntents::onAddContactClick,
|
||||
),
|
||||
addressSelectorFactory = addressSelectorFactory,
|
||||
)
|
||||
is AddressBookRoute.EditContact -> DefaultEditContactComponent(
|
||||
appComponentContext = context,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* Maps contacts to [MatchedContact]s for a given [networkId], keeping only those that have at least one address in that
|
||||
* network (with just the matching entries). Name/address query filtering is done upstream by `GetContactsUseCase`.
|
||||
*/
|
||||
internal object ContactMatcher {
|
||||
|
||||
private val DEFAULT_ICON_COLOR = CryptoPortfolioIcon.Color.Azure
|
||||
|
||||
fun match(contacts: List<Contact>, networkId: String): List<MatchedContact> {
|
||||
return contacts.mapNotNull { contact ->
|
||||
val entries = contact.addressEntries.filter { it.networkId.value == networkId }
|
||||
if (entries.isEmpty()) return@mapNotNull null
|
||||
|
||||
MatchedContact(
|
||||
contactId = contact.id.value,
|
||||
name = contact.name.value,
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = contact.resolveIconColor(),
|
||||
),
|
||||
networkId = networkId,
|
||||
entries = entries.map { entry ->
|
||||
MatchedContact.ContactAddress(
|
||||
address = entry.address,
|
||||
memo = entry.memo,
|
||||
networkName = entry.networkName,
|
||||
)
|
||||
}.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Contact.resolveIconColor(): CryptoPortfolioIcon.Color =
|
||||
CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor } ?: DEFAULT_ICON_COLOR
|
||||
}
|
||||
|
|
@ -96,9 +96,12 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
|||
}
|
||||
|
||||
private fun initialStack(): List<AddressBookRoute> = when (val mode = params.addressBookOpenMode) {
|
||||
AddressBookOpenMode.Default -> listOf(AddressBookRoute.List)
|
||||
AddressBookOpenMode.Default -> listOf(AddressBookRoute.List())
|
||||
is AddressBookOpenMode.ContactSelection -> listOf(
|
||||
AddressBookRoute.List(mode = AddressBookRoute.ListMode.Selector(networkId = mode.networkId)),
|
||||
)
|
||||
is AddressBookOpenMode.WithContactCreation -> listOf(
|
||||
AddressBookRoute.List,
|
||||
AddressBookRoute.List(),
|
||||
// Address + network are already known, so open the new contact with that address attached — no AddAddress.
|
||||
AddressBookRoute.EditContact(
|
||||
predefinedAddress = mode.address,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.features.addressbook.ContactSelectionListener
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.SelectedContact
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One-shot delivery of a contact picked on the full address-book list back to the Send flow.
|
||||
*
|
||||
* Implements both [ContactSelectionTrigger] (the list emits) and [ContactSelectionListener] (Send collects). The flow
|
||||
* is no-replay with a 1-item buffer so [trigger] is non-blocking ([tryEmit]) — the picker can always close even if the
|
||||
* collector is momentarily absent; nothing is retained for late subscribers, so there is no stale value to clear.
|
||||
*/
|
||||
@Singleton
|
||||
internal class DefaultContactSelectionTrigger @Inject constructor() :
|
||||
ContactSelectionTrigger,
|
||||
ContactSelectionListener {
|
||||
|
||||
private val mutableResultFlow = MutableSharedFlow<SelectedContact>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
override val resultFlow: SharedFlow<SelectedContact> = mutableResultFlow
|
||||
|
||||
override fun trigger(contact: SelectedContact) {
|
||||
mutableResultFlow.tryEmit(contact)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.tangem.features.addressbook.common.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.common.ui.account.AccountIcon
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.ds2.row.*
|
||||
import com.tangem.core.ui.extensions.pluralStringResourceSafe
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
|
||||
@Composable
|
||||
internal fun ContactRow(contact: ContactUM) {
|
||||
TangemRow(
|
||||
onClick = contact.onClick,
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
contentLead = TangemRowContentLead.Start,
|
||||
startSlot = {
|
||||
AccountIcon(
|
||||
name = stringReference(contact.name),
|
||||
icon = contact.icon,
|
||||
size = AccountIconSize.Contact,
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = contact.name,
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = pluralStringResourceSafe(
|
||||
R.plurals.address_book_addresses,
|
||||
contact.networkAddressCount,
|
||||
contact.networkAddressCount,
|
||||
),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_ContactRow() {
|
||||
TangemThemePreviewRedesign {
|
||||
ContactRow(
|
||||
ContactUM(
|
||||
id = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = 1,
|
||||
onClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
package com.tangem.features.addressbook.di
|
||||
|
||||
import com.tangem.features.addressbook.AddressBookComponent
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.ContactSelectionListener
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.addressselector.DefaultAddressSelectorComponent
|
||||
import com.tangem.features.addressbook.block.DefaultAddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.common.DefaultAddressBookComponent
|
||||
import com.tangem.features.addressbook.common.DefaultContactSelectionTrigger
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -15,4 +22,24 @@ internal interface AddressBookComponentModule {
|
|||
@Binds
|
||||
@Singleton
|
||||
fun bindAddressBookComponentFactory(factory: DefaultAddressBookComponent.Factory): AddressBookComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindContactsBlockComponentFactory(
|
||||
factory: DefaultAddressBookContactsBlockComponent.Factory,
|
||||
): AddressBookContactsBlockComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindAddressSelectorComponentFactory(
|
||||
factory: DefaultAddressSelectorComponent.Factory,
|
||||
): AddressSelectorComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindContactSelectionTrigger(impl: DefaultContactSelectionTrigger): ContactSelectionTrigger
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindContactSelectionListener(impl: DefaultContactSelectionTrigger): ContactSelectionListener
|
||||
}
|
||||
|
|
@ -3,6 +3,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.block.model.ContactsBlockModel
|
||||
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
||||
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
||||
import dagger.Binds
|
||||
|
|
@ -20,6 +21,11 @@ internal interface AddressBookModelModule {
|
|||
@ClassKey(AddressBookListModel::class)
|
||||
fun bindAddressBookModel(model: AddressBookListModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(ContactsBlockModel::class)
|
||||
fun bindContactsBlockModel(model: ContactsBlockModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(EditContactModel::class)
|
||||
|
|
|
|||
|
|
@ -12,19 +12,17 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class EditContactStateController @Inject constructor() {
|
||||
|
||||
private val mutableUiState: MutableStateFlow<EditContactUM> = MutableStateFlow(value = getInitialState())
|
||||
|
||||
val uiState: StateFlow<EditContactUM> get() = mutableUiState.asStateFlow()
|
||||
val uiState: StateFlow<EditContactUM>
|
||||
field = MutableStateFlow(value = getInitialState())
|
||||
|
||||
fun update(transformer: Transformer<EditContactUM>) {
|
||||
mutableUiState.update(function = transformer::transform)
|
||||
uiState.update(function = transformer::transform)
|
||||
}
|
||||
|
||||
private fun getInitialState(): EditContactUM {
|
||||
|
|
|
|||
|
|
@ -5,35 +5,72 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.slot.childSlot
|
||||
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.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.AddressBookListScreen
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
|
||||
internal class DefaultAddressBookListComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
addressSelectorFactory: AddressSelectorComponent.Factory,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: AddressBookListModel = getOrCreateModel(params)
|
||||
|
||||
private val selectorSlot = childSlot(
|
||||
source = model.selectorNavigation,
|
||||
serializer = null,
|
||||
key = "address_selector_slot",
|
||||
handleBackButton = true,
|
||||
childFactory = { contact, componentContext ->
|
||||
addressSelectorFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = AddressSelectorComponent.Params(
|
||||
contact = contact,
|
||||
onAddressSelected = model::deliverSelection,
|
||||
onDismiss = { model.selectorNavigation.dismiss() },
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
val selector by selectorSlot.subscribeAsState()
|
||||
when (val addressBookListUM = state) {
|
||||
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
|
||||
onAddContactClick = addressBookListUM.onAddClick,
|
||||
onBackClick = router::pop,
|
||||
modifier = modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
is AddressBookListUM.AddressList -> TODO("[REDACTED_TASK_KEY]")
|
||||
is AddressBookListUM.Content -> AddressBookListScreen(
|
||||
state = addressBookListUM,
|
||||
onBackClick = router::pop,
|
||||
modifier = modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
selector.child?.instance?.BottomSheet()
|
||||
}
|
||||
|
||||
/**
|
||||
* @property mode Default (management) or Selector (pick a contact for a network)
|
||||
* @property onContactClick management mode — opens the contact editor (TODO [REDACTED_TASK_KEY])
|
||||
* @property onAddContactClick opens the new-contact editor
|
||||
*/
|
||||
data class Params(
|
||||
val mode: AddressBookRoute.ListMode,
|
||||
val onContactClick: (String) -> Unit,
|
||||
val onAddContactClick: () -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,30 +1,92 @@
|
|||
package com.tangem.features.addressbook.list.model
|
||||
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
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.decompose.navigation.Router
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.SelectedContact
|
||||
import com.tangem.features.addressbook.common.ContactMatcher
|
||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListInitialStateTransformer
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListSelectionStateTransformer
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Backs the contacts list. The list content is the same however the address book was opened — the open
|
||||
* [AddressBookRoute.ListMode] only decides what tapping a contact does:
|
||||
* - [AddressBookRoute.ListMode.Default]: browse / manage contacts (full UI is TODO [REDACTED_TASK_KEY]).
|
||||
* - [AddressBookRoute.ListMode.Selector]: pick a recipient for the given network — a single matching address is
|
||||
* returned right away, several open the address selector first.
|
||||
*/
|
||||
@ModelScoped
|
||||
internal class AddressBookListModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: AddressBookListStateController,
|
||||
private val router: Router,
|
||||
private val contactSelectionTrigger: ContactSelectionTrigger,
|
||||
private val getContactsUseCase: GetContactsUseCase,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<DefaultAddressBookListComponent.Params>()
|
||||
|
||||
val state: StateFlow<AddressBookListUM> get() = stateController.uiState
|
||||
|
||||
/** Address-selector bottom sheet, shown when a picked contact has more than one address in the target network. */
|
||||
val selectorNavigation = SlotNavigation<MatchedContact>()
|
||||
|
||||
init {
|
||||
stateController.update(
|
||||
UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick),
|
||||
)
|
||||
when (val mode = params.mode) {
|
||||
// Browse/manage: full list UI is TODO [REDACTED_TASK_KEY].
|
||||
AddressBookRoute.ListMode.Default -> stateController.update(
|
||||
UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick),
|
||||
)
|
||||
// Pick a recipient: same list, the tap returns the chosen address.
|
||||
is AddressBookRoute.ListMode.Selector -> observeSelectionContacts(networkId = mode.networkId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSelectionContacts(networkId: String) {
|
||||
getContactsUseCase(query = "")
|
||||
.onEach { contacts ->
|
||||
stateController.update(
|
||||
UpdateAddressBookListSelectionStateTransformer(
|
||||
matched = ContactMatcher.match(contacts = contacts, networkId = networkId),
|
||||
onAddContactClick = params.onAddContactClick,
|
||||
onContactClick = ::onPickContact,
|
||||
),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun onPickContact(contact: MatchedContact) {
|
||||
val singleEntry = contact.entries.singleOrNull()
|
||||
if (singleEntry != null) {
|
||||
deliverSelection(contact.toSelectedContact(singleEntry))
|
||||
} else {
|
||||
selectorNavigation.activate(contact)
|
||||
}
|
||||
}
|
||||
|
||||
fun deliverSelection(contact: SelectedContact) {
|
||||
contactSelectionTrigger.trigger(contact)
|
||||
selectorNavigation.dismiss()
|
||||
router.pop()
|
||||
}
|
||||
}
|
||||
|
|
@ -5,20 +5,17 @@ import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
|||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class AddressBookListStateController @Inject constructor() {
|
||||
|
||||
private val mutableUiState: MutableStateFlow<AddressBookListUM> =
|
||||
MutableStateFlow(value = getInitialState())
|
||||
|
||||
val uiState: StateFlow<AddressBookListUM> get() = mutableUiState.asStateFlow()
|
||||
val uiState: StateFlow<AddressBookListUM>
|
||||
field = MutableStateFlow(value = getInitialState())
|
||||
|
||||
fun update(transformer: Transformer<AddressBookListUM>) {
|
||||
mutableUiState.update(function = transformer::transform)
|
||||
uiState.update(function = transformer::transform)
|
||||
}
|
||||
|
||||
private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {})
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
package com.tangem.features.addressbook.list.state.converter
|
||||
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
/**
|
||||
* Maps a domain [Contact] to its UI representation [ContactUM].
|
||||
*
|
||||
* TODO AddressBook ([REDACTED_TASK_KEY]): wire into [com.tangem.features.addressbook.list.model.AddressBookListModel] when the contacts list
|
||||
* is loaded from the repository and the [com.tangem.features.addressbook.list.ui.state.AddressBookListUM.AddressList]
|
||||
* screen is implemented.
|
||||
*/
|
||||
internal class ContactUMConverter : Converter<Contact, ContactUM> {
|
||||
|
||||
override fun convert(value: Contact): ContactUM = ContactUM(
|
||||
id = value.id.value,
|
||||
name = value.name.value,
|
||||
addressCount = value.addressEntries.size,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
|
|
@ -13,7 +14,9 @@ internal class UpdateAddressBookListInitialStateTransformer(
|
|||
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
|
||||
return when (prevState) {
|
||||
is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick)
|
||||
is AddressBookListUM.AddressList -> prevState
|
||||
is AddressBookListUM.Content -> prevState.copy(
|
||||
contentMode = ContentMode.Default(onAddClick = onAddContactClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* Builds the contacts list from the [matched] contacts. An empty result falls back to [AddressBookListUM.Empty] so the
|
||||
* user can still add a contact.
|
||||
*/
|
||||
internal class UpdateAddressBookListSelectionStateTransformer(
|
||||
private val matched: List<MatchedContact>,
|
||||
private val onAddContactClick: () -> Unit,
|
||||
private val onContactClick: (MatchedContact) -> Unit,
|
||||
) : Transformer<AddressBookListUM> {
|
||||
|
||||
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
|
||||
return if (matched.isEmpty()) {
|
||||
AddressBookListUM.Empty(onAddClick = onAddContactClick)
|
||||
} else {
|
||||
AddressBookListUM.Content(
|
||||
contacts = matched.map { it.toContactUM() }.toImmutableList(),
|
||||
contentMode = ContentMode.Select,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MatchedContact.toContactUM(): ContactUM = ContactUM(
|
||||
id = contactId,
|
||||
name = name,
|
||||
icon = icon,
|
||||
networkAddressCount = entries.size,
|
||||
onClick = { onContactClick(this) },
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package com.tangem.features.addressbook.list.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.R
|
||||
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.resourceReference
|
||||
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_chevron_left_20
|
||||
import com.tangem.core.ui.res.generated.icons.ic_cross_20
|
||||
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.common.ui.ContactRow
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
internal fun AddressBookListScreen(
|
||||
state: AddressBookListUM.Content,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
TangemTopBar(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
title = resourceReference(R.string.address_book_title),
|
||||
startContent = when (state.contentMode) {
|
||||
is ContentMode.Default -> {
|
||||
{
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_20),
|
||||
onClick = onBackClick,
|
||||
size = TangemButton.Size.X11,
|
||||
variant = TangemButton.Variant.Material,
|
||||
)
|
||||
}
|
||||
}
|
||||
ContentMode.Select -> null
|
||||
},
|
||||
endContent = {
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(
|
||||
imageVector = when (state.contentMode) {
|
||||
is ContentMode.Default -> Icons.ic_sign_plus_20
|
||||
ContentMode.Select -> Icons.ic_cross_20
|
||||
},
|
||||
),
|
||||
onClick = when (state.contentMode) {
|
||||
is ContentMode.Default -> state.contentMode.onAddClick
|
||||
ContentMode.Select -> onBackClick
|
||||
},
|
||||
size = TangemButton.Size.X11,
|
||||
variant = TangemButton.Variant.Material,
|
||||
)
|
||||
},
|
||||
)
|
||||
LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
items(items = state.contacts, key = ContactUM::id) { contact ->
|
||||
ContactRow(contact = contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_AddressBookListScreen() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
AddressBookListScreen(
|
||||
state = AddressBookListUM.Content(
|
||||
contacts = persistentListOf(
|
||||
ContactUM(
|
||||
id = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = 1,
|
||||
onClick = {},
|
||||
),
|
||||
ContactUM(
|
||||
id = "2",
|
||||
name = "Alice",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.UFOGreen,
|
||||
),
|
||||
networkAddressCount = 3,
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
contentMode = ContentMode.Default(onAddClick = {}),
|
||||
),
|
||||
onBackClick = {},
|
||||
)
|
||||
|
||||
AddressBookListScreen(
|
||||
state = AddressBookListUM.Content(
|
||||
contacts = persistentListOf(
|
||||
ContactUM(
|
||||
id = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = 1,
|
||||
onClick = {},
|
||||
),
|
||||
ContactUM(
|
||||
id = "2",
|
||||
name = "Alice",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.UFOGreen,
|
||||
),
|
||||
networkAddressCount = 3,
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
contentMode = ContentMode.Select,
|
||||
),
|
||||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,26 @@ package com.tangem.features.addressbook.list.ui.state
|
|||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* UI state of the contacts list. The list itself is the same however the address book was opened — it is either
|
||||
* [Empty] or shows [Content]. How the address book was opened (browse vs. pick a recipient) only changes what a
|
||||
* contact tap does, which is captured by [ContactUM.onClick], not by a separate state.
|
||||
*/
|
||||
@Immutable
|
||||
internal sealed interface AddressBookListUM {
|
||||
|
||||
data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
|
||||
|
||||
data class AddressList(val contacts: ImmutableList<ContactUM>) : AddressBookListUM
|
||||
data class Content(
|
||||
val contacts: ImmutableList<ContactUM>,
|
||||
val contentMode: ContentMode,
|
||||
) : AddressBookListUM
|
||||
}
|
||||
|
||||
@Immutable
|
||||
internal sealed interface ContentMode {
|
||||
|
||||
data class Default(val onAddClick: () -> Unit) : ContentMode
|
||||
|
||||
data object Select : ContentMode
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
package com.tangem.features.addressbook.list.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
|
||||
/** UI model of a single address-book contact row. Holds only what the list needs to render — no domain types. */
|
||||
@Immutable
|
||||
internal data class ContactUM(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val addressCount: Int,
|
||||
val icon: AccountIconUM.CryptoPortfolio,
|
||||
val networkAddressCount: Int,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -5,14 +5,19 @@ import kotlinx.serialization.Serializable
|
|||
@Serializable
|
||||
internal sealed class AddressBookRoute {
|
||||
|
||||
/**
|
||||
* The contacts list. [mode] mirrors the entry point: [ListMode.Default] for plain browsing/management, and
|
||||
* [ListMode.Selector] when the list is opened to pick a contact for a given network — a tap then returns the
|
||||
* chosen address instead of opening the editor.
|
||||
*/
|
||||
@Serializable
|
||||
data object List : AddressBookRoute()
|
||||
data class List(val mode: ListMode = ListMode.Default) : AddressBookRoute()
|
||||
|
||||
/**
|
||||
* if [contactId] is not null we should fetch existing contact.
|
||||
*
|
||||
* [predefinedAddress] and [predefinedNetworkId] are set only when the feature is opened in
|
||||
* [com.tangem.features.addressbook.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.
|
||||
*/
|
||||
@Serializable
|
||||
|
|
@ -24,4 +29,16 @@ internal sealed class AddressBookRoute {
|
|||
|
||||
@Serializable
|
||||
data object AddAddress : AddressBookRoute()
|
||||
|
||||
/** How the contacts list is shown — agnostic of which feature opened it. */
|
||||
@Serializable
|
||||
sealed interface ListMode {
|
||||
|
||||
@Serializable
|
||||
data object Default : ListMode
|
||||
|
||||
/** Pick a contact that has an address in [networkId]. */
|
||||
@Serializable
|
||||
data class Selector(val networkId: String) : ListMode
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class ContactMatcherTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN contacts WHEN match THEN keeps only contacts with an address in the network`() {
|
||||
// Arrange
|
||||
val ethContact = contact("Binance", entry("0xAAA", ETHEREUM), entry("Trx", TRON))
|
||||
val tronOnly = contact("Tron Friend", entry("Trx2", TRON))
|
||||
|
||||
// Act
|
||||
val result = ContactMatcher.match(listOf(ethContact, tronOnly), networkId = ETHEREUM)
|
||||
|
||||
// Assert
|
||||
assertThat(result.map { it.name }).containsExactly("Binance")
|
||||
assertThat(result.single().entries.map { it.address }).containsExactly("0xAAA")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no contact in the network WHEN match THEN returns empty`() {
|
||||
// Arrange
|
||||
val tronOnly = contact("Tron Friend", entry("Trx", TRON))
|
||||
|
||||
// Act
|
||||
val result = ContactMatcher.match(listOf(tronOnly), networkId = ETHEREUM)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with multiple addresses in the network WHEN match THEN all those entries are returned`() {
|
||||
// Arrange
|
||||
val exchange = contact("Exchange", entry("0xAAA", ETHEREUM, memo = "1"), entry("0xBBB", ETHEREUM))
|
||||
|
||||
// Act
|
||||
val result = ContactMatcher.match(listOf(exchange), networkId = ETHEREUM)
|
||||
|
||||
// Assert
|
||||
val entries = result.single().entries
|
||||
assertThat(entries.map { it.address }).containsExactly("0xAAA", "0xBBB")
|
||||
assertThat(entries.first { it.address == "0xAAA" }.memo).isEqualTo("1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with stored color WHEN match THEN avatar color is taken from the contact`() {
|
||||
// Arrange
|
||||
val contact = contact("Binance", entry("0xAAA", ETHEREUM), iconColor = "MexicanPink")
|
||||
|
||||
// Act
|
||||
val result = ContactMatcher.match(listOf(contact), networkId = ETHEREUM)
|
||||
|
||||
// Assert
|
||||
assertThat(result.single().icon.color).isEqualTo(CryptoPortfolioIcon.Color.MexicanPink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with unknown color WHEN match THEN avatar color falls back to default`() {
|
||||
// Arrange
|
||||
val contact = contact("Binance", entry("0xAAA", ETHEREUM), iconColor = "not-a-color")
|
||||
|
||||
// Act
|
||||
val result = ContactMatcher.match(listOf(contact), networkId = ETHEREUM)
|
||||
|
||||
// Assert
|
||||
assertThat(result.single().icon.color).isEqualTo(CryptoPortfolioIcon.Color.Azure)
|
||||
}
|
||||
|
||||
private fun contact(name: String, vararg entries: AddressEntry, iconColor: String = "Azure"): Contact = Contact(
|
||||
id = ContactId(name),
|
||||
walletId = UserWalletId(stringValue = "0001"),
|
||||
name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" },
|
||||
icon = "",
|
||||
iconColor = iconColor,
|
||||
createdAt = "2026-06-10T14:30:00.000Z",
|
||||
updatedAt = "2026-06-10T14:30:00.000Z",
|
||||
addressEntries = entries.toList(),
|
||||
)
|
||||
|
||||
private fun entry(address: String, networkId: String, memo: String? = null): AddressEntry = AddressEntry(
|
||||
id = AddressEntryId(address),
|
||||
address = address,
|
||||
networkId = Network.RawID(networkId),
|
||||
memo = memo,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ETHEREUM = "ethereum"
|
||||
const val TRON = "tron"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue