Updated on 2026-08-14
This commit is contained in:
commit
53ffcc2918
677 changed files with 33091 additions and 6980 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
|
||||
/**
|
||||
* Bottom sheet shown when a picked contact has more than one address in the target network. Lets the user choose a
|
||||
* concrete address; the chosen one is returned via [Params.onAddressSelected] as a [SelectedContact].
|
||||
*/
|
||||
interface AddressSelectorComponent : ComposableBottomSheetComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, AddressSelectorComponent>
|
||||
|
||||
data class Params(
|
||||
val contact: MatchedContact,
|
||||
val onAddressSelected: (SelectedContact) -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
/**
|
||||
* Delivers a contact picked in the full address-book list (opened in selection mode) back to whatever feature
|
||||
* requested the selection. The picker and the requesting feature live in independent model scopes, so a one-shot
|
||||
* [SharedFlow] is used instead of a retained holder: nothing is kept after emission, so there is nothing to clear.
|
||||
*
|
||||
* Mirrors the `SwapChooseTokenNetworkTrigger`/`Listener` pattern.
|
||||
*/
|
||||
interface ContactSelectionTrigger {
|
||||
|
||||
fun trigger(contact: SelectedContact)
|
||||
}
|
||||
|
||||
interface ContactSelectionListener {
|
||||
|
||||
val resultFlow: SharedFlow<SelectedContact>
|
||||
}
|
||||
|
|
@ -0,0 +1,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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.features.addressbook.addressselector
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.addressselector.ui.AddressSelectorBottomSheet
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultAddressSelectorComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: AddressSelectorComponent.Params,
|
||||
) : AddressSelectorComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
override fun dismiss() = params.onDismiss()
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
AddressSelectorBottomSheet(
|
||||
contact = params.contact,
|
||||
onAddressClick = { entry -> params.onAddressSelected(params.contact.toSelectedContact(entry)) },
|
||||
onDismiss = ::dismiss,
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressSelectorComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressSelectorComponent.Params,
|
||||
): DefaultAddressSelectorComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.features.addressbook.block
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.block.model.ContactsBlockModel
|
||||
import com.tangem.features.addressbook.block.ui.ContactsBlock
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultAddressBookContactsBlockComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted params: AddressBookContactsBlockComponent.Params,
|
||||
) : AddressBookContactsBlockComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: ContactsBlockModel = getOrCreateModel(params)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
ContactsBlock(state = state, modifier = modifier)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressBookContactsBlockComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressBookContactsBlockComponent.Params,
|
||||
): DefaultAddressBookContactsBlockComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.addressbook.block.state
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class ContactsBlockStateController @Inject constructor() {
|
||||
|
||||
val uiState: StateFlow<ContactsBlockUM>
|
||||
field = MutableStateFlow<ContactsBlockUM>(value = ContactsBlockUM.Hidden)
|
||||
|
||||
fun update(transformer: Transformer<ContactsBlockUM>) {
|
||||
uiState.update(function = transformer::transform)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.features.addressbook.ContactSelectionListener
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.SelectedContact
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One-shot delivery of a contact picked on the full address-book list back to the Send flow.
|
||||
*
|
||||
* Implements both [ContactSelectionTrigger] (the list emits) and [ContactSelectionListener] (Send collects). The flow
|
||||
* is no-replay with a 1-item buffer so [trigger] is non-blocking ([tryEmit]) — the picker can always close even if the
|
||||
* collector is momentarily absent; nothing is retained for late subscribers, so there is no stale value to clear.
|
||||
*/
|
||||
@Singleton
|
||||
internal class DefaultContactSelectionTrigger @Inject constructor() :
|
||||
ContactSelectionTrigger,
|
||||
ContactSelectionListener {
|
||||
|
||||
private val mutableResultFlow = MutableSharedFlow<SelectedContact>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
override val resultFlow: SharedFlow<SelectedContact> = mutableResultFlow
|
||||
|
||||
override fun trigger(contact: SelectedContact) {
|
||||
mutableResultFlow.tryEmit(contact)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {})
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 */ },
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@ internal data class DetailsUM(
|
|||
val items: ImmutableList<DetailsItemUM>,
|
||||
val footer: DetailsFooterUM,
|
||||
val selectFeedbackEmailTypeBSConfig: TangemBottomSheetConfig,
|
||||
val selectContactSupportTypeBSConfig: TangemBottomSheetConfig,
|
||||
val popBack: () -> Unit,
|
||||
)
|
||||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
features/for-you/api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
20
features/for-you/api/build.gradle.kts
Normal file
20
features/for-you/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.features.foryou
|
||||
|
||||
interface ForYouFeatureToggles {
|
||||
val isForYouEnabled: Boolean
|
||||
}
|
||||
1
features/for-you/impl/.gitignore
vendored
Normal file
1
features/for-you/impl/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
30
features/for-you/impl/build.gradle.kts
Normal file
30
features/for-you/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue