Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-27 14:00:46 +05:00
commit 53ffcc2918
677 changed files with 33091 additions and 6980 deletions

View file

@ -10,6 +10,10 @@ android {
dependencies {
/* Project - Common */
api(projects.common.routing)
implementation(projects.common.ui)
/* Project - Domain */
implementation(projects.domain.models)
@ -19,4 +23,7 @@ dependencies {
/* Compose */
implementation(deps.compose.runtime)
/** Other */
implementation(deps.kotlin.immutable.collections)
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.addressbook
import com.tangem.common.routing.entity.AddressBookOpenMode
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
@ -7,5 +8,5 @@ interface AddressBookComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddressBookComponent>
data class Params(val predefinedAddress: String?)
data class Params(val addressBookOpenMode: AddressBookOpenMode)
}

View file

@ -0,0 +1,30 @@
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 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 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 network: Network,
val queryFlow: StateFlow<String>,
val onContactClick: (MatchedContact) -> Unit,
val onSeeAllClick: () -> Unit,
)
}

View file

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

View file

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

View file

@ -0,0 +1,38 @@
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 walletId: 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,
)
}

View file

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

View file

@ -19,6 +19,7 @@ dependencies {
implementation(projects.domain.account)
implementation(projects.domain.addressBook)
implementation(projects.domain.models)
implementation(projects.domain.wallets)
/** Common */
implementation(projects.common.ui)

View file

@ -1,15 +0,0 @@
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<Params, AddAddressComponent>
data class Params(
val onBackClick: () -> Unit,
val onConfirm: (ValidatedAddress) -> Unit,
)
}

View file

@ -7,16 +7,15 @@ 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.core.ui.decompose.ComposableContentComponent
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
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
internal class DefaultAddAddressComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: AddAddressComponent.Params,
) : AddAddressComponent, AppComponentContext by context {
internal class DefaultAddAddressComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: AddAddressModel = getOrCreateModel(params)
@ -30,11 +29,8 @@ internal class DefaultAddAddressComponent @AssistedInject constructor(
BackHandler(onBack = state.onBackClick)
}
@AssistedFactory
interface Factory : AddAddressComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddAddressComponent.Params,
): DefaultAddAddressComponent
}
data class Params(
val onBackClick: () -> Unit,
val onConfirm: (ValidatedAddress) -> Unit,
)
}

View file

@ -1,30 +1,21 @@
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.features.addressbook.addaddress.DefaultAddAddressComponent
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddAddressInitialStateTransformer
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressInputTransformer
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
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
@ -33,12 +24,12 @@ internal class AddAddressModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
multiAccountListSupplier: MultiAccountListSupplier,
private val clipboardManager: ClipboardManager,
private val stateController: AddAddressStateController,
) : Model() {
private val params: AddAddressComponent.Params = paramsContainer.require()
private val params: DefaultAddAddressComponent.Params = paramsContainer.require()
val state: StateFlow<AddAddressUM>
field = MutableStateFlow(getInitialState())
val state: StateFlow<AddAddressUM> get() = stateController.uiState
private val availableCoins: StateFlow<List<CryptoCurrency.Coin>> = multiAccountListSupplier()
.map { accountLists ->
@ -47,6 +38,7 @@ internal class AddAddressModel @Inject constructor(
.filterIsInstance<CryptoCurrency.Coin>()
.distinctBy { it.network.id }
}
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
private val addressInput = state
@ -55,94 +47,44 @@ internal class AddAddressModel @Inject constructor(
.debounce(ADD_ADDRESS_DEBOUNCE)
init {
subscribeToAddressInput()
updateInitialState()
subscribeToAddressValidation()
}
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 updateInitialState() {
stateController.update(
UpdateAddAddressInitialStateTransformer(
onAddressChange = { onAddressChange(value = it) },
onAddressClear = { onAddressChange("") },
onPasteClick = ::onPaste,
onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
onBackClick = params.onBackClick,
onConfirmClick = ::validateAndConfirm,
),
)
}
private fun subscribeToAddressInput() {
private fun onAddressChange(value: String) {
stateController.update(UpdateAddressInputTransformer(value = value))
}
private fun subscribeToAddressValidation() {
combine(addressInput, availableCoins) { input, coins ->
getUniqueNetworks(input, coins)
UpdateAddressValidationTransformer(address = input, coins = coins)
}
.onEach { availableNetworks ->
state.update { oldState ->
oldState.copy(
availableNetworks = availableNetworks,
chosenNetworkStateUM = createChosenNetworkState(availableNetworks),
)
}
}
.onEach(stateController::update)
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun createChosenNetworkState(availableNetworks: ImmutableList<Network>): 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<CryptoCurrency.Coin>): ImmutableList<Network> {
return coins
.filter { it.network.toBlockchain().validateAddress(input) }
.map { it.network }
.toImmutableList()
}
private fun onPaste() {
onAddressChange(value = clipboardManager.getText().orEmpty(), isPasted = true)
onAddressChange(value = clipboardManager.getText().orEmpty())
}
private fun validateAndConfirm() {
// TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm
// TODO Address book ([REDACTED_TASK_KEY]): navigate to the network-selection with the address and its matching networks.
}
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
}

View file

@ -0,0 +1,48 @@
package com.tangem.features.addressbook.addaddress.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.R
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.features.addressbook.addaddress.ui.state.AddAddressUM
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.update
import javax.inject.Inject
@ModelScoped
internal class AddAddressStateController @Inject constructor() {
val uiState: StateFlow<AddAddressUM>
field = MutableStateFlow(value = getInitialState())
fun update(transformer: Transformer<AddAddressUM>) {
uiState.update(function = transformer::transform)
}
private fun getInitialState(): AddAddressUM = AddAddressUM(
addressField = AddressFieldUM(
value = "",
placeholder = resourceReference(R.string.address_book_enter_address),
label = resourceReference(R.string.common_address),
isError = false,
),
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 = {},
onNetworkClick = {},
)
}

View file

@ -0,0 +1,29 @@
package com.tangem.features.addressbook.addaddress.state.transformers
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.utils.transformer.Transformer
/**
* Wires the callbacks owned by [com.tangem.features.addressbook.addaddress.model.AddAddressModel] into the initial
* state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController].
*/
internal class UpdateAddAddressInitialStateTransformer(
private val onAddressChange: (String) -> Unit,
private val onAddressClear: () -> Unit,
private val onPasteClick: () -> Unit,
private val onQrClick: () -> Unit,
private val onBackClick: () -> Unit,
private val onConfirmClick: () -> Unit,
) : Transformer<AddAddressUM> {
override fun transform(prevState: AddAddressUM): AddAddressUM {
return prevState.copy(
onAddressChange = onAddressChange,
onAddressClear = onAddressClear,
onPasteClick = onPasteClick,
onQrClick = onQrClick,
onBackClick = onBackClick,
buttonUM = prevState.buttonUM.copy(onClick = onConfirmClick),
)
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.addressbook.addaddress.state.transformers
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.utils.transformer.Transformer
/**
* Updates the address field with a freshly entered/pasted [value] and clears any previous error, restoring the default
* label. The actual (re)validation runs after a debounce see [UpdateAddressValidationTransformer].
*/
internal class UpdateAddressInputTransformer(
private val value: String,
) : Transformer<AddAddressUM> {
override fun transform(prevState: AddAddressUM): AddAddressUM {
return prevState.copy(
addressField = prevState.addressField.copy(
value = value,
isError = false,
label = resourceReference(R.string.common_address),
),
)
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.addressbook.addaddress.state.transformers
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.utils.transformer.Transformer
/**
* Validates [address] against the wallet's [coins] and reflects the result in the UI.
*
* The network is not chosen on this screen (it is selected on the next screen), so the address is valid when it matches
* at least one of the available networks the same blockchain check the Send flow uses. An invalid (non-empty,
* matching nothing) address surfaces the error in the field label and disables the confirm button.
*/
internal class UpdateAddressValidationTransformer(
private val address: String,
private val coins: List<CryptoCurrency.Coin>,
) : Transformer<AddAddressUM> {
override fun transform(prevState: AddAddressUM): AddAddressUM {
val hasMatchedAnyNetwork = address.isNotBlank() &&
coins.any { it.network.toBlockchain().validateAddress(address) }
val isError = address.isNotBlank() && !hasMatchedAnyNetwork
val label = if (isError) {
resourceReference(R.string.address_book_invalid_address_error)
} else {
resourceReference(R.string.common_address)
}
return prevState.copy(
addressField = prevState.addressField.copy(isError = isError, label = label),
buttonUM = prevState.buttonUM.copy(isEnabled = hasMatchedAnyNetwork),
)
}
}

View file

@ -3,14 +3,17 @@ 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.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.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.components.SpacerH
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
@ -20,9 +23,8 @@ 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
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
@Composable
internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifier) {
@ -34,7 +36,6 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.address_book_add_address),
startContent = {
TangemButton(
@ -45,30 +46,59 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
)
},
)
RecipientRow(
addressField = state.addressField,
onValueChange = state.onAddressChange,
onAddressClear = state.onAddressClear,
onQrClick = state.onQrClick,
onPasteClick = state.onPasteClick,
)
SpacerH12()
NetworkBlock(state.chosenNetworkStateUM)
PrimaryButton(state.buttonUM)
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
val minContentHeight = maxHeight
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
Column(
modifier = Modifier
.heightIn(min = minContentHeight)
.imePadding(),
) {
RecipientRow(
modifier = Modifier.padding(horizontal = 16.dp),
addressField = state.addressField,
onValueChange = state.onAddressChange,
onAddressClear = state.onAddressClear,
onQrClick = state.onQrClick,
onPasteClick = state.onPasteClick,
)
SpacerH(20.dp)
NetworkBlock(
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(color = TangemTheme.colors3.bg.secondary),
chosenNetworkStateUM = state.chosenNetworkStateUM,
onNetworkSelectClick = state.onNetworkClick,
)
PrimaryButton(state.buttonUM)
}
}
}
}
}
@Composable
private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) {
Spacer(modifier = Modifier.weight(1f))
PrimaryTangemButton(
TangemButton(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
buttonUM = buttonUM,
.padding(horizontal = 16.dp, vertical = 12.dp),
onClick = buttonUM.onClick,
isEnabled = buttonUM.isEnabled,
isLoading = buttonUM.isLoading,
size = TangemButton.Size.X12,
text = buttonUM.text,
)
}
@ -84,7 +114,6 @@ private fun Preview_AddAddressContent() {
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,
@ -97,6 +126,7 @@ private fun Preview_AddAddressContent() {
onPasteClick = {},
onQrClick = {},
onBackClick = {},
onNetworkClick = {},
),
)
}

View file

@ -1,86 +1,95 @@
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.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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.clickableSingle
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 com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
import com.tangem.utils.StringsSigns
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) {
internal fun NetworkBlock(
onNetworkSelectClick: () -> Unit,
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
modifier: Modifier = Modifier,
) {
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),
modifier = modifier,
titleSlot = {
Text(
text = stringResourceSafe(R.string.common_network),
style = TangemTheme.typography.body2,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
)
},
endSlot = {
SelectNetworkButton(chosenNetworkStateUM)
SelectNetworkButton(
onNetworkSelectClick = onNetworkSelectClick,
chosenNetworkStateUM = chosenNetworkStateUM,
)
},
)
}
@Composable
private fun SelectNetworkButton(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
private fun SelectNetworkButton(
onNetworkSelectClick: () -> Unit,
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
) {
Row(
modifier = Modifier.clickableSingle(
onClick = onNetworkSelectClick,
enabled = chosenNetworkStateUM !is AddAddressUM.ChosenNetworkStateUM.Loading,
),
verticalAlignment = Alignment.CenterVertically,
) {
when (chosenNetworkStateUM) {
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList)
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader()
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20)
AddAddressUM.ChosenNetworkStateUM.Empty -> {
Text(
modifier = Modifier.padding(start = 8.dp),
text = stringResourceSafe(R.string.address_book_select_network),
style = TangemTheme.typography.body2,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.secondary,
)
SpacerW(4.dp)
ChevronIcon()
}
}
@ -100,7 +109,7 @@ private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
Text(
modifier = Modifier.padding(start = 8.dp),
text = network.networkName,
style = TangemTheme.typography.body2,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.secondary,
)
ChevronIcon()
@ -120,7 +129,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
val remaining = networks.size - visible.size
Box(modifier = Modifier.wrapContentWidth()) {
visible.forEachIndexed { index, network ->
visible.fastForEachIndexed { index, network ->
Image(
painter = painterResource(id = network.iconResId),
contentDescription = null,
@ -128,7 +137,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
modifier = Modifier
.padding(start = NetworkIconStep * index)
.networkIconRing()
.size(NetworkIconSize),
.size(24.dp),
)
}
if (remaining > 0) {
@ -137,12 +146,13 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
.padding(start = NetworkIconStep * visible.size)
.networkIconRing()
.background(color = TangemTheme.colors3.bg.tertiary)
.size(NetworkIconSize),
.heightIn(min = 24.dp)
.padding(vertical = 2.dp, horizontal = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$remaining",
style = TangemTheme.typography.caption1,
text = "${StringsSigns.PLUS}$remaining",
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
)
}
@ -160,20 +170,23 @@ private fun Modifier.networkIconRing(): Modifier = this
@Composable
private fun ChevronIcon() {
Image(
modifier = Modifier.padding(start = 8.dp),
painter = painterResource(id = R.drawable.ic_select_18_24),
Icon(
modifier = Modifier
.padding(start = 8.dp)
.size(20.dp),
tint = TangemTheme.colors3.icon.secondary,
imageVector = ImageVector.vectorResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true)
@Composable
private fun Preview_NetworkBlock() {
TangemThemePreviewRedesign {
Column {
NetworkBlock(
onNetworkSelectClick = {},
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = persistentListOf(
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
@ -182,6 +195,7 @@ private fun Preview_NetworkBlock() {
)
SpacerH12()
NetworkBlock(
onNetworkSelectClick = {},
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = persistentListOf(
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
@ -192,6 +206,7 @@ private fun Preview_NetworkBlock() {
)
SpacerH12()
NetworkBlock(
onNetworkSelectClick = {},
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = List(15) {
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
@ -199,9 +214,9 @@ private fun Preview_NetworkBlock() {
),
)
SpacerH12()
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading)
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, onNetworkSelectClick = {})
SpacerH12()
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty)
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, onNetworkSelectClick = {})
}
}
}

View file

@ -3,11 +3,7 @@ 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.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
@ -19,7 +15,6 @@ 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
@ -28,13 +23,14 @@ 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.resolveReference
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
import com.tangem.core.ui.res.generated.icons.ic_scan_20
import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
@Composable
internal fun RecipientRow(
@ -43,19 +39,23 @@ internal fun RecipientRow(
onAddressClear: () -> Unit,
onQrClick: () -> Unit,
onPasteClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
modifier = modifier
.clip(RoundedCornerShape(24.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,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
text = addressField.label.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = if (addressField.isError) {
TangemTheme.colors3.text.status.error
} else {
TangemTheme.colors3.text.secondary
},
)
TangemRow(
modifier = Modifier.fillMaxWidth(),
@ -64,7 +64,7 @@ internal fun RecipientRow(
startSlot = {
TangemIcon(
modifier = Modifier
.size(36.dp)
.size(40.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.tertiary),
tangemIconUM = TangemIconUM.Ident(text = addressField.value),
@ -72,43 +72,55 @@ internal fun RecipientRow(
},
titleSlot = {
SimpleTextField(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
modifier = Modifier.weight(1f),
value = addressField.value,
onValueChange = onValueChange,
placeholder = TextReference.Res(R.string.address_book_enter_address),
singleLine = false,
placeholder = addressField.placeholder,
)
},
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,
)
}
}
RecipientEndSlot(
hasValue = addressField.value.isNotEmpty(),
onAddressClear = onAddressClear,
onQrClick = onQrClick,
onPasteClick = onPasteClick,
)
},
)
}
}
@Composable
private fun RecipientEndSlot(
hasValue: Boolean,
onAddressClear: () -> Unit,
onQrClick: () -> Unit,
onPasteClick: () -> Unit,
) {
if (hasValue) {
Icon(
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = onAddressClear),
imageVector = Icons.ic_cross_circle_20_filled,
tint = TangemTheme.colors3.icon.tertiary,
contentDescription = null,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TangemButton(
variant = TangemButton.Variant.Secondary,
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_scan_20),
onClick = onQrClick,
)
TangemButton(
text = TextReference.Res(id = R.string.common_paste),
onClick = onPasteClick,
)
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -1,14 +1,13 @@
package com.tangem.features.addressbook.addaddress.contract
package com.tangem.features.addressbook.addaddress.ui.state
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
@Immutable
internal data class AddAddressUM(
val addressField: AddressFieldUM,
val availableNetworks: ImmutableList<Network>,
val buttonUM: TangemButtonUM,
val chosenNetworkStateUM: ChosenNetworkStateUM,
val onAddressChange: (String) -> Unit,
@ -16,15 +15,14 @@ internal data class AddAddressUM(
val onPasteClick: () -> Unit,
val onQrClick: () -> Unit,
val onBackClick: () -> Unit,
val onNetworkClick: () -> Unit,
) {
@Immutable
sealed class ChosenNetworkStateUM {
data object Loading : ChosenNetworkStateUM()
data object Empty : ChosenNetworkStateUM()
data class Result(
val networkUMList: ImmutableList<NetworkUM>,
) : ChosenNetworkStateUM() {
sealed interface ChosenNetworkStateUM {
data object Loading : ChosenNetworkStateUM
data object Empty : ChosenNetworkStateUM
data class Result(val networkUMList: ImmutableList<NetworkUM>) : ChosenNetworkStateUM {
data class NetworkUM(
val networkName: String,
@DrawableRes val iconResId: Int,

View file

@ -1,4 +1,4 @@
package com.tangem.features.addressbook.addaddress.contract
package com.tangem.features.addressbook.addaddress.ui.state
import com.tangem.core.ui.extensions.TextReference
@ -7,7 +7,4 @@ internal data class AddressFieldUM(
val placeholder: TextReference,
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val isValuePasted: Boolean = false,
val blockchainAddress: String? = null,
)

View file

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

View file

@ -0,0 +1,160 @@
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",
walletId = "00",
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 = {},
)
}
}

View file

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

View file

@ -0,0 +1,54 @@
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.domain.wallets.usecase.GetWalletsUseCase
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.*
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,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
init {
combine(
params.queryFlow.flatMapLatest { query ->
getContactsUseCase(query = query, userWalletId = null)
},
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
) { contacts, wallets -> contacts to wallets.values.toList() }
.onEach { (contacts, wallets) ->
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
stateController.update(
UpdateContactsBlockStateTransformer(
matched = matched,
walletNamesById = wallets.associate { it.walletId.stringValue to it.name },
shouldShowWalletName = matched.mapTo(HashSet()) { it.walletId }.size > 1,
onSeeAllClick = params.onSeeAllClick,
onContactClick = params.onContactClick,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
}

View file

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

View file

@ -0,0 +1,45 @@
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 walletNamesById: Map<String, String>,
private val shouldShowWalletName: Boolean,
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,
walletId = walletId,
name = name,
icon = icon,
networkAddressCount = entries.size,
walletName = if (shouldShowWalletName) walletNamesById[walletId] else null,
onClick = { onContactClick(this) },
)
private companion object {
const val MAX_CONTACTS = 5
}
}

View file

@ -0,0 +1,103 @@
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",
walletId = "00",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
networkAddressCount = 1,
onClick = {},
),
ContactUM(
id = "2",
walletId = "01",
name = "Alice",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.UFOGreen,
),
networkAddressCount = 3,
onClick = {},
),
),
onSeeAllClick = {},
shouldShowSeeAll = true,
),
)
}
}

View file

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

View file

@ -0,0 +1,61 @@
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
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.route.AddressBookRoute
import kotlinx.collections.immutable.persistentListOf
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(
private val addressSelectorFactory: AddressSelectorComponent.Factory,
) {
fun createChild(
route: AddressBookRoute,
context: AppComponentContext,
clickIntents: AddressBookClickIntents,
): ComposableContentComponent = when (route) {
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,
params = DefaultEditContactComponent.Params(
contactId = route.contactId?.let(::ContactId),
predefinedAddress = buildPredefinedAddress(route),
onBackClick = clickIntents::onEditContactBack,
onAddAddressClick = clickIntents::onAddAddressClick,
),
)
AddressBookRoute.AddAddress -> DefaultAddAddressComponent(
appComponentContext = context,
params = DefaultAddAddressComponent.Params(
onBackClick = clickIntents::onAddAddressBack,
onConfirm = clickIntents::onAddressConfirmed,
),
)
}
/** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */
private fun buildPredefinedAddress(route: AddressBookRoute.EditContact): ValidatedAddress? {
val address = route.predefinedAddress ?: return null
val networkId = route.predefinedNetworkId ?: return null
return ValidatedAddress(address = address, networkIds = persistentListOf(networkId))
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.addressbook.common
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
/**
* Navigation/click contract that the container ([DefaultAddressBookComponent]) implements and passes down to its
* children through [AddressBookChildFactory]. Keeping all cross-screen intents in one place removes the need for the
* children to know about each other or about navigation.
*
* Result delivery (the confirmed address) is handled out-of-band by [AddressBookResultHolder], not by this contract.
*/
internal interface AddressBookClickIntents {
fun onContactClick(contactId: ContactId)
fun onAddContactClick()
fun onEditContactBack()
fun onAddAddressClick()
fun onAddAddressBack()
fun onAddressConfirmed(address: ValidatedAddress)
}

View file

@ -0,0 +1,29 @@
package com.tangem.features.addressbook.common
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Carries a [ValidatedAddress] confirmed on the AddAddress screen over to the EditContact screen.
*
* The two screens live in independent model scopes, so a shared singleton holder is used to hand the result over
* instead of routing it through navigation/click intents. The producer calls [setConfirmedAddress]; the consumer
* observes [confirmedAddress] and calls [clear] after applying the value so it is not re-applied on resubscription.
*/
@Singleton
internal class AddressBookResultHolder @Inject constructor() {
val confirmedAddress: StateFlow<ValidatedAddress?>
field = MutableStateFlow<ValidatedAddress?>(null)
fun setConfirmedAddress(address: ValidatedAddress) {
confirmedAddress.value = address
}
fun clear() {
confirmedAddress.value = null
}
}

View file

@ -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 {
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,
walletId = contact.walletId.stringValue,
name = contact.name.value,
icon = contact.toAvatarIcon(),
networkId = networkId,
entries = entries.map { entry ->
MatchedContact.ContactAddress(
address = entry.address,
memo = entry.memo,
networkName = entry.networkName,
)
}.toImmutableList(),
)
}
}
/** Builds the contact avatar from the domain [Contact.icon] / [Contact.iconColor] (enum names), with fallbacks. */
private fun Contact.toAvatarIcon(): AccountIconUM.CryptoPortfolio = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == icon } ?: CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor }
?: CryptoPortfolioIcon.Color.Azure,
)
}

View file

@ -0,0 +1,120 @@
package com.tangem.features.addressbook.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.tangem.common.routing.entity.AddressBookOpenMode
import com.tangem.core.decompose.context.AppComponentContext
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.editcontact.ui.state.ValidatedAddress
import com.tangem.features.addressbook.route.AddressBookRoute
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddressBookComponent.Params,
private val childFactory: AddressBookChildFactory,
private val resultHolder: AddressBookResultHolder,
) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddressBookRoute>()
init {
// Drop any address left over from a previous session before the (possibly preloaded) stack starts collecting.
resultHolder.clear()
}
private val clickIntents = object : AddressBookClickIntents {
override fun onContactClick(contactId: ContactId) {
navigation.pushNew(AddressBookRoute.EditContact(contactId = contactId.value))
}
override fun onAddContactClick() {
navigation.pushNew(AddressBookRoute.EditContact())
}
override fun onEditContactBack() {
navigation.pop()
}
override fun onAddAddressClick() {
navigation.pushNew(AddressBookRoute.AddAddress)
}
override fun onAddAddressBack() {
navigation.pop()
}
override fun onAddressConfirmed(address: ValidatedAddress) {
resultHolder.setConfirmedAddress(address)
navigation.pop()
}
}
private val contentStack = childStack(
key = "address_book_stack",
source = navigation,
serializer = AddressBookRoute.serializer(),
initialStack = ::initialStack,
handleBackButton = false,
childFactory = ::screenChild,
)
@Composable
override fun Content(modifier: Modifier) {
val childStack by contentStack.subscribeAsState()
Children(
modifier = modifier,
stack = childStack,
animation = stackAnimation(slide()),
) { child ->
child.instance.Content(Modifier)
}
}
private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent {
return childFactory.createChild(
route = config,
context = childByContext(componentContext),
clickIntents = clickIntents,
)
}
private fun initialStack(): List<AddressBookRoute> = when (val mode = params.addressBookOpenMode) {
AddressBookOpenMode.Default -> listOf(AddressBookRoute.List())
is AddressBookOpenMode.ContactSelection -> listOf(
AddressBookRoute.List(mode = AddressBookRoute.ListMode.Selector(networkId = mode.networkId)),
)
is AddressBookOpenMode.WithContactCreation -> listOf(
AddressBookRoute.List(),
// Address + network are already known, so open the new contact with that address attached — no AddAddress.
AddressBookRoute.EditContact(
predefinedAddress = mode.address,
predefinedNetworkId = mode.networkId,
),
)
}
@AssistedFactory
interface Factory : AddressBookComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookComponent.Params,
): DefaultAddressBookComponent
}
}

View file

@ -1,7 +1,8 @@
package com.tangem.features.addressbook
package com.tangem.features.addressbook.common
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.addressbook.AddressBookFeatureToggles
internal class DefaultAddressBookFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,

View file

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

View file

@ -0,0 +1,70 @@
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
import com.tangem.utils.StringsSigns
@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 = {
val addresses = pluralStringResourceSafe(
R.plurals.address_book_addresses,
contact.networkAddressCount,
contact.networkAddressCount,
)
TangemRowText(
text = contact.walletName?.let { walletName -> "$addresses ${StringsSigns.DOT} $walletName" }
?: addresses,
role = TangemRowTextRole.Subtitle,
)
},
)
}
@Composable
@Preview(showBackground = true, widthDp = 360)
private fun Preview_ContactRow() {
TangemThemePreviewRedesign {
ContactRow(
ContactUM(
id = "1",
walletId = "00",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
walletName = "Wallet 1",
networkAddressCount = 1,
onClick = {},
),
)
}
}

View file

@ -1,21 +0,0 @@
package com.tangem.features.addressbook.component
import kotlinx.serialization.Serializable
@Serializable
internal sealed class AddressBookRoute {
@Serializable
data object List : AddressBookRoute()
/**
* if [contactId] is not null we should fetch existing contact
*/
@Serializable
data class EditContact(
val contactId: String? = null,
) : AddressBookRoute()
@Serializable
data object AddAddress : AddressBookRoute()
}

View file

@ -1,108 +0,0 @@
package com.tangem.features.addressbook.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.tangem.core.decompose.context.AppComponentContext
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.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
internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@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<AddressBookRoute>()
/**
* 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,
serializer = AddressBookRoute.serializer(),
initialConfiguration = AddressBookRoute.List,
handleBackButton = false,
childFactory = ::screenChild,
)
@Suppress("ReusedModifierInstance")
@Composable
override fun Content(modifier: Modifier) {
val childStack by contentStack.subscribeAsState()
Children(stack = childStack, animation = stackAnimation()) { child ->
child.instance.Content(modifier = modifier)
}
}
private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent =
when (config) {
AddressBookRoute.List -> addressBookListComponentFactory.create(
context = childByContext(componentContext),
params = AddressBookListComponent.Params(
onContactClick = { contactId ->
navigation.pushNew(AddressBookRoute.EditContact(contactId))
},
onAddContactClick = { navigation.pushNew(AddressBookRoute.EditContact()) },
),
)
is AddressBookRoute.EditContact -> editContactComponentFactory.create(
context = childByContext(componentContext),
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()
},
),
)
}
@AssistedFactory
interface Factory : AddressBookComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookComponent.Params,
): DefaultAddressBookComponent
}
}

View file

@ -1,13 +1,14 @@
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
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.EditContactComponent
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
@ -24,15 +25,21 @@ internal interface AddressBookComponentModule {
@Binds
@Singleton
fun bindAddressBookListComponentFactory(
factory: DefaultAddressBookListComponent.Factory,
): AddressBookListComponent.Factory
fun bindContactsBlockComponentFactory(
factory: DefaultAddressBookContactsBlockComponent.Factory,
): AddressBookContactsBlockComponent.Factory
@Binds
@Singleton
fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory
fun bindAddressSelectorComponentFactory(
factory: DefaultAddressSelectorComponent.Factory,
): AddressSelectorComponent.Factory
@Binds
@Singleton
fun bindAddAddressComponentFactory(factory: DefaultAddAddressComponent.Factory): AddAddressComponent.Factory
fun bindContactSelectionTrigger(impl: DefaultContactSelectionTrigger): ContactSelectionTrigger
@Binds
@Singleton
fun bindContactSelectionListener(impl: DefaultContactSelectionTrigger): ContactSelectionListener
}

View file

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

View file

@ -2,7 +2,7 @@ package com.tangem.features.addressbook.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.addressbook.DefaultAddressBookFeatureToggles
import com.tangem.features.addressbook.common.DefaultAddressBookFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn

View file

@ -7,16 +7,16 @@ 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.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.editcontact.model.EditContactModel
import com.tangem.features.addressbook.editcontact.ui.EditContactContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
internal class DefaultEditContactComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: EditContactComponent.Params,
) : EditContactComponent, AppComponentContext by context {
internal class DefaultEditContactComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: EditContactModel = getOrCreateModel(params)
@ -30,11 +30,10 @@ internal class DefaultEditContactComponent @AssistedInject constructor(
BackHandler(onBack = state.onCloseClick)
}
@AssistedFactory
interface Factory : EditContactComponent.Factory {
override fun create(
context: AppComponentContext,
params: EditContactComponent.Params,
): DefaultEditContactComponent
}
data class Params(
val contactId: ContactId?,
val predefinedAddress: ValidatedAddress? = null,
val onBackClick: () -> Unit,
val onAddAddressClick: () -> Unit,
)
}

View file

@ -1,17 +0,0 @@
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 {
interface Factory : ComponentFactory<Params, EditContactComponent>
data class Params(
val contactId: ContactId?,
val onBackClick: () -> Unit,
val onAddAddressClick: (onResult: (ValidatedAddress) -> Unit) -> Unit,
)
}

View file

@ -1,14 +0,0 @@
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,
)

View file

@ -1,80 +1,79 @@
package com.tangem.features.addressbook.editcontact.model
import com.tangem.common.ui.account.AccountIconUM
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.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.features.addressbook.common.AddressBookResultHolder
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.state.EditContactStateController
import com.tangem.features.addressbook.editcontact.state.transformers.AddValidatedAddressTransformer
import com.tangem.features.addressbook.editcontact.state.transformers.SelectContactColorTransformer
import com.tangem.features.addressbook.editcontact.state.transformers.UpdateContactNameTransformer
import com.tangem.features.addressbook.editcontact.state.transformers.UpdateEditContactInitialStateTransformer
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.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
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@ModelScoped
internal class EditContactModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: EditContactStateController,
private val resultHolder: AddressBookResultHolder,
) : Model() {
private val params: EditContactComponent.Params = paramsContainer.require()
private val params: DefaultEditContactComponent.Params = paramsContainer.require()
val state: StateFlow<EditContactUM>
field = MutableStateFlow(getInitialState())
val state: StateFlow<EditContactUM> get() = stateController.uiState
init {
updateInitialState()
prefillPredefinedAddress()
subscribeToConfirmedAddresses()
}
/** In WithContactCreation mode the contact opens with the already-known address attached. */
private fun prefillPredefinedAddress() {
params.predefinedAddress?.let(::addAddress)
}
private fun updateInitialState() {
stateController.update(
UpdateEditContactInitialStateTransformer(
isExistingContact = params.contactId != null,
onNameChange = ::onNameChange,
onColorSelect = ::onColorSelect,
onCloseClick = params.onBackClick,
onAddAddressClick = params.onAddAddressClick,
),
)
}
private fun subscribeToConfirmedAddresses() {
resultHolder.confirmedAddress
.filterNotNull()
.onEach { address ->
addAddress(address)
resultHolder.clear()
}
.launchIn(modelScope)
}
private fun onNameChange(name: String) {
state.update { it.copy(name = name) }
stateController.update(UpdateContactNameTransformer(name = name))
}
private fun onColorSelect(color: CryptoPortfolioIcon.Color) {
state.update { oldState ->
oldState.copy(
colors = oldState.colors.copy(selected = color),
portfolioIcon = oldState.portfolioIcon.copy(color = color),
)
}
}
private fun requestAddAddress() {
params.onAddAddressClick(::addAddress)
stateController.update(SelectContactColorTransformer(color = color))
}
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()
val titleResId = if (params.contactId == null) {
R.string.address_book_new_contact
} else {
R.string.address_book_contact
}
return EditContactUM(
title = resourceReference(titleResId),
name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = selectedColor,
),
colors = EditContactUM.Colors(
selected = selectedColor,
list = colors,
onColorSelect = ::onColorSelect,
),
addresses = persistentListOf(),
onNameChange = ::onNameChange,
onCloseClick = params.onBackClick,
onAddAddressClick = ::requestAddAddress,
)
stateController.update(AddValidatedAddressTransformer(address = address))
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.features.addressbook.editcontact.state
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class EditContactStateController @Inject constructor() {
val uiState: StateFlow<EditContactUM>
field = MutableStateFlow(value = getInitialState())
fun update(transformer: Transformer<EditContactUM>) {
uiState.update(function = transformer::transform)
}
private fun getInitialState(): EditContactUM {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val selectedColor = colors.first()
return EditContactUM(
title = TextReference.EMPTY,
name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = selectedColor,
),
colors = EditContactUM.Colors(
selected = selectedColor,
list = colors,
onColorSelect = {},
),
addresses = persistentListOf(),
onNameChange = {},
onCloseClick = {},
onAddAddressClick = {},
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class AddValidatedAddressTransformer(
private val address: ValidatedAddress,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
// Skip duplicates: an address is identified by its string value (it already carries all its networks).
if (prevState.addresses.any { it.address == address.address }) return prevState
return prevState.copy(
addresses = (prevState.addresses + address).toImmutableList(),
)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
internal class SelectContactColorTransformer(
private val color: CryptoPortfolioIcon.Color,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(
colors = prevState.colors.copy(selected = color),
portfolioIcon = prevState.portfolioIcon.copy(color = color),
)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
internal class UpdateContactNameTransformer(
private val name: String,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(name = name)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
/**
* Wires the title (derived from whether an existing contact is being edited) and the callbacks owned by
* [com.tangem.features.addressbook.editcontact.model.EditContactModel] into the initial state.
*/
internal class UpdateEditContactInitialStateTransformer(
private val isExistingContact: Boolean,
private val onNameChange: (String) -> Unit,
private val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
private val onCloseClick: () -> Unit,
private val onAddAddressClick: () -> Unit,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
val titleResId = if (isExistingContact) {
R.string.address_book_contact
} else {
R.string.address_book_new_contact
}
return prevState.copy(
title = resourceReference(titleResId),
colors = prevState.colors.copy(onColorSelect = onColorSelect),
onNameChange = onNameChange,
onCloseClick = onCloseClick,
onAddAddressClick = onAddAddressClick,
)
}
}

View file

@ -1,20 +1,16 @@
package com.tangem.features.addressbook.editcontact.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
@ -22,19 +18,27 @@ import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.getUiColor
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.fields.AutoSizeTextField
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.ds2.button.TangemButton
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.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.*
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_sign_plus_20
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -45,6 +49,7 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors3.bg.primary)
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -63,118 +68,126 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ContactSummary(state = state)
ContactColor(colors = state.colors)
ContactAddresses(addresses = state.addresses)
AddAddressRow(onClick = state.onAddAddressClick)
}
}
}
@Composable
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) {
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),
BlockCard(
shape = RoundedCornerShape(24.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary),
) {
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,
)
ContactAddresses(addresses = state.addresses)
AddAddressRow(onClick = state.onAddAddressClick)
}
}
}
}
@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,
)
}
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) {
addresses.fastForEach { entry ->
AddressRow(entry = entry)
}
}
@Composable
private fun AddressRow(entry: ValidatedAddress) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
startSlot = {
TangemIcon(
tangemIconUM = TangemIconUM.Ident(text = entry.address),
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
},
titleSlot = {
TangemRowText(
text = stringReference(entry.address),
role = TangemRowTextRole.Title,
overflow = TextOverflow.MiddleEllipsis,
)
},
subtitleSlot = {
TangemRowText(
text = pluralReference(
id = R.plurals.common_networks_count,
count = entry.networkIds.size,
formatArgs = wrappedList(entry.networkIds.size),
),
role = TangemRowTextRole.Subtitle,
)
},
)
}
@Composable
private fun AddAddressRow(onClick: () -> Unit) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
onClick = onClick,
startSlot = {
TangemIcon(
tangemIconUM = TangemIconUM.Icon(
imageVector = Icons.ic_sign_plus_20,
tintReference = { TangemTheme.colors3.icon.brand },
),
modifier = Modifier
.size(40.dp)
.background(
color = TangemTheme.colors3.bg.status.infoSubtle,
shape = RoundedCornerShape(10.dp),
)
.padding(8.dp),
)
},
titleSlot = {
TangemRowText(
text = TextReference.Res(R.string.address_book_add_address),
role = TangemRowTextRole.Title,
)
},
subtitleSlot = {
TangemRowText(
text = TextReference.Res(R.string.address_book_add_address_description),
role = TangemRowTextRole.Subtitle,
)
},
)
}
@Composable
private fun ContactSummary(state: EditContactUM) {
val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() }
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(24.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
.background(TangemTheme.colors3.bg.secondary)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(24.dp))
SpacerH(20.dp)
AccountIcon(
name = stringReference(avatarName),
icon = state.portfolioIcon,
size = AccountIconSize.Large,
size = AccountIconSize.RedesignLarge,
)
Spacer(modifier = Modifier.height(24.dp))
SpacerH(28.dp)
Text(
text = stringResourceSafe(R.string.address_book_contact_name),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
color = TangemTheme.colors3.text.secondary,
)
Spacer(modifier = Modifier.height(2.dp))
SpacerH(4.dp)
AutoSizeTextField(
value = state.name,
@ -186,7 +199,7 @@ private fun ContactSummary(state: EditContactUM) {
color = TangemTheme.colors3.text.primary,
placeholderColor = TangemTheme.colors3.text.tertiary,
)
Spacer(modifier = Modifier.height(20.dp))
SpacerH(8.dp)
}
}
@ -196,16 +209,16 @@ private fun ContactSummary(state: EditContactUM) {
private fun ContactColor(colors: EditContactUM.Colors) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(24.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
.background(TangemTheme.colors3.bg.secondary)
.padding(16.dp),
) {
FlowRow(
maxItemsInEachRow = 6,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
colors.list.fastForEach { color ->
val isSelected = color == colors.selected
@ -219,12 +232,12 @@ private fun ContactColor(colors: EditContactUM.Colors) {
if (isSelected) {
Box(
modifier = Modifier
.size(47.dp)
.size(48.dp)
.border(2.dp, color.getUiColor(), shape = CircleShape),
)
Box(
modifier = Modifier
.size(36.dp)
.size(38.dp)
.background(color = color.getUiColor(), shape = CircleShape),
)
} else {
@ -260,7 +273,12 @@ private fun Preview_EditContactContent() {
list = colors,
onColorSelect = {},
),
addresses = persistentListOf(),
addresses = persistentListOf(
ValidatedAddress(
address = "0x1234567890abcdef1234567890abcdef12345678",
networkIds = persistentListOf("ethereum", "bsc", "polygon"),
),
),
onNameChange = {},
onCloseClick = {},
onAddAddressClick = {},

View file

@ -1,10 +1,12 @@
package com.tangem.features.addressbook.editcontact.contract
package com.tangem.features.addressbook.editcontact.ui.state
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,

View file

@ -0,0 +1,17 @@
package com.tangem.features.addressbook.editcontact.ui.state
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
/**
* A recipient address validated on the AddAddress screen, together with the networks it resolves to.
*
* A single address can belong to several networks (e.g. the same address across EVM chains), so it carries a list of
* [networkIds]. This is the in-progress (pre-save) representation accumulated in [EditContactUM]; the [networkIds] are
* used to rebuild the domain `AddressEntry`s when the contact is persisted.
*/
@Immutable
data class ValidatedAddress(
val address: String,
val networkIds: ImmutableList<String>,
)

View file

@ -1,14 +0,0 @@
package com.tangem.features.addressbook.list
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
internal interface AddressBookListComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddressBookListComponent>
data class Params(
val onContactClick: (String) -> Unit,
val onAddContactClick: () -> Unit,
)
}

View file

@ -1,43 +1,77 @@
package com.tangem.features.addressbook.list
import androidx.compose.foundation.background
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.features.addressbook.list.contract.AddressBookListUM
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 dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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 @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted val params: AddressBookListComponent.Params,
) : AddressBookListComponent, AppComponentContext by context {
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(
tangemButtonUM = addressBookListUM.tangemButtonUM,
onAddContactClick = addressBookListUM.onAddClick,
onBackClick = router::pop,
modifier = modifier,
modifier = modifier.background(TangemTheme.colors3.bg.primary),
)
is AddressBookListUM.Content -> AddressBookListScreen(
state = addressBookListUM,
onBackClick = router::pop,
modifier = modifier.background(TangemTheme.colors3.bg.primary),
)
is AddressBookListUM.AddressList -> TODO("[REDACTED_TASK_KEY]")
}
selector.child?.instance?.BottomSheet()
}
@AssistedFactory
interface Factory : AddressBookListComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookListComponent.Params,
): DefaultAddressBookListComponent
}
/**
* @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,
)
}

View file

@ -1,15 +0,0 @@
package com.tangem.features.addressbook.list.contract
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.domain.addressbook.model.Contact
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class AddressBookListUM {
data class Empty(
val tangemButtonUM: TangemButtonUM,
) : AddressBookListUM()
data class AddressList(val contacts: ImmutableList<Contact>) : AddressBookListUM()
}

View file

@ -1,43 +1,149 @@
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.ui.R
import com.tangem.core.ui.R.drawable.ic_plus_24
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
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.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.list.contract.AddressBookListUM
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.ContactSelectionTrigger
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.list.state.AddressBookListStateController
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
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 (editor 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.
*/
@Suppress("LongParameterList", "NamedArguments")
@OptIn(ExperimentalCoroutinesApi::class)
@ModelScoped
internal class AddressBookListModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: AddressBookListStateController,
private val router: Router,
private val contactSelectionTrigger: ContactSelectionTrigger,
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val params = paramsContainer.require<AddressBookListComponent.Params>()
private val params = paramsContainer.require<DefaultAddressBookListComponent.Params>()
val state: StateFlow<AddressBookListUM> = MutableStateFlow(
AddressBookListUM.Empty(
tangemButtonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_new_contact),
tangemIconUM = TangemIconUM.Icon(
iconRes = ic_plus_24,
tintReference = { TangemTheme.colors3.text.inverse.primary },
),
iconPosition = TangemButtonIconPosition.End,
type = TangemButtonType.Primary,
onClick = params.onAddContactClick,
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>()
private val searchQuery = MutableStateFlow(value = "")
private val searchActive = MutableStateFlow(value = false)
private val selectedWalletId = MutableStateFlow<String?>(value = null)
private val allContacts: SharedFlow<List<VerifiedContact>> =
getVerifiedContactsInteractor(query = "", userWalletId = null)
.shareIn(modelScope, SharingStarted.Lazily, replay = 1)
init {
val matchedContacts = searchQuery.flatMapLatest { query ->
if (query.isBlank()) allContacts else getVerifiedContactsInteractor(query = query, userWalletId = null)
}
combine(
allContacts,
matchedContacts,
searchQuery,
combine(selectedWalletId, searchActive) { selected, active -> selected to active },
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
) { all, matched, query, (selected, active), wallets ->
ListInputs(
allContacts = all,
matchedContacts = matched,
query = query,
selectedWalletId = selected,
isSearchActive = active,
wallets = wallets,
)
}
.onEach(::updateState)
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun updateState(inputs: ListInputs) {
stateController.update(
UpdateAddressBookListContentTransformer(
allContacts = inputs.allContacts,
matchedContacts = inputs.matchedContacts,
mode = params.mode,
wallets = inputs.wallets,
selectedWalletId = inputs.selectedWalletId,
query = inputs.query,
isSearchActive = inputs.isSearchActive,
onContactClick = params.onContactClick,
onPickContact = ::onPickContact,
onQueryChange = ::onQueryChange,
onActiveChange = ::onActiveChange,
onClearQuery = ::onClearQuery,
onChipSelected = ::onChipSelected,
onAddContactClick = params.onAddContactClick,
),
),
)
}
private fun onQueryChange(query: String) {
searchQuery.value = query
}
private fun onActiveChange(active: Boolean) {
searchActive.value = active
}
private fun onClearQuery() {
searchQuery.value = ""
}
private fun onChipSelected(walletId: String?) {
if (selectedWalletId.value == walletId) return
selectedWalletId.value = walletId
}
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()
}
private data class ListInputs(
val allContacts: List<VerifiedContact>,
val matchedContacts: List<VerifiedContact>,
val query: String,
val selectedWalletId: String?,
val isSearchActive: Boolean,
val wallets: Map<UserWalletId, UserWallet>,
)
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.addressbook.list.state
import com.tangem.core.decompose.di.ModelScoped
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.update
import javax.inject.Inject
@ModelScoped
internal class AddressBookListStateController @Inject constructor() {
val uiState: StateFlow<AddressBookListUM>
field = MutableStateFlow(value = getInitialState())
fun update(transformer: Transformer<AddressBookListUM>) {
uiState.update(function = transformer::transform)
}
private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {})
}

View file

@ -0,0 +1,129 @@
package com.tangem.features.addressbook.list.state.transformers
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.common.ContactMatcher
import com.tangem.features.addressbook.list.state.transformers.converter.DefaultContactConverter
import com.tangem.features.addressbook.list.state.transformers.converter.SelectorContactConverter
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
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.features.addressbook.route.AddressBookRoute
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList")
internal class UpdateAddressBookListContentTransformer(
wallets: Map<UserWalletId, UserWallet>,
private val allContacts: List<VerifiedContact>,
private val matchedContacts: List<VerifiedContact>,
private val mode: AddressBookRoute.ListMode,
private val selectedWalletId: String?,
private val query: String,
private val isSearchActive: Boolean,
private val onContactClick: (String) -> Unit,
private val onPickContact: (MatchedContact) -> Unit,
private val onQueryChange: (String) -> Unit,
private val onActiveChange: (Boolean) -> Unit,
private val onClearQuery: () -> Unit,
private val onChipSelected: (String?) -> Unit,
private val onAddContactClick: () -> Unit,
) : Transformer<AddressBookListUM> {
private val orderedWalletIds: List<String> = wallets.values.map { it.walletId.stringValue }
private val walletNamesById: Map<String, String> = wallets.values.associate { it.walletId.stringValue to it.name }
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
val matchedItems = matchedItems()
if (matchedItems.isEmpty() && query.isBlank()) {
return AddressBookListUM.Empty(onAddClick = onAddContactClick)
}
val matchingWalletIds = matchedItems.map { it.walletId }.distinct()
val effectiveSelected = selectedWalletId.takeIf { it in matchingWalletIds }
val areChipsVisible = totalWalletIds().size >= 2 && matchedItems.isNotEmpty()
// On the "All" chip of a multi-wallet book each contact shows which wallet it belongs to.
val shouldShowWalletName = areChipsVisible && effectiveSelected == null
val displayContacts = matchedItems
.filter { effectiveSelected == null || it.walletId == effectiveSelected }
.map { if (shouldShowWalletName) it.copy(walletName = walletNamesById[it.walletId]) else it }
.toImmutableList()
return AddressBookListUM.Content(
searchBar = buildSearchBar(),
chips = if (areChipsVisible) buildChips(matchingWalletIds, effectiveSelected) else persistentListOf(),
contacts = displayContacts,
isNothingFound = matchedItems.isEmpty(),
contentMode = contentMode(),
)
}
private fun matchedItems(): List<ContactUM> = when (val mode = mode) {
AddressBookRoute.ListMode.Default ->
DefaultContactConverter(onContactClick).convertList(matchedContacts)
is AddressBookRoute.ListMode.Selector ->
SelectorContactConverter(onPickContact)
.convertList(ContactMatcher.match(matchedContacts.map { it.contact }, mode.networkId))
}
/** Wallets that own at least one contact (respecting the network filter in selector mode) — drives chip visibility. */
private fun totalWalletIds(): Set<String> = when (val mode = mode) {
AddressBookRoute.ListMode.Default -> allContacts.mapTo(mutableSetOf()) { it.contact.walletId.stringValue }
is AddressBookRoute.ListMode.Selector ->
ContactMatcher.match(allContacts.map { it.contact }, mode.networkId).mapTo(mutableSetOf()) { it.walletId }
}
private fun contentMode(): ContentMode = when (mode) {
AddressBookRoute.ListMode.Default -> ContentMode.Default(onAddClick = onAddContactClick)
is AddressBookRoute.ListMode.Selector -> ContentMode.Select
}
private fun buildSearchBar(): TangemSearch.State = TangemSearch.State(
placeholderText = resourceReference(R.string.common_search),
query = query,
onQueryChange = onQueryChange,
isActive = isSearchActive,
onActiveChange = onActiveChange,
onClearClick = onClearQuery,
onCloseClick = { onActiveChange(false) },
)
private fun buildChips(matchingWalletIds: List<String>, effectiveSelected: String?) = buildList {
add(
AddressBookChipUM(
id = ALL_CHIP_ID,
text = resourceReference(R.string.common_all),
isSelected = effectiveSelected == null,
onClick = { onChipSelected(null) },
),
)
orderedWalletIds
.filter { it in matchingWalletIds }
.forEach { walletId ->
add(
AddressBookChipUM(
id = walletId,
text = stringReference(walletNamesById[walletId] ?: walletId),
isSelected = walletId == effectiveSelected,
onClick = { onChipSelected(walletId) },
iconRes = R.drawable.ic_key_card_20,
),
)
}
}.toImmutableList()
private companion object {
const val ALL_CHIP_ID = "all"
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.addressbook.list.state.transformers.converter
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.converter.Converter
internal class DefaultContactConverter(
private val onContactClick: (String) -> Unit,
) : Converter<VerifiedContact, ContactUM> {
override fun convert(value: VerifiedContact): ContactUM {
val contact = value.contact
val name = contact.name.value
return ContactUM(
id = contact.id.value,
walletId = contact.walletId.stringValue,
name = name,
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == contact.icon }
?: CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor }
?: CryptoPortfolioIcon.Color.Azure,
),
networkAddressCount = contact.addressEntries.size,
onClick = { onContactClick(contact.id.value) },
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.list.state.transformers.converter
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.converter.Converter
internal class SelectorContactConverter(
private val onPickContact: (MatchedContact) -> Unit,
) : Converter<MatchedContact, ContactUM> {
override fun convert(value: MatchedContact): ContactUM = ContactUM(
id = value.contactId,
walletId = value.walletId,
name = value.name,
icon = value.icon,
networkAddressCount = value.entries.size,
onClick = { onPickContact(value) },
)
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.addressbook.list.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
@Composable
internal fun AddressBookChip(state: AddressBookChipUM, modifier: Modifier = Modifier) {
val backgroundColor = if (state.isSelected) {
TangemTheme.colors2.tabs.backgroundPrimary
} else {
TangemTheme.colors2.tabs.backgroundSecondary
}
val textColor = if (state.isSelected) {
TangemTheme.colors2.tabs.textPrimary
} else {
TangemTheme.colors2.tabs.textSecondary
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.clip(shape = CircleShape)
.background(color = backgroundColor)
.selectable(
selected = state.isSelected,
onClick = state.onClick,
role = Role.Tab,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = state.text.resolveReference(),
style = TangemTheme.typography3.body.medium,
color = textColor,
maxLines = 1,
)
if (state.iconRes != null) {
Icon(
modifier = Modifier
.padding(start = 4.dp)
.size(20.dp),
painter = painterResource(id = state.iconRes),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant,
)
}
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.features.addressbook.list.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@ -9,26 +8,26 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.button.PrimaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
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.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_chevron_left_20
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
@Composable
internal fun AddressBookEmptyScreen(
tangemButtonUM: TangemButtonUM,
onAddContactClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -41,26 +40,19 @@ internal fun AddressBookEmptyScreen(
title = resourceReference(R.string.address_book_title),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_20),
onClick = onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
NoContactInfo()
PrimaryTangemButton(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
buttonUM = tangemButtonUM,
)
NoContactInfo(onAddClick = onAddContactClick)
}
}
@Composable
private fun ColumnScope.NoContactInfo() {
private fun ColumnScope.NoContactInfo(onAddClick: () -> Unit) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
@ -68,18 +60,24 @@ private fun ColumnScope.NoContactInfo() {
) {
ContactImage()
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing24),
modifier = Modifier.padding(top = 32.dp),
text = stringResourceSafe(R.string.address_book_no_contacts),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.medium,
style = TangemTheme.typography3.heading.small,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
modifier = Modifier.padding(top = 8.dp),
text = stringResourceSafe(R.string.address_book_no_contacts_description),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
style = TangemTheme.typography3.subheading.medium,
textAlign = TextAlign.Center,
)
TangemButton(
modifier = Modifier.padding(top = 40.dp),
text = resourceReference(R.string.address_book_add_address),
onClick = onAddClick,
iconEnd = TangemIconUM.Icon(imageVector = Icons.ic_sign_plus_20),
)
}
}
@ -95,7 +93,7 @@ private fun ContactImage() {
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(R.drawable.ic_contact_20),
imageVector = ImageVector.vectorResource(R.drawable.ic_address_book_24),
contentDescription = stringResourceSafe(R.string.address_book_no_contacts),
modifier = Modifier.size(28.dp),
)
@ -104,16 +102,11 @@ private fun ContactImage() {
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_AddressBookEmptyScreen() {
AddressBookEmptyScreen(
tangemButtonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_new_contact),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_plus_24),
iconPosition = TangemButtonIconPosition.End,
type = TangemButtonType.Secondary,
onClick = {},
),
onBackClick = {},
)
TangemThemePreviewRedesign {
AddressBookEmptyScreen(
onAddContactClick = {},
onBackClick = {},
)
}
}

View file

@ -0,0 +1,166 @@
package com.tangem.features.addressbook.list.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
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.ds2.search.TangemSearch
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.*
import com.tangem.features.addressbook.common.ui.ContactRow
import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewParameterProvider
import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewScenario
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
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.ImmutableList
@Composable
internal fun AddressBookListScreen(
state: AddressBookListUM.Content,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.navigationBarsPadding()) {
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,
)
},
)
TangemSearch(
state = state.searchBar,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
if (state.chips.isNotEmpty()) {
WalletChips(chips = state.chips)
}
if (state.isNothingFound) {
NothingFoundContent()
} else {
LazyColumn(
modifier = Modifier
.imePadding()
.padding(top = 16.dp)
.background(
color = TangemTheme.colors3.bg.secondary,
shape = RoundedCornerShape(24.dp),
),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
bottom = 12.dp,
),
) {
items(items = state.contacts, key = ContactUM::id) { contact ->
ContactRow(contact = contact)
}
}
}
}
}
@Composable
private fun WalletChips(chips: ImmutableList<AddressBookChipUM>) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = chips, key = AddressBookChipUM::id) { chip ->
AddressBookChip(state = chip)
}
}
}
@Composable
private fun ColumnScope.NothingFoundContent() {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(80.dp)
.background(color = TangemTheme.colors3.bg.opaque.primary, shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.ic_search_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
modifier = Modifier.size(28.dp),
)
}
Text(
modifier = Modifier.padding(top = 32.dp),
text = stringResourceSafe(R.string.common_no_results),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.small,
)
}
}
@Composable
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
private fun Preview_AddressBookListScreen(
@PreviewParameter(AddressBookListPreviewParameterProvider::class) scenario: AddressBookListPreviewScenario,
) {
TangemThemePreviewRedesign {
AddressBookListScreen(
state = scenario.state,
onBackClick = {},
)
}
}

View file

@ -0,0 +1,106 @@
package com.tangem.features.addressbook.list.ui.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
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
internal data class AddressBookListPreviewScenario(
val title: String,
val state: AddressBookListUM.Content,
)
internal object AddressBookListPreviewFixtures {
private val scenarioDefaultWithContacts = AddressBookListPreviewScenario(
title = "Default chips and contacts",
state = AddressBookListUM.Content(
searchBar = searchBar(query = ""),
chips = persistentListOf(
AddressBookChipUM(
id = "all",
text = resourceReference(R.string.common_all),
isSelected = true,
onClick = {},
),
AddressBookChipUM(
id = "00",
text = stringReference("Wallet 1"),
isSelected = false,
onClick = {},
iconRes = R.drawable.ic_key_card_20,
),
AddressBookChipUM(
id = "01",
text = stringReference("Wallet 2"),
isSelected = false,
onClick = {},
iconRes = R.drawable.ic_key_card_20,
),
),
contacts = persistentListOf(
contact(
walletId = "00",
name = "Binance",
color = CryptoPortfolioIcon.Color.Azure,
count = 1,
),
contact(
walletId = "01",
name = "Alice",
color = CryptoPortfolioIcon.Color.UFOGreen,
count = 3,
),
),
isNothingFound = false,
contentMode = ContentMode.Default(onAddClick = {}),
),
)
val scenarioSelectNothingFound = AddressBookListPreviewScenario(
title = "Select nothing found",
state = AddressBookListUM.Content(
searchBar = searchBar(query = "Antonio"),
chips = persistentListOf(),
contacts = persistentListOf(),
isNothingFound = true,
contentMode = ContentMode.Select,
),
)
fun allScenarios(): List<AddressBookListPreviewScenario> = listOf(
scenarioDefaultWithContacts,
scenarioSelectNothingFound,
)
private fun searchBar(query: String) = TangemSearch.State(
placeholderText = resourceReference(R.string.common_search),
query = query,
onQueryChange = {},
isActive = false,
onActiveChange = {},
)
private fun contact(walletId: String, name: String, color: CryptoPortfolioIcon.Color, count: Int) = ContactUM(
id = name + walletId,
walletId = walletId,
name = name,
icon = AccountIconUM.CryptoPortfolio(value = CryptoPortfolioIcon.Icon.Letter, color = color),
networkAddressCount = count,
onClick = {},
)
}
/** All [AddressBookListPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */
internal class AddressBookListPreviewParameterProvider : PreviewParameterProvider<AddressBookListPreviewScenario> {
override val values: Sequence<AddressBookListPreviewScenario>
get() = AddressBookListPreviewFixtures.allScenarios().asSequence()
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.addressbook.list.ui.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal data class AddressBookChipUM(
val id: String,
val text: TextReference,
val isSelected: Boolean,
val onClick: () -> Unit,
@DrawableRes val iconRes: Int? = null,
)

View file

@ -0,0 +1,39 @@
package com.tangem.features.addressbook.list.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds2.search.TangemSearch
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] (no contacts at all) 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] and [Content.contentMode].
*/
@Immutable
internal sealed interface AddressBookListUM {
data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
/**
* @property searchBar always shown so the user can filter contacts across all wallets.
* @property chips wallet filter chips (`All` + a chip per matching wallet); empty means the row is hidden.
* @property contacts contacts for the currently selected chip; empty together with [isNothingFound] = true.
* @property isNothingFound true when the active search matched nothing show the "no results" stub instead of the
* list (the search bar stays visible so the query can be edited).
*/
data class Content(
val searchBar: TangemSearch.State,
val chips: ImmutableList<AddressBookChipUM>,
val contacts: ImmutableList<ContactUM>,
val isNothingFound: Boolean,
val contentMode: ContentMode,
) : AddressBookListUM
}
@Immutable
internal sealed interface ContentMode {
data class Default(val onAddClick: () -> Unit) : ContentMode
data object Select : ContentMode
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.addressbook.list.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM
@Immutable
internal data class ContactUM(
val id: String,
val walletId: String,
val name: String,
val icon: AccountIconUM.CryptoPortfolio,
val networkAddressCount: Int,
val walletName: String? = null,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,44 @@
package com.tangem.features.addressbook.route
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 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.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
data class EditContact(
val contactId: String? = null,
val predefinedAddress: String? = null,
val predefinedNetworkId: String? = null,
) : 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
}
}

View file

@ -3,24 +3,23 @@ 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.R
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
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.features.addressbook.addaddress.DefaultAddAddressComponent
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
import com.tangem.features.addressbook.editcontact.ui.state.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
@ -28,11 +27,7 @@ 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
import org.junit.jupiter.api.*
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ -73,7 +68,6 @@ internal class AddAddressModelTest {
// Assert
assertThat(state.addressField.value).isEmpty()
assertThat(state.addressField.isValuePasted).isFalse()
assertThat(state.buttonUM.isEnabled).isFalse()
}
@ -87,13 +81,11 @@ internal class AddAddressModelTest {
model.state.value.onAddressChange(address)
// Assert
val field = model.state.value.addressField
assertThat(field.value).isEqualTo(address)
assertThat(field.isValuePasted).isFalse()
assertThat(model.state.value.addressField.value).isEqualTo(address)
}
@Test
fun `GIVEN empty field WHEN onPasteClick THEN value marked as pasted`() = runTest {
fun `GIVEN empty field WHEN onPasteClick THEN value taken from clipboard`() = runTest {
// Arrange
val model = createModel(testScope = this)
val address = "0xABC"
@ -103,13 +95,10 @@ internal class AddAddressModelTest {
model.state.value.onPasteClick()
// Assert
val field = model.state.value.addressField
assertThat(field.value).isEqualTo(address)
assertThat(field.isValuePasted).isTrue()
assertThat(model.state.value.addressField.value).isEqualTo(address)
}
// 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
@ -127,13 +116,12 @@ internal class AddAddressModelTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class AddressInput {
inner class Validation {
@Test
fun `GIVEN coins available WHEN valid address typed THEN matching network chosen`() = runTest {
fun `GIVEN coins available WHEN valid address typed THEN no error AND button enabled`() = runTest {
// Arrange
every { multiAccountListSupplier.invoke() } returns
flowOf(listOf(accountListWith(ethereum, bitcoin)))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin)))
val model = createModel(testScope = this)
advanceUntilIdle()
@ -143,16 +131,14 @@ internal class AddAddressModelTest {
// Assert
val state = model.state.value
assertThat(state.availableNetworks).containsExactly(ethereum.network)
assertThat(state.chosenNetworkStateUM)
.isEqualTo(resultOf(ethereum.network))
assertThat(state.addressField.isError).isFalse()
assertThat(state.buttonUM.isEnabled).isTrue()
}
@Test
fun `GIVEN coins available WHEN address matches no network THEN empty state`() = runTest {
fun `GIVEN coins available WHEN address matches no network THEN error AND button disabled`() = runTest {
// Arrange
every { multiAccountListSupplier.invoke() } returns
flowOf(listOf(accountListWith(ethereum, bitcoin)))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin)))
val model = createModel(testScope = this)
advanceUntilIdle()
@ -162,31 +148,32 @@ internal class AddAddressModelTest {
// Assert
val state = model.state.value
assertThat(state.availableNetworks).isEmpty()
assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
assertThat(state.addressField.isError).isTrue()
assertThat(state.addressField.label)
.isEqualTo(resourceReference(R.string.address_book_invalid_address_error))
assertThat(state.buttonUM.isEnabled).isFalse()
}
@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())
fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest {
// Arrange
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum)))
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
model.state.value.onAddressChange("")
advanceUntilIdle()
// Assert
val state = model.state.value
assertThat(state.availableNetworks).isEmpty()
assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
assertThat(state.addressField.isError).isFalse()
assertThat(state.buttonUM.isEnabled).isFalse()
}
// 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.
// The address is typed before coins load; validity must resolve reactively once the supplier emits them.
@Test
fun `GIVEN address typed before coins load WHEN coins emitted THEN network resolved reactively`() = runTest {
fun `GIVEN address typed before coins load WHEN coins emitted THEN validated reactively`() = runTest {
// Arrange
val accountsFlow = MutableStateFlow<List<AccountList>>(emptyList())
every { multiAccountListSupplier.invoke() } returns accountsFlow
@ -197,29 +184,19 @@ internal class AddAddressModelTest {
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
advanceUntilIdle()
// Assert intermediate: nothing to match yet
assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
assertThat(model.state.value.buttonUM.isEnabled).isFalse()
// Act — coins arrive later
accountsFlow.value = listOf(accountListWith(ethereum, bitcoin))
advanceUntilIdle()
// Assert
assertThat(model.state.value.chosenNetworkStateUM)
.isEqualTo(resultOf(ethereum.network))
val state = model.state.value
assertThat(state.buttonUM.isEnabled).isTrue()
assertThat(state.addressField.isError).isFalse()
}
}
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(
@ -239,7 +216,7 @@ internal class AddAddressModelTest {
private fun createModel(
testScope: TestScope,
onConfirm: (ValidatedAddress) -> Unit = {},
params: AddAddressComponent.Params = AddAddressComponent.Params(
params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params(
onBackClick = {},
onConfirm = onConfirm,
),
@ -250,6 +227,7 @@ internal class AddAddressModelTest {
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
multiAccountListSupplier = multiAccountListSupplier,
clipboardManager = clipboardManager,
stateController = AddAddressStateController(),
).also { model = it }
}

View file

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

View file

@ -8,23 +8,36 @@ 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.features.addressbook.common.AddressBookResultHolder
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.state.EditContactStateController
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.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
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.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class EditContactModelTest {
private val resultHolder = AddressBookResultHolder()
private var model: EditContactModel? = null
@AfterEach
fun tearDown() {
// Cancels modelScope, stopping the confirmed-addresses collector.
model?.onDestroy()
model = null
}
@Test
fun `WHEN model created THEN initial state is correct`() = runTest {
val expectedColors = CryptoPortfolioIcon.Color.entries.toImmutableList()
@ -57,11 +70,7 @@ internal class EditContactModelTest {
@Test
fun `GIVEN existing contactId WHEN model created THEN title is contact`() = runTest {
// Arrange
val params = EditContactComponent.Params(
contactId = ContactId(value = "contact-id"),
onBackClick = {},
onAddAddressClick = {},
)
val params = createParams(contactId = ContactId(value = "contact-id"))
// Act
val model = createModel(testScope = this, params = params)
@ -94,38 +103,73 @@ internal class EditContactModelTest {
}
@Test
fun `GIVEN add address requested WHEN result delivered THEN address appended to state`() = runTest {
fun `GIVEN confirmed address set on holder WHEN collected 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())
val model = createModel(testScope = this)
advanceUntilIdle()
val validatedAddress = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))
// Act
model.state.value.onAddAddressClick()
capturedSink?.invoke(validatedAddress)
resultHolder.setConfirmedAddress(validatedAddress)
advanceUntilIdle()
// Assert
assertThat(model.state.value.addresses).containsExactly(validatedAddress)
// The value must be consumed so it is not re-applied on resubscription.
assertThat(resultHolder.confirmedAddress.value).isNull()
}
@Test
fun `GIVEN same address confirmed twice WHEN collected THEN added only once`() = runTest {
// Arrange
val model = createModel(testScope = this)
advanceUntilIdle()
val validatedAddress = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))
// Act
resultHolder.setConfirmedAddress(validatedAddress)
advanceUntilIdle()
resultHolder.setConfirmedAddress(validatedAddress)
advanceUntilIdle()
// Assert
assertThat(model.state.value.addresses).containsExactly(validatedAddress)
}
@Test
fun `GIVEN predefined address WHEN model created THEN address attached`() = runTest {
// Arrange
val predefined = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))
// Act
val model = createModel(testScope = this, params = createParams(predefinedAddress = predefined))
advanceUntilIdle()
// Assert
assertThat(model.state.value.addresses).containsExactly(predefined)
}
private fun createParams(
contactId: ContactId? = null,
predefinedAddress: ValidatedAddress? = null,
): DefaultEditContactComponent.Params = DefaultEditContactComponent.Params(
contactId = contactId,
predefinedAddress = predefinedAddress,
onBackClick = {},
onAddAddressClick = {},
)
private fun createModel(
testScope: TestScope,
params: EditContactComponent.Params = EditContactComponent.Params(
contactId = null,
onBackClick = {},
onAddAddressClick = {},
),
params: DefaultEditContactComponent.Params = createParams(),
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
): EditContactModel {
return EditContactModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
)
stateController = EditContactStateController(),
resultHolder = resultHolder,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {

View file

@ -0,0 +1,182 @@
package com.tangem.features.addressbook.list.state.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.domain.addressbook.model.*
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.route.AddressBookRoute
import org.junit.jupiter.api.Test
internal class UpdateAddressBookListContentTransformerTest {
private val wallet1 = "00"
private val wallet2 = "01"
private val wallets: Map<UserWalletId, UserWallet> = linkedMapOf(
UserWalletId(stringValue = wallet1) to wallet(wallet1, "Wallet 1"),
UserWalletId(stringValue = wallet2) to wallet(wallet2, "Wallet 2"),
)
@Test
fun `GIVEN contacts in one wallet WHEN blank query THEN no chips`() {
// Arrange
val all = listOf(verified(wallet1, "Alice"), verified(wallet1, "Bob"))
// Act
val result = transform(allContacts = all, matchedContacts = all)
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.chips).isEmpty()
assertThat(content.contacts).hasSize(2)
assertThat(content.isNothingFound).isFalse()
// Single-wallet book has no chips, so the wallet name is not shown.
assertThat(content.contacts.none { it.walletName != null }).isTrue()
}
@Test
fun `GIVEN contacts in two wallets WHEN blank query THEN All plus per-wallet chips with All selected`() {
// Arrange
val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob"))
// Act
val result = transform(allContacts = all, matchedContacts = all)
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.chips.map { it.id }).containsExactly("all", wallet1, wallet2).inOrder()
assertThat(content.chips.first().isSelected).isTrue() // All
assertThat(content.chips.drop(1).none { it.isSelected }).isTrue()
assertThat(content.contacts).hasSize(2)
// On the "All" chip of a multi-wallet book each contact shows its wallet name.
assertThat(content.contacts.map { it.walletName }).containsExactly("Wallet 1", "Wallet 2").inOrder()
}
@Test
fun `GIVEN two wallets WHEN a wallet chip selected THEN list filtered but chips unchanged`() {
// Arrange
val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob"))
// Act
val result = transform(allContacts = all, matchedContacts = all, selectedWalletId = wallet2)
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.chips.map { it.id }).containsExactly("all", wallet1, wallet2).inOrder()
assertThat(content.chips.first().isSelected).isFalse() // All not selected
assertThat(content.contacts.map { it.name }).containsExactly("Bob")
// A specific wallet is selected, so the (redundant) wallet name is not shown.
assertThat(content.contacts.single().walletName).isNull()
}
@Test
fun `GIVEN two wallets WHEN query narrows to one wallet THEN chips kept as All plus that wallet`() {
// Arrange — the book spans two wallets, but the query matched only wallet1 in the domain
val all = listOf(verified(wallet1, "Antonio"), verified(wallet2, "Bob"))
val matched = listOf(verified(wallet1, "Antonio"))
// Act
val result = transform(allContacts = all, matchedContacts = matched, query = "Anto")
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.chips.map { it.id }).containsExactly("all", wallet1).inOrder()
assertThat(content.contacts.map { it.name }).containsExactly("Antonio")
assertThat(content.isNothingFound).isFalse()
}
@Test
fun `GIVEN selected wallet no longer matches query THEN falls back to All`() {
// Arrange
val all = listOf(verified(wallet1, "Antonio"), verified(wallet2, "Bob"))
val matched = listOf(verified(wallet1, "Antonio"))
// Act — selected wallet2, but query matched only wallet1
val result = transform(
allContacts = all,
matchedContacts = matched,
selectedWalletId = wallet2,
query = "Anto",
)
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.chips.first().isSelected).isTrue() // All selected again
assertThat(content.contacts.map { it.name }).containsExactly("Antonio")
}
@Test
fun `GIVEN no contacts WHEN blank query THEN Empty`() {
// Act
val result = transform(allContacts = emptyList(), matchedContacts = emptyList())
// Assert
assertThat(result).isInstanceOf(AddressBookListUM.Empty::class.java)
}
@Test
fun `GIVEN query matches nothing WHEN non-blank query THEN nothing found and chips hidden`() {
// Arrange
val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob"))
// Act
val result = transform(allContacts = all, matchedContacts = emptyList(), query = "Zzz")
// Assert
val content = result as AddressBookListUM.Content
assertThat(content.isNothingFound).isTrue()
assertThat(content.contacts).isEmpty()
assertThat(content.chips).isEmpty()
}
private fun transform(
allContacts: List<VerifiedContact>,
matchedContacts: List<VerifiedContact>,
selectedWalletId: String? = null,
query: String = "",
): AddressBookListUM = UpdateAddressBookListContentTransformer(
allContacts = allContacts,
matchedContacts = matchedContacts,
mode = AddressBookRoute.ListMode.Default,
wallets = wallets,
selectedWalletId = selectedWalletId,
query = query,
isSearchActive = false,
onContactClick = {},
onPickContact = {},
onQueryChange = {},
onActiveChange = {},
onClearQuery = {},
onChipSelected = {},
onAddContactClick = {},
).transform(prevState = AddressBookListUM.Empty(onAddClick = {}))
private fun wallet(id: String, name: String): UserWallet =
MockUserWalletFactory.create().copy(walletId = UserWalletId(stringValue = id), name = name)
private fun verified(walletId: String, name: String): VerifiedContact = VerifiedContact(
contact = Contact(
id = ContactId(name + walletId),
walletId = UserWalletId(stringValue = walletId),
name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" },
icon = "",
iconColor = "Azure",
createdAt = "2026-06-10T14:30:00.000Z",
updatedAt = "2026-06-10T14:30:00.000Z",
addressEntries = listOf(
AddressEntry(
id = AddressEntryId(name),
address = "addr-$name",
networkId = Network.RawID("ethereum"),
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
),
invalidEntries = emptyList(),
)
}

View file

@ -17,10 +17,10 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.impl.model.GiveApprovalModel
import com.tangem.features.approval.impl.ui.GiveApprovalContent
import com.tangem.features.send.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

View file

@ -33,9 +33,9 @@ import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger

View file

@ -34,7 +34,7 @@ import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.approval.impl.model.GiveApprovalUM
import com.tangem.features.send.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -2,8 +2,8 @@ package com.tangem.features.approval.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.features.send.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
internal class PreviewFeeSelectorBlockComponent : FeeSelectorBlockComponent {
override fun updateState(feeSelectorUM: FeeSelectorUM) {

View file

@ -30,11 +30,9 @@ internal class PreviewDetailsComponent : DetailsComponent {
).buildAll(
isWalletConnectAvailable = true,
isAddressBookAvailable = true,
isSupportChatAvailable = true,
hasAnyMobileWallet = true,
userWalletId = UserWalletId(""),
onSupportEmailClick = {},
onSupportChatClick = {},
onSupportClick = {},
onBuyClick = {},
)
}
@ -48,6 +46,7 @@ internal class PreviewDetailsComponent : DetailsComponent {
items = previewBlocks,
footer = previewFooter,
selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty,
selectContactSupportTypeBSConfig = TangemBottomSheetConfig.Empty,
popBack = { /* no-op */ },
)

View file

@ -25,10 +25,11 @@ internal sealed class DetailsItemUM {
override val id: String = "wallet_connect"
}
data class WalletConnectAddressBookBlock(val items: List<Item>) : DetailsItemUM() {
data class WalletActionBlock(val items: ImmutableList<Item>) : DetailsItemUM() {
override val id: String = "wallet_connect_address_book"
sealed class Item(open val onClick: () -> Unit) {
data class WalletConnect(override val onClick: () -> Unit) : Item(onClick)
data class AddressBook(override val onClick: () -> Unit) : Item(onClick)
}

View file

@ -7,5 +7,6 @@ internal data class DetailsUM(
val items: ImmutableList<DetailsItemUM>,
val footer: DetailsFooterUM,
val selectFeedbackEmailTypeBSConfig: TangemBottomSheetConfig,
val selectContactSupportTypeBSConfig: TangemBottomSheetConfig,
val popBack: () -> Unit,
)

View file

@ -0,0 +1,16 @@
package com.tangem.features.details.entity
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.details.impl.R
internal data class SelectContactSupportTypeBS(
val onOptionClick: (Option) -> Unit,
) : TangemBottomSheetConfigContent {
enum class Option(val text: TextReference) {
Mail(resourceReference(R.string.support_selector_view_email_button)),
Chat(resourceReference(R.string.support_selector_view_chat_button)),
}
}

View file

@ -24,6 +24,7 @@ import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.analytics.Settings
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookFeatureToggles
@ -31,6 +32,7 @@ import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.entity.DetailsUM
import com.tangem.features.details.entity.SelectContactSupportTypeBS
import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
import com.tangem.features.details.utils.ItemsBuilder
import com.tangem.features.details.utils.SocialsBuilder
@ -71,6 +73,8 @@ internal class DetailsModel @Inject constructor(
private val params: DetailsComponent.Params = paramsContainer.require()
private val isUsedeskEnabled = feedbackFeatureToggles.isUsedeskEnabled
private val items: MutableStateFlow<ImmutableList<DetailsItemUM>>
val state: MutableStateFlow<DetailsUM>
@ -89,11 +93,9 @@ internal class DetailsModel @Inject constructor(
itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable,
isAddressBookAvailable = addressBookFeatureToggles.isAddressBookEnabled,
isSupportChatAvailable = feedbackFeatureToggles.isUsedeskEnabled,
hasAnyMobileWallet = getWalletsUseCase.invokeSync().any { it is UserWallet.Hot },
userWalletId = params.userWalletId,
onSupportEmailClick = ::sendFeedback,
onSupportChatClick = ::openUseDesk,
onSupportClick = ::onContactSupportClick,
onBuyClick = ::onBuyClick,
),
)
@ -108,6 +110,7 @@ internal class DetailsModel @Inject constructor(
appVersion = getAppVersion(),
),
selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty,
selectContactSupportTypeBSConfig = TangemBottomSheetConfig.Empty,
popBack = router::pop,
),
)
@ -159,6 +162,46 @@ internal class DetailsModel @Inject constructor(
}
}
private fun onContactSupportClick() {
// Offer the mail/chat choice only when the chat is available; otherwise open mail directly.
if (isUsedeskEnabled) {
showContactSupportChooserBS()
} else {
sendFeedback()
}
}
private fun showContactSupportChooserBS() {
state.update { current ->
current.copy(
selectContactSupportTypeBSConfig = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::hideContactSupportChooserBS,
content = SelectContactSupportTypeBS(
onOptionClick = { option ->
hideContactSupportChooserBS()
when (option) {
SelectContactSupportTypeBS.Option.Mail -> sendFeedback()
SelectContactSupportTypeBS.Option.Chat -> {
analyticsEventHandler.send(Settings.ButtonOpenChat())
openUseDesk()
}
}
},
),
),
)
}
}
private fun hideContactSupportChooserBS() {
state.update { current ->
current.copy(
selectContactSupportTypeBSConfig = current.selectContactSupportTypeBSConfig.copy(isShown = false),
)
}
}
private fun openUseDesk() {
modelScope.launch {
val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null")

View file

@ -2,13 +2,13 @@ package com.tangem.features.details.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
@ -27,19 +28,27 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.BlockItem
import com.tangem.core.ui.components.inputrow.InputRowImageBase
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
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.TextReference
import com.tangem.core.ui.extensions.resolveReference
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.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_chevron_right_20
import com.tangem.core.ui.test.DetailsScreenTestTags
import com.tangem.features.details.component.preview.PreviewDetailsComponent
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM
import com.tangem.features.details.entity.DetailsUM
import com.tangem.features.details.impl.R
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun DetailsScreen(
@ -67,6 +76,7 @@ internal fun DetailsScreen(
}
SelectFeedbackEmailTypeBottomSheet(state.selectFeedbackEmailTypeBSConfig)
SelectContactSupportTypeBottomSheet(state.selectContactSupportTypeBSConfig)
}
@Composable
@ -159,9 +169,9 @@ private fun Block(
onClick = model.onClick,
)
}
is DetailsItemUM.WalletConnectAddressBookBlock -> {
is DetailsItemUM.WalletActionBlock -> {
BlockCard {
WalletConnectAddressBookBlockItems(
WalletActionsBlock(
items = model.items,
modifier = itemModifier,
)
@ -170,37 +180,106 @@ private fun Block(
is DetailsItemUM.UserWalletList -> {
userWalletListBlockContent.Content(modifier = itemModifier)
}
is DetailsItemUM.UnderSectionText -> { /* Handled above */
}
is DetailsItemUM.UnderSectionText -> Unit
}
}
}
@Composable
private fun WalletConnectAddressBookBlockItems(
items: List<DetailsItemUM.WalletConnectAddressBookBlock.Item>,
private fun WalletActionsBlock(
items: ImmutableList<DetailsItemUM.WalletActionBlock.Item>,
modifier: Modifier = Modifier,
) {
items.fastForEach { item ->
when (item) {
is DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect -> InputRowImageBase(
modifier = modifier.clickable(onClick = item.onClick).padding(12.dp),
iconResVector = R.drawable.ic_wallet_connect_24,
iconTint = TangemTheme.colors.icon.primary1,
subtitle = TextReference.Res(R.string.wallet_connect_title),
caption = TextReference.Res(R.string.wallet_connect_subtitle),
is DetailsItemUM.WalletActionBlock.Item.WalletConnect -> WalletConnectActionRow(
onClick = item.onClick,
modifier = modifier,
)
is DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook -> InputRowImageBase(
modifier = modifier.clickable(onClick = item.onClick).padding(12.dp),
iconResVector = R.drawable.ic_contact_20,
iconTint = TangemTheme.colors.icon.accent,
subtitle = TextReference.Res(R.string.address_book_title),
caption = TextReference.Res(R.string.address_book_description),
is DetailsItemUM.WalletActionBlock.Item.AddressBook -> AddressBookActionRow(
onClick = item.onClick,
modifier = modifier,
)
}
}
}
@Composable
private fun WalletConnectActionRow(onClick: () -> Unit, modifier: Modifier = Modifier) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
modifier = modifier,
onClick = onClick,
startSlot = {
TangemIcon(
tangemIconUM = TangemIconUM.Image(R.drawable.img_wallet_connect_76),
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp)),
)
},
titleSlot = {
TangemRowText(
text = TextReference.Res(R.string.wallet_connect_title),
role = TangemRowTextRole.Title,
)
},
subtitleSlot = {
TangemRowText(
text = TextReference.Res(R.string.wallet_connect_subtitle),
role = TangemRowTextRole.Subtitle,
)
},
endSlot = { ActionRowChevron() },
)
}
@Composable
private fun AddressBookActionRow(onClick: () -> Unit, modifier: Modifier = Modifier) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
modifier = modifier,
onClick = onClick,
startSlot = {
TangemIcon(
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_address_book_24,
tintReference = { TangemTheme.colors3.icon.brand },
),
modifier = Modifier
.size(40.dp)
.background(
color = TangemTheme.colors3.bg.status.infoSubtle,
shape = RoundedCornerShape(12.dp),
)
.padding(8.dp),
)
},
titleSlot = {
TangemRowText(
text = TextReference.Res(R.string.address_book_title),
role = TangemRowTextRole.Title,
)
},
subtitleSlot = {
TangemRowText(
text = TextReference.Res(R.string.address_book_description),
role = TangemRowTextRole.Subtitle,
)
},
endSlot = { ActionRowChevron() },
)
}
@Composable
private fun ActionRowChevron() {
Icon(
imageVector = Icons.ic_chevron_right_20,
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
)
}
@Composable
private fun UnderSectionTextBlock(text: TextReference, modifier: Modifier = Modifier) {
Text(

View file

@ -0,0 +1,58 @@
package com.tangem.features.details.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.inputrow.InputRowChecked
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.details.entity.SelectContactSupportTypeBS
import com.tangem.features.details.impl.R
@Composable
internal fun SelectContactSupportTypeBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<SelectContactSupportTypeBS>(
config = config,
titleText = resourceReference(R.string.common_contact_support),
containerColor = TangemTheme.colors.background.tertiary,
content = { Content(it) },
)
}
@Composable
private fun Content(content: SelectContactSupportTypeBS) {
Column(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
SelectContactSupportTypeBS.Option.entries.forEachIndexed { index, type ->
DividerContainer(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = SelectContactSupportTypeBS.Option.entries.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action)
.clickable { content.onOptionClick(type) },
showDivider = index != SelectContactSupportTypeBS.Option.entries.lastIndex,
) {
InputRowChecked(
text = type.text,
checked = false,
)
}
}
}
}

View file

@ -27,15 +27,13 @@ internal class ItemsBuilder @Inject constructor(
fun buildAll(
isWalletConnectAvailable: Boolean,
isAddressBookAvailable: Boolean,
isSupportChatAvailable: Boolean,
hasAnyMobileWallet: Boolean,
userWalletId: UserWalletId,
onSupportEmailClick: () -> Unit,
onSupportChatClick: () -> Unit,
onSupportClick: () -> Unit,
onBuyClick: () -> Unit,
): ImmutableList<DetailsItemUM> = buildList {
if (isAddressBookAvailable) {
buildWalletConnectAddressBookBlock(isWalletConnectAvailable, userWalletId)
buildWalletActionBlock(isWalletConnectAvailable, userWalletId)
} else {
buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
}
@ -50,11 +48,7 @@ internal class ItemsBuilder @Inject constructor(
buildShopBlock(onBuyClick).let(::add)
buildSettingsBlock().let(::add)
buildSupportBlock(
onSupportEmailClick = onSupportEmailClick,
onSupportChatClick = onSupportChatClick,
isSupportChatAvailable = isSupportChatAvailable,
).let(::add)
buildSupportBlock(onSupportClick = onSupportClick).let(::add)
}.toImmutableList()
fun addTangemPayItem(items: ImmutableList<DetailsItemUM>, onClick: () -> Unit): ImmutableList<DetailsItemUM> {
@ -91,29 +85,29 @@ internal class ItemsBuilder @Inject constructor(
}
}
private fun MutableList<DetailsItemUM>.buildWalletConnectAddressBookBlock(
private fun MutableList<DetailsItemUM>.buildWalletActionBlock(
isWalletConnectAvailable: Boolean,
userWalletId: UserWalletId,
) {
val walletConnectAddressBookItems = buildList {
val walletActionItems = buildList {
if (isWalletConnectAvailable) add(buildWalletConnectButton(userWalletId))
add(buildAddressBookButton())
}
if (walletConnectAddressBookItems.isNotEmpty()) {
add(DetailsItemUM.WalletConnectAddressBookBlock(walletConnectAddressBookItems))
}.toImmutableList()
if (walletActionItems.isNotEmpty()) {
add(DetailsItemUM.WalletActionBlock(walletActionItems))
}
}
private fun buildWalletConnectButton(
userWalletId: UserWalletId,
): DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect {
return DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect(
): DetailsItemUM.WalletActionBlock.Item.WalletConnect {
return DetailsItemUM.WalletActionBlock.Item.WalletConnect(
onClick = { router.push(AppRoute.WalletConnectSessions(userWalletId)) },
)
}
private fun buildAddressBookButton(): DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook {
return DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook(
private fun buildAddressBookButton(): DetailsItemUM.WalletActionBlock.Item.AddressBook {
return DetailsItemUM.WalletActionBlock.Item.AddressBook(
onClick = { router.push(AppRoute.AddressBook()) },
)
}
@ -148,33 +142,18 @@ internal class ItemsBuilder @Inject constructor(
}.toImmutableList(),
)
private fun buildSupportBlock(
onSupportEmailClick: () -> Unit,
onSupportChatClick: () -> Unit,
isSupportChatAvailable: Boolean,
): DetailsItemUM = DetailsItemUM.Basic(
private fun buildSupportBlock(onSupportClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic(
id = "support",
items = buildList {
DetailsItemUM.Basic.Item(
id = "support_email",
id = "contact_support",
block = BlockUM(
text = resourceReference(R.string.common_contact_support),
iconRes = R.drawable.ic_comment_24,
onClick = onSupportEmailClick,
onClick = onSupportClick,
),
).let(::add)
if (isSupportChatAvailable) {
DetailsItemUM.Basic.Item(
id = "support_chat",
block = BlockUM(
text = resourceReference(R.string.details_row_title_contact_to_support_chat),
iconRes = R.drawable.ic_chat_24,
onClick = onSupportChatClick,
),
).let(::add)
}
DetailsItemUM.Basic.Item(
id = "disclaimer",
block = BlockUM(

View file

@ -34,7 +34,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
// Act
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
onSupportSlot.captured.invoke()
advanceUntilIdle()
// Assert
@ -54,7 +54,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
onSupportSlot.captured.invoke()
advanceUntilIdle()
verify { analyticsEventHandler.send(any<Basic.ButtonSupport>()) }
@ -73,7 +73,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
onSupportSlot.captured.invoke()
advanceUntilIdle()
val bsConfig = model.state.value.selectFeedbackEmailTypeBSConfig
@ -93,7 +93,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
onSupportSlot.captured.invoke()
advanceUntilIdle()
coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) }
@ -183,7 +183,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
currentModel = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
onSupportSlot.captured.invoke()
advanceUntilIdle()
return currentModel.state.value.selectFeedbackEmailTypeBSConfig.content as SelectEmailFeedbackTypeBS

View file

@ -65,24 +65,6 @@ internal class DetailsModelInitTest : DetailsModelTestBase() {
assertThat(abSlot.captured).isFalse()
}
@Test
fun `GIVEN usedesk enabled WHEN init THEN buildAll receives isSupportChatAvailable true`() = runTest {
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(chatSlot.captured).isTrue()
}
@Test
fun `GIVEN usedesk disabled WHEN init THEN buildAll receives isSupportChatAvailable false`() = runTest {
every { feedbackFeatureToggles.isUsedeskEnabled } returns false
createModel(this).also { advanceUntilIdle() }.onDestroy()
assertThat(chatSlot.captured).isFalse()
}
@Test
fun `GIVEN a hot wallet present WHEN init THEN buildAll receives hasAnyMobileWallet true`() = runTest {
every { getWalletsUseCase.invokeSync() } returns listOf(hotWallet(wallet1))

View file

@ -5,7 +5,9 @@ import arrow.core.right
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.features.details.entity.SelectContactSupportTypeBS
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -17,17 +19,19 @@ import org.junit.jupiter.api.Test
internal class DetailsModelNavigationTest : DetailsModelTestBase() {
@Test
fun `GIVEN selected wallet and meta WHEN support chat clicked THEN router pushes Usedesk`() = runTest {
fun `GIVEN usedesk enabled WHEN chat option selected THEN router pushes Usedesk`() = runTest {
// Arrange
val wallet = hotWallet(wallet1)
val meta = metaInfo(wallet1)
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
// Act
val model = createModel(this)
advanceUntilIdle()
onChatSlot.captured.invoke()
onSupportSlot.captured.invoke()
selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Chat)
advanceUntilIdle()
// Assert
@ -36,20 +40,51 @@ internal class DetailsModelNavigationTest : DetailsModelTestBase() {
}
@Test
fun `GIVEN meta info missing WHEN support chat clicked THEN no navigation`() = runTest {
fun `GIVEN meta info missing WHEN chat option selected THEN no navigation`() = runTest {
val wallet = hotWallet(wallet1)
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns Throwable().left()
val model = createModel(this)
advanceUntilIdle()
onChatSlot.captured.invoke()
onSupportSlot.captured.invoke()
selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Chat)
advanceUntilIdle()
verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
model.onDestroy()
}
@Test
fun `GIVEN usedesk enabled WHEN mail option selected THEN sends email and does not open Usedesk`() = runTest {
// Arrange
val wallet = hotWallet(wallet1)
val meta = metaInfo(wallet1)
every { feedbackFeatureToggles.isUsedeskEnabled } returns true
every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
// Act
val model = createModel(this)
advanceUntilIdle()
onSupportSlot.captured.invoke()
selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Mail)
advanceUntilIdle()
// Assert
coVerify { sendFeedbackEmailUseCase(any()) }
verify(exactly = 0) { router.push(route = AppRoute.Usedesk(meta), onComplete = any()) }
model.onDestroy()
}
private fun selectContactSupportOption(model: DetailsModel, option: SelectContactSupportTypeBS.Option) {
val content = model.state.value.selectContactSupportTypeBSConfig.content as SelectContactSupportTypeBS
content.onOptionClick(option)
}
@Test
fun `GIVEN buy link WHEN buy clicked THEN opens url and sends analytics`() = runTest {
coEvery {

View file

@ -70,11 +70,9 @@ internal abstract class DetailsModelTestBase {
// Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven.
protected val wcSlot = slot<Boolean>()
protected val abSlot = slot<Boolean>()
protected val chatSlot = slot<Boolean>()
protected val mobileSlot = slot<Boolean>()
protected val walletIdSlot = slot<UserWalletId>()
protected val onEmailSlot = slot<() -> Unit>()
protected val onChatSlot = slot<() -> Unit>()
protected val onSupportSlot = slot<() -> Unit>()
protected val onBuySlot = slot<() -> Unit>()
protected val onTangemPaySlot = slot<() -> Unit>()
@ -96,11 +94,9 @@ internal abstract class DetailsModelTestBase {
itemsBuilder.buildAll(
isWalletConnectAvailable = capture(wcSlot),
isAddressBookAvailable = capture(abSlot),
isSupportChatAvailable = capture(chatSlot),
hasAnyMobileWallet = capture(mobileSlot),
userWalletId = capture(walletIdSlot),
onSupportEmailClick = capture(onEmailSlot),
onSupportChatClick = capture(onChatSlot),
onSupportClick = capture(onSupportSlot),
onBuyClick = capture(onBuySlot),
)
} returns persistentListOf()
@ -136,7 +132,7 @@ internal abstract class DetailsModelTestBase {
protected fun stubBuildAllReturns(list: ImmutableList<DetailsItemUM>) {
every {
itemsBuilder.buildAll(any(), any(), any(), any(), any(), any(), any(), any())
itemsBuilder.buildAll(any(), any(), any(), any(), any(), any())
} returns list
}

View file

@ -65,10 +65,10 @@ internal class ItemsBuilderTest {
"support",
).inOrder()
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
val block = result.first() as DetailsItemUM.WalletActionBlock
assertThat(block.items.map { it::class.java }).containsExactly(
DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect::class.java,
DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java,
DetailsItemUM.WalletActionBlock.Item.WalletConnect::class.java,
DetailsItemUM.WalletActionBlock.Item.AddressBook::class.java,
).inOrder()
}
@ -86,9 +86,9 @@ internal class ItemsBuilderTest {
"support",
).inOrder()
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
val block = result.first() as DetailsItemUM.WalletActionBlock
assertThat(block.items.map { it::class.java }).containsExactly(
DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java,
DetailsItemUM.WalletActionBlock.Item.AddressBook::class.java,
)
}
@ -109,9 +109,9 @@ internal class ItemsBuilderTest {
fun `GIVEN combined block walletConnect item WHEN clicked THEN router pushes WalletConnectSessions`() {
// Arrange
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true)
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
val block = result.first() as DetailsItemUM.WalletActionBlock
val walletConnect = block.items
.filterIsInstance<DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect>()
.filterIsInstance<DetailsItemUM.WalletActionBlock.Item.WalletConnect>()
.single()
// Act
@ -125,9 +125,9 @@ internal class ItemsBuilderTest {
fun `GIVEN combined block addressBook item WHEN clicked THEN router pushes AddressBook`() {
// Arrange
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true)
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
val block = result.first() as DetailsItemUM.WalletActionBlock
val addressBook = block.items
.filterIsInstance<DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook>()
.filterIsInstance<DetailsItemUM.WalletActionBlock.Item.AddressBook>()
.single()
// Act
@ -192,7 +192,6 @@ internal class ItemsBuilderTest {
val result = buildAll(
isWalletConnectAvailable = false,
isAddressBookAvailable = false,
isSupportChatAvailable = false,
hasAnyMobileWallet = false,
)
@ -209,7 +208,7 @@ internal class ItemsBuilderTest {
assertThat(shop.items.map { it.id }).containsExactly("buy_tangem_wallet")
val support = result.single { it.id == "support" } as DetailsItemUM.Basic
assertThat(support.items.map { it.id }).containsExactly("support_email", "disclaimer").inOrder()
assertThat(support.items.map { it.id }).containsExactly("contact_support", "disclaimer").inOrder()
}
@Test
@ -254,16 +253,13 @@ internal class ItemsBuilderTest {
private fun buildAll(
isWalletConnectAvailable: Boolean = false,
isAddressBookAvailable: Boolean = false,
isSupportChatAvailable: Boolean = false,
hasAnyMobileWallet: Boolean = false,
): ImmutableList<DetailsItemUM> = itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable,
isAddressBookAvailable = isAddressBookAvailable,
isSupportChatAvailable = isSupportChatAvailable,
hasAnyMobileWallet = hasAnyMobileWallet,
userWalletId = USER_WALLET_ID,
onSupportEmailClick = {},
onSupportChatClick = {},
onSupportClick = {},
onBuyClick = {},
)

1
features/for-you/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,20 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
id("kotlin-parcelize")
id("configuration")
}
android {
namespace = "com.tangem.features.foryou.api"
}
dependencies {
/** Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/** Other dependencies */
implementation(deps.compose.foundation)
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.foryou
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
interface ForYouComponent : ComposableModularBottomSheetContentComponent {
interface Factory : ComponentFactory<Unit, ForYouComponent>
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.foryou
interface ForYouFeatureToggles {
val isForYouEnabled: Boolean
}

1
features/for-you/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,30 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.foryou.impl"
}
dependencies {
/** Features */
implementation(projects.features.forYou.api)
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.configToggles)
implementation(deps.compose.ui)
implementation(deps.compose.foundation)
implementation(deps.lifecycle.compose)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.foryou.impl
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.features.foryou.ForYouComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultForYouComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Suppress("UnusedPrivateMember") @Assisted params: Unit,
) : AppComponentContext by context, ForYouComponent {
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
TODO("Not yet implemented")
}
@Composable
override fun Content(
bottomSheetState: State<BottomSheetState>,
contentPadding: PaddingValues,
modifier: Modifier,
) {
TODO("Not yet implemented")
}
@AssistedFactory
interface Factory : ForYouComponent.Factory {
override fun create(context: AppComponentContext, params: Unit): DefaultForYouComponent
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.foryou.impl.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.ForYouFeatureToggles
import com.tangem.features.foryou.impl.DefaultForYouComponent
import com.tangem.features.foryou.impl.featuretoggles.DefaultForYouFeatureToggles
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object ForYouFeatureModule {
@Provides
@Singleton
fun provideForYouFeatureToggles(featureTogglesManager: FeatureTogglesManager): ForYouFeatureToggles {
return DefaultForYouFeatureToggles(featureTogglesManager = featureTogglesManager)
}
}
@Module
@InstallIn(SingletonComponent::class)
internal interface ForYouComponentModule {
@Binds
@Singleton
fun bindForYouComponent(factory: DefaultForYouComponent.Factory): ForYouComponent.Factory
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.foryou.impl.featuretoggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.foryou.ForYouFeatureToggles
import javax.inject.Inject
internal class DefaultForYouFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : ForYouFeatureToggles {
override val isForYouEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1469_FOR_YOU_ENABLED)
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.home.api
interface HomeFeatureToggles {
val isStoriesContainerEnabled: Boolean
}

Some files were not shown because too many files have changed in this diff Show more