Updated on 2026-08-14
This commit is contained in:
commit
50fe9e603d
1919 changed files with 94940 additions and 15178 deletions
|
|
@ -10,16 +10,12 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/* Project - Core */
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.common.ui)
|
||||
// region Project - Core
|
||||
api(projects.core.decompose)
|
||||
api(projects.core.ui)
|
||||
// endregion
|
||||
|
||||
/* Project - Domain */
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.account)
|
||||
// region Project - Domain
|
||||
api(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -13,43 +13,35 @@ android {
|
|||
|
||||
dependencies {
|
||||
/** Api */
|
||||
implementation(projects.features.account.api)
|
||||
implementation(projects.features.wallet.api)
|
||||
api(projects.features.account.api)
|
||||
|
||||
/** Core modules */
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.analytics.models)
|
||||
implementation(projects.core.utils)
|
||||
api(projects.core.analytics)
|
||||
api(projects.core.analytics.models)
|
||||
api(projects.core.decompose)
|
||||
api(projects.core.error)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.error)
|
||||
implementation(projects.core.res)
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.datasource)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.account.status)
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.account.status)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.wallets)
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.appCurrency)
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.balanceHiding)
|
||||
implementation(projects.domain.balanceHiding.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.models)
|
||||
|
||||
/** Tangem libraries */
|
||||
implementation(tangemDeps.card.core)
|
||||
runtimeOnly(projects.domain.appCurrency)
|
||||
runtimeOnly(projects.domain.balanceHiding)
|
||||
runtimeOnly(projects.domain.tokens)
|
||||
|
||||
/** Common */
|
||||
implementation(projects.common.ui)
|
||||
implementation(projects.common.routing)
|
||||
|
||||
/** AndroidX libraries */
|
||||
implementation(deps.androidx.core.ktx)
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.appCompat)
|
||||
implementation(deps.lifecycle.compose)
|
||||
implementation(deps.lifecycle.runtime.ktx)
|
||||
|
||||
/** Compose libraries */
|
||||
|
|
@ -58,14 +50,13 @@ dependencies {
|
|||
implementation(deps.compose.foundation)
|
||||
implementation(deps.compose.ui)
|
||||
implementation(deps.compose.ui.tooling)
|
||||
implementation(deps.compose.coil)
|
||||
implementation(deps.decompose.ext.compose)
|
||||
implementation(deps.androidx.activity.compose)
|
||||
|
||||
/** Other libraries */
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
implementation(deps.kotlin.serialization)
|
||||
implementation(deps.firebase.crashlytics)
|
||||
|
||||
/** DI */
|
||||
|
|
|
|||
|
|
@ -10,13 +10,22 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/* Project - Domain */
|
||||
implementation(projects.domain.models)
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
api(deps.kotlin.immutable.collections)
|
||||
// endregion
|
||||
|
||||
/* Project - Core */
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.ui)
|
||||
// region Project - Common
|
||||
api(projects.common.routing)
|
||||
api(projects.common.ui)
|
||||
// endregion
|
||||
|
||||
/* Compose */
|
||||
implementation(deps.compose.runtime)
|
||||
// region Project - Core
|
||||
api(projects.core.decompose)
|
||||
api(projects.core.ui)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -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,21 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Address-book analytics that are triggered from outside the address-book feature (i.e. from the Send flow).
|
||||
*
|
||||
* The [com.tangem.features.addressbook.analytics] events are internal to the address-book impl module, so the Send
|
||||
* feature cannot construct them directly — it reports the address-book funnel through this contract instead.
|
||||
*/
|
||||
interface AddressBookSendAnalytics {
|
||||
|
||||
/**
|
||||
* Fired when the recipient address (and memo, if any) picked from the address book has been substituted into the
|
||||
* Send form — the final step of choosing a recipient from the book.
|
||||
*
|
||||
* @param walletId the current (sending) wallet
|
||||
* @param contactId id of the picked contact
|
||||
*/
|
||||
fun onAddressSubstitutedInSend(walletId: UserWalletId, contactId: String)
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -13,25 +13,31 @@ android {
|
|||
|
||||
dependencies {
|
||||
/** Api */
|
||||
implementation(projects.features.addressBook.api)
|
||||
api(projects.features.addressBook.api)
|
||||
api(projects.features.commonFeatures.api)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.addressBook)
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.addressBook)
|
||||
api(projects.domain.wallets)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.qrScanning)
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
|
||||
/** Common */
|
||||
implementation(projects.common.routing)
|
||||
implementation(projects.common.ui)
|
||||
|
||||
/** Core modules */
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.utils)
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.decompose)
|
||||
api(projects.core.ui)
|
||||
api(projects.core.utils)
|
||||
|
||||
/** Compose */
|
||||
api(deps.compose.foundation)
|
||||
implementation(deps.compose.animation)
|
||||
implementation(deps.compose.runtime)
|
||||
implementation(deps.compose.foundation)
|
||||
implementation(deps.compose.ui)
|
||||
implementation(deps.compose.ui.tooling)
|
||||
implementation(deps.compose.material3)
|
||||
|
|
@ -44,7 +50,12 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
api(deps.kotlin.immutable.collections)
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.appCompat)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.serialization.core)
|
||||
|
||||
/** Utils */
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
|
|
|||
|
|
@ -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,14 @@ 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 walletId: String?,
|
||||
val excludeContactId: String?,
|
||||
val prefillAddress: String?,
|
||||
val prefillNetworkIds: List<String>,
|
||||
val prefillMemo: String?,
|
||||
val onBackClick: () -> Unit,
|
||||
val onSelectNetworksClick: (matchedNetworkIds: List<String>, selectedNetworkIds: List<String>) -> Unit,
|
||||
val onConfirm: (address: ValidatedAddress, replaces: String?) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
package com.tangem.features.addressbook.addaddress.contract
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.domain.models.network.Network
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
internal data class AddAddressUM(
|
||||
val addressField: AddressFieldUM,
|
||||
val availableNetworks: ImmutableList<Network>,
|
||||
val buttonUM: TangemButtonUM,
|
||||
val chosenNetworkStateUM: ChosenNetworkStateUM,
|
||||
val onAddressChange: (String) -> Unit,
|
||||
val onAddressClear: () -> Unit,
|
||||
val onPasteClick: () -> Unit,
|
||||
val onQrClick: () -> Unit,
|
||||
val onBackClick: () -> Unit,
|
||||
) {
|
||||
@Immutable
|
||||
sealed class ChosenNetworkStateUM {
|
||||
data object Loading : ChosenNetworkStateUM()
|
||||
data object Empty : ChosenNetworkStateUM()
|
||||
data class Result(
|
||||
val networkUMList: ImmutableList<NetworkUM>,
|
||||
) : ChosenNetworkStateUM() {
|
||||
|
||||
data class NetworkUM(
|
||||
val networkName: String,
|
||||
@DrawableRes val iconResId: Int,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,149 +1,297 @@
|
|||
package com.tangem.features.addressbook.addaddress.model
|
||||
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.common.ui.extensions.iconResId
|
||||
import arrow.core.getOrElse
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.common.routing.AppRoute
|
||||
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.decompose.navigation.Router
|
||||
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.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.usecase.CheckAddressDuplicateUseCase
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.qrscanning.models.SourceType
|
||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||
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.state.transformers.UpdateMemoInputTransformer
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.common.AddressMemoValidator
|
||||
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
|
||||
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.map
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Suppress("LongParameterList")
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
@ModelScoped
|
||||
internal class AddAddressModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val supportedNetworksMatcher: SupportedNetworksMatcher,
|
||||
private val memoValidator: AddressMemoValidator,
|
||||
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
|
||||
private val clipboardManager: ClipboardManager,
|
||||
private val stateController: AddAddressStateController,
|
||||
private val selectNetworksResultHolder: SelectNetworksResultHolder,
|
||||
private val checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
private val router: Router,
|
||||
) : 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 ->
|
||||
accountLists
|
||||
.flatMap { it.flattenCurrencies() }
|
||||
.filterIsInstance<CryptoCurrency.Coin>()
|
||||
.distinctBy { it.network.id }
|
||||
}
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val addressInput = state
|
||||
private val validation: StateFlow<AddressValidation> = state
|
||||
.map { it.addressField.value }
|
||||
.distinctUntilChanged()
|
||||
.debounce(ADD_ADDRESS_DEBOUNCE)
|
||||
.map { address ->
|
||||
AddressValidation(address = address, matchedBlockchains = supportedNetworksMatcher.match(address))
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, AddressValidation(address = "", matchedBlockchains = emptyList()))
|
||||
|
||||
/** `true` when a non-blank memo doesn't pass the chosen network's format rules (e.g. XRP destination tag). */
|
||||
private val isMemoInvalid = MutableStateFlow(false)
|
||||
|
||||
private val selectedNetworkIds = MutableStateFlow<Set<String>?>(null)
|
||||
|
||||
private val chosenNetworks: StateFlow<ChosenNetworks> = combine(
|
||||
validation,
|
||||
selectedNetworkIds,
|
||||
) { validation, selected ->
|
||||
val matched = validation.matchedBlockchains
|
||||
ChosenNetworks(
|
||||
address = validation.address,
|
||||
matched = matched,
|
||||
selected = selectedNetworks(matched, selected),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.stateIn(
|
||||
modelScope,
|
||||
SharingStarted.Eagerly,
|
||||
ChosenNetworks(address = "", matched = emptyList(), selected = emptyList()),
|
||||
)
|
||||
|
||||
/**
|
||||
* Name of the contact that already holds one of the selected `network + address` pairs in the target wallet, or
|
||||
* `null` when the pair is free.
|
||||
*/
|
||||
private val duplicateName: StateFlow<String?> = chosenNetworks
|
||||
.mapLatest { networks ->
|
||||
val walletId = params.walletId ?: return@mapLatest null
|
||||
if (networks.selected.isEmpty()) return@mapLatest null
|
||||
networks.selected.firstNotNullOfOrNull { blockchain ->
|
||||
checkAddressDuplicateUseCase(
|
||||
userWalletId = UserWalletId(walletId),
|
||||
networkId = blockchain.toNetworkId(),
|
||||
address = networks.address,
|
||||
excludeContactId = params.excludeContactId?.let(::ContactId),
|
||||
)
|
||||
}
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
subscribeToAddressInput()
|
||||
// Drop any selection left over from a previous AddAddress session before subscribing to it.
|
||||
selectNetworksResultHolder.clear()
|
||||
sendInitAnalytics()
|
||||
updateInitialState()
|
||||
subscribeToValidation()
|
||||
subscribeToMemoValidation()
|
||||
subscribeToSelectedNetworks()
|
||||
subscribeToQrScanResult()
|
||||
subscribeToAddressInvalid()
|
||||
prefillData()
|
||||
}
|
||||
|
||||
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,
|
||||
private fun prefillData() {
|
||||
val prefillAddress = params.prefillAddress ?: return
|
||||
onAddressChange(prefillAddress)
|
||||
selectedNetworkIds.value = params.prefillNetworkIds.toSet().ifEmpty { null }
|
||||
params.prefillMemo?.let(::onMemoChange)
|
||||
}
|
||||
|
||||
private fun updateInitialState() {
|
||||
stateController.update(
|
||||
UpdateAddAddressInitialStateTransformer(
|
||||
intents = UpdateAddAddressInitialStateTransformer.Intents(
|
||||
onAddressChange = ::onAddressChange,
|
||||
onAddressClear = { onAddressChange("") },
|
||||
onPasteClick = ::onPaste,
|
||||
onQrClick = ::onQrClick,
|
||||
onBackClick = params.onBackClick,
|
||||
onNetworkClick = ::onNetworkClick,
|
||||
onMemoChange = ::onMemoChange,
|
||||
onMemoPasteClick = ::onMemoPaste,
|
||||
onConfirmClick = ::validateAndConfirm,
|
||||
),
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading,
|
||||
isEditMode = params.prefillAddress != null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onAddressChange(value: String) {
|
||||
stateController.update(UpdateAddressInputTransformer(value = value))
|
||||
selectedNetworkIds.value = null
|
||||
}
|
||||
|
||||
private fun onMemoChange(value: String) {
|
||||
stateController.update(UpdateMemoInputTransformer(value = value))
|
||||
}
|
||||
|
||||
private fun subscribeToValidation() {
|
||||
combine(chosenNetworks, isMemoInvalid, duplicateName) { networks, memoInvalid, duplicate ->
|
||||
UpdateAddressValidationTransformer(
|
||||
address = networks.address,
|
||||
matchedBlockchains = networks.matched,
|
||||
selectedBlockchains = networks.selected,
|
||||
isMemoInvalid = memoInvalid,
|
||||
duplicateName = duplicate,
|
||||
)
|
||||
}
|
||||
.onEach(stateController::update)
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun subscribeToAddressInput() {
|
||||
combine(addressInput, availableCoins) { input, coins ->
|
||||
getUniqueNetworks(input, coins)
|
||||
}
|
||||
.onEach { availableNetworks ->
|
||||
state.update { oldState ->
|
||||
oldState.copy(
|
||||
availableNetworks = availableNetworks,
|
||||
chosenNetworkStateUM = createChosenNetworkState(availableNetworks),
|
||||
)
|
||||
}
|
||||
private fun subscribeToMemoValidation() {
|
||||
val memoInput = state.map { it.memoField.value }.distinctUntilChanged().debounce(MEMO_DEBOUNCE)
|
||||
combine(memoInput, chosenNetworks) { memo, networks -> memo to networks.extrasBlockchain }
|
||||
.mapLatest { (memo, blockchain) ->
|
||||
blockchain != null && memo.isNotBlank() && !memoValidator.isValid(blockchain, memo)
|
||||
}
|
||||
.onEach { isMemoInvalid.value = it }
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun subscribeToSelectedNetworks() {
|
||||
selectNetworksResultHolder.selectedNetworkIds
|
||||
.filterNotNull()
|
||||
.onEach { ids ->
|
||||
selectedNetworkIds.value = ids
|
||||
selectNetworksResultHolder.clear()
|
||||
}
|
||||
.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 subscribeToAddressInvalid() {
|
||||
val walletId = params.walletId ?: return
|
||||
validation
|
||||
.map { it.address.isNotBlank() && it.matchedBlockchains.isEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.filter { isInvalid -> isInvalid }
|
||||
.onEach {
|
||||
analyticsSender.sendAddressInvalid(
|
||||
walletId = UserWalletId(walletId),
|
||||
contactId = params.excludeContactId.orEmpty(),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun onPaste() {
|
||||
onAddressChange(value = clipboardManager.getText().orEmpty(), isPasted = true)
|
||||
onAddressChange(value = clipboardManager.getText().orEmpty())
|
||||
}
|
||||
|
||||
private fun onMemoPaste() {
|
||||
onMemoChange(value = clipboardManager.getText().orEmpty())
|
||||
}
|
||||
|
||||
private fun onQrClick() {
|
||||
router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.AddressBook))
|
||||
}
|
||||
|
||||
private fun subscribeToQrScanResult() {
|
||||
listenToQrScanningUseCase(SourceType.ADDRESS_BOOK)
|
||||
.getOrElse { emptyFlow() }
|
||||
.onEach { onAddressChange(value = normalizeScannedAddress(it)) }
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the bare address from a scanned payment URI like `ethereum:0xADDR@1?amount=1.5`: drops the query
|
||||
* (`?…`), the chain suffix (`@…`) and the scheme (`scheme:`). A plain address is returned unchanged.
|
||||
*/
|
||||
private fun normalizeScannedAddress(raw: String): String {
|
||||
val withoutQueryAndChain = raw.trim().substringBefore('?').substringBefore('@')
|
||||
return withoutQueryAndChain.substringAfter(':', missingDelimiterValue = withoutQueryAndChain)
|
||||
}
|
||||
|
||||
private fun onNetworkClick() {
|
||||
params.onSelectNetworksClick(
|
||||
chosenNetworks.value.matched.map { it.toNetworkId() },
|
||||
selectedNetworkIds.value?.toList().orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateAndConfirm() {
|
||||
// TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm
|
||||
val networks = chosenNetworks.value
|
||||
if (networks.selected.isEmpty()) return
|
||||
if (duplicateName.value != null) return
|
||||
|
||||
val memoField = stateController.uiState.value.memoField
|
||||
val memo = memoField.value.trim().takeIf { memoField.isVisible && it.isNotEmpty() }
|
||||
params.onConfirm(
|
||||
ValidatedAddress(
|
||||
address = networks.address,
|
||||
networkIds = networks.selected.map { it.toNetworkId() }.toImmutableList(),
|
||||
memo = memo,
|
||||
),
|
||||
// In the edit-address flow this confirmation supersedes the entry the screen was opened for.
|
||||
params.prefillAddress,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
/**
|
||||
* What is actually selected for saving. A single matched network is auto-selected (there is nothing to choose and
|
||||
* the selection screen can't be opened); otherwise the user must pick explicitly before saving.
|
||||
*/
|
||||
private fun selectedNetworks(matched: List<Blockchain>, selected: Set<String>?): List<Blockchain> {
|
||||
if (selected == null) return listOfNotNull(matched.singleOrNull())
|
||||
return matched.filter { it.toNetworkId() in selected }
|
||||
}
|
||||
|
||||
private fun sendInitAnalytics() {
|
||||
analyticsSender.sendAddressScreenOpened()
|
||||
}
|
||||
|
||||
private data class AddressValidation(
|
||||
val address: String,
|
||||
val matchedBlockchains: List<Blockchain>,
|
||||
)
|
||||
|
||||
private data class ChosenNetworks(
|
||||
val address: String,
|
||||
val matched: List<Blockchain>,
|
||||
val selected: List<Blockchain>,
|
||||
) {
|
||||
/** The first selected network that supports a memo / destination tag, if any. */
|
||||
val extrasBlockchain: Blockchain?
|
||||
get() = selected.firstOrNull { it.getSupportedTransactionExtras().isTxExtrasSupported() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ADD_ADDRESS_DEBOUNCE = 500L
|
||||
private const val MEMO_DEBOUNCE = 300L
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
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,
|
||||
),
|
||||
memoField = AddAddressUM.MemoFieldUM(
|
||||
isVisible = false,
|
||||
value = "",
|
||||
label = resourceReference(R.string.send_extras_hint_memo),
|
||||
isError = false,
|
||||
onValueChange = {},
|
||||
onPasteClick = {},
|
||||
),
|
||||
buttonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_save_address),
|
||||
type = TangemButtonType.Primary,
|
||||
isEnabled = false,
|
||||
onClick = {},
|
||||
),
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden,
|
||||
onAddressChange = {},
|
||||
onAddressClear = {},
|
||||
onPasteClick = {},
|
||||
onQrClick = {},
|
||||
onBackClick = {},
|
||||
onNetworkClick = {},
|
||||
title = resourceReference(R.string.address_book_add_address),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
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
|
||||
|
||||
/**
|
||||
* 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 intents: Intents,
|
||||
private val isEditMode: Boolean,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
return prevState.copy(
|
||||
onAddressChange = intents.onAddressChange,
|
||||
onAddressClear = intents.onAddressClear,
|
||||
onPasteClick = intents.onPasteClick,
|
||||
onQrClick = intents.onQrClick,
|
||||
onBackClick = intents.onBackClick,
|
||||
onNetworkClick = intents.onNetworkClick,
|
||||
memoField = prevState.memoField.copy(
|
||||
onValueChange = intents.onMemoChange,
|
||||
onPasteClick = intents.onMemoPasteClick,
|
||||
),
|
||||
buttonUM = prevState.buttonUM.copy(onClick = intents.onConfirmClick),
|
||||
title = if (isEditMode) {
|
||||
resourceReference(R.string.address_book_edit_address)
|
||||
} else {
|
||||
resourceReference(R.string.address_book_add_address)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
data class Intents(
|
||||
val onAddressChange: (String) -> Unit,
|
||||
val onAddressClear: () -> Unit,
|
||||
val onPasteClick: () -> Unit,
|
||||
val onQrClick: () -> Unit,
|
||||
val onBackClick: () -> Unit,
|
||||
val onNetworkClick: () -> Unit,
|
||||
val onMemoChange: (String) -> Unit,
|
||||
val onMemoPasteClick: () -> Unit,
|
||||
val onConfirmClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
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.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM
|
||||
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 confirm button is disabled while validation is pending; the actual (re)validation runs after a debounce —
|
||||
* see [UpdateAddressValidationTransformer].
|
||||
*
|
||||
* The network selector reflects the pending validation: a non-blank address shows [ChosenNetworkStateUM.Loading], but
|
||||
* an already-resolved selector keeps its networks on screen instead of flashing back to the spinner on every keystroke.
|
||||
*/
|
||||
internal class UpdateAddressInputTransformer(
|
||||
private val value: String,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
val chosenNetworkState = when (prevState.chosenNetworkStateUM) {
|
||||
is ChosenNetworkStateUM.Result,
|
||||
ChosenNetworkStateUM.SelectNetwork,
|
||||
-> if (value.isBlank()) ChosenNetworkStateUM.Hidden else prevState.chosenNetworkStateUM
|
||||
else -> if (value.isBlank()) ChosenNetworkStateUM.Hidden else ChosenNetworkStateUM.Loading
|
||||
}
|
||||
val memoField = if (value.isBlank()) {
|
||||
prevState.memoField.copy(isVisible = false, value = "", isError = false)
|
||||
} else {
|
||||
prevState.memoField
|
||||
}
|
||||
return prevState.copy(
|
||||
addressField = prevState.addressField.copy(
|
||||
value = value,
|
||||
isError = false,
|
||||
label = resourceReference(R.string.common_address),
|
||||
),
|
||||
chosenNetworkStateUM = chosenNetworkState,
|
||||
buttonUM = prevState.buttonUM.copy(isEnabled = false),
|
||||
memoField = memoField,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package com.tangem.features.addressbook.addaddress.state.transformers
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.features.addressbook.addaddress.state.transformers.converter.ChosenNetworkConverter
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* Reflects the result of validating an address (and its memo) in the UI.
|
||||
*
|
||||
* [matchedBlockchains] are all supported networks the address resolves to. [selectedBlockchains] is what is actually
|
||||
* chosen for saving (a single match is auto-selected; for several matches the user must pick explicitly on the
|
||||
* SelectNetworks screen). The network block shows only the selected networks — while several match but none is picked
|
||||
* yet it prompts with [ChosenNetworkStateUM.SelectNetwork] rather than showing every matched network. While the address
|
||||
* is blank or matches nothing the network selector stays [ChosenNetworkStateUM.Hidden]; an invalid (non-empty, matching
|
||||
* nothing) address surfaces the error in the field label.
|
||||
*
|
||||
* The confirm button is enabled only once at least one network is actually selected (and the memo, if any, is valid).
|
||||
* The memo field is shown when a selected network supports transaction extras; [isMemoInvalid] marks a malformed memo.
|
||||
*/
|
||||
internal class UpdateAddressValidationTransformer(
|
||||
private val address: String,
|
||||
private val matchedBlockchains: List<Blockchain>,
|
||||
private val selectedBlockchains: List<Blockchain>,
|
||||
private val isMemoInvalid: Boolean,
|
||||
private val duplicateName: String?,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
val hasMatch = matchedBlockchains.isNotEmpty()
|
||||
val isInvalidAddress = address.isNotBlank() && !hasMatch
|
||||
val isDuplicate = duplicateName != null
|
||||
val isError = isInvalidAddress || isDuplicate
|
||||
|
||||
val chosenNetworkState = when {
|
||||
!hasMatch -> ChosenNetworkStateUM.Hidden
|
||||
selectedBlockchains.isEmpty() -> ChosenNetworkStateUM.SelectNetwork
|
||||
else -> ChosenNetworkStateUM.Result(
|
||||
networkUMList = selectedBlockchains.map(ChosenNetworkConverter()::convert).toImmutableList(),
|
||||
// A single matched network leaves nothing to choose, so the selection screen is not opened.
|
||||
isClickable = matchedBlockchains.size > 1,
|
||||
)
|
||||
}
|
||||
|
||||
val label = when {
|
||||
isDuplicate -> resourceReference(R.string.address_book_address_taken_error, wrappedList(duplicateName))
|
||||
isInvalidAddress -> resourceReference(R.string.address_book_invalid_address_error)
|
||||
else -> resourceReference(R.string.common_address)
|
||||
}
|
||||
val isConfirmEnabled = selectedBlockchains.isNotEmpty() && !isMemoInvalid && !isDuplicate
|
||||
return prevState.copy(
|
||||
addressField = prevState.addressField.copy(isError = isError, label = label),
|
||||
chosenNetworkStateUM = chosenNetworkState,
|
||||
memoField = resolveMemoField(prevState.memoField),
|
||||
buttonUM = prevState.buttonUM.copy(isEnabled = isConfirmEnabled),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the memo field with the right label when a chosen network supports transaction extras; hides it and clears
|
||||
* the value otherwise (e.g. the supporting network was deselected or the address changed). A malformed memo
|
||||
* ([isMemoInvalid]) turns the field label into an error.
|
||||
*/
|
||||
private fun resolveMemoField(prevMemoField: AddAddressUM.MemoFieldUM): AddAddressUM.MemoFieldUM {
|
||||
val extrasType = selectedBlockchains
|
||||
.map { it.getSupportedTransactionExtras() }
|
||||
.firstOrNull { it.isTxExtrasSupported() }
|
||||
?: return prevMemoField.copy(isVisible = false, value = "", isError = false)
|
||||
|
||||
val fieldLabelRes = when (extrasType) {
|
||||
Network.TransactionExtrasType.DESTINATION_TAG -> R.string.send_destination_tag_field
|
||||
else -> R.string.send_extras_hint_memo
|
||||
}
|
||||
val labelRes = if (isMemoInvalid) R.string.send_memo_destination_tag_error else fieldLabelRes
|
||||
return prevMemoField.copy(
|
||||
isVisible = true,
|
||||
label = resourceReference(labelRes),
|
||||
isError = isMemoInvalid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.features.addressbook.addaddress.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class UpdateMemoInputTransformer(
|
||||
private val value: String,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
return prevState.copy(
|
||||
memoField = prevState.memoField.copy(value = value),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.features.addressbook.addaddress.state.transformers.converter
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.ui.extensions.getActiveIconRes
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class ChosenNetworkConverter : Converter<Blockchain, NetworkUM> {
|
||||
|
||||
override fun convert(value: Blockchain): NetworkUM = NetworkUM(
|
||||
networkName = value.fullName,
|
||||
iconResId = getActiveIconRes(value),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,28 +1,33 @@
|
|||
package com.tangem.features.addressbook.addaddress.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.snap
|
||||
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.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.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
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
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.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) {
|
||||
|
|
@ -30,45 +35,128 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
|
|||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(color = TangemTheme.colors3.bg.primary)
|
||||
.systemBarsPadding(),
|
||||
.navigationBarsPadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TangemTopBar(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
title = resourceReference(R.string.address_book_add_address),
|
||||
startContent = {
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
|
||||
onClick = state.onBackClick,
|
||||
size = TangemButton.Size.X11,
|
||||
variant = TangemButton.Variant.Material,
|
||||
)
|
||||
},
|
||||
TangemTopNavigation(
|
||||
title = state.title,
|
||||
contentAlign = TangemTopNavigation.ContentAlign.Center,
|
||||
blurBackground = false,
|
||||
onBack = state.onBackClick,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
MemoSection(memoField = state.memoField)
|
||||
NetworkSelector(
|
||||
chosenNetworkStateUM = state.chosenNetworkStateUM,
|
||||
onNetworkClick = state.onNetworkClick,
|
||||
)
|
||||
AddButton(state.buttonUM)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
private fun MemoSection(memoField: AddAddressUM.MemoFieldUM) {
|
||||
AnimatedVisibility(
|
||||
visible = memoField.isVisible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
MemoRow(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 12.dp),
|
||||
memoField = memoField,
|
||||
)
|
||||
SpacerH(10.dp)
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
text = stringResourceSafe(R.string.send_recipient_memo_footer_v2),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
text = stringResourceSafe(R.string.send_recipient_memo_footer_v2_highlighted),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PrimaryTangemButton(
|
||||
@Composable
|
||||
private fun NetworkSelector(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, onNetworkClick: () -> Unit) {
|
||||
AnimatedContent(
|
||||
targetState = chosenNetworkStateUM,
|
||||
transitionSpec = {
|
||||
ContentTransform(
|
||||
targetContentEnter = fadeIn(),
|
||||
initialContentExit = fadeOut(),
|
||||
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
|
||||
)
|
||||
},
|
||||
contentKey = { it::class },
|
||||
modifier = Modifier.animateContentSize(),
|
||||
label = "network_selector",
|
||||
) { networkState ->
|
||||
when (networkState) {
|
||||
AddAddressUM.ChosenNetworkStateUM.Hidden -> Box(modifier = Modifier.fillMaxWidth())
|
||||
AddAddressUM.ChosenNetworkStateUM.Loading,
|
||||
AddAddressUM.ChosenNetworkStateUM.SelectNetwork,
|
||||
is AddAddressUM.ChosenNetworkStateUM.Result,
|
||||
-> NetworkBlock(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.fillMaxWidth()
|
||||
.background(color = TangemTheme.colors3.bg.secondary),
|
||||
chosenNetworkStateUM = networkState,
|
||||
onNetworkSelectClick = onNetworkClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.AddButton(buttonUM: TangemButtonUM) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
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,19 +172,28 @@ private fun Preview_AddAddressContent() {
|
|||
placeholder = resourceReference(R.string.address_book_enter_address),
|
||||
label = resourceReference(R.string.common_address),
|
||||
),
|
||||
availableNetworks = persistentListOf(),
|
||||
memoField = AddAddressUM.MemoFieldUM(
|
||||
isVisible = false,
|
||||
value = "",
|
||||
label = resourceReference(R.string.send_extras_hint_memo),
|
||||
isError = false,
|
||||
onValueChange = {},
|
||||
onPasteClick = {},
|
||||
),
|
||||
buttonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
type = TangemButtonType.Primary,
|
||||
isEnabled = false,
|
||||
onClick = { },
|
||||
),
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden,
|
||||
onAddressChange = {},
|
||||
onAddressClear = {},
|
||||
onPasteClick = {},
|
||||
onQrClick = {},
|
||||
onBackClick = {},
|
||||
onNetworkClick = {},
|
||||
title = resourceReference(R.string.address_book_add_address),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.fields.SimpleTextField
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowContentLead
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
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.ui.state.AddAddressUM
|
||||
|
||||
@Composable
|
||||
internal fun MemoRow(memoField: AddAddressUM.MemoFieldUM, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
text = memoField.label.resolveReference(),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = if (memoField.isError) {
|
||||
TangemTheme.colors3.text.status.error
|
||||
} else {
|
||||
TangemTheme.colors3.text.secondary
|
||||
},
|
||||
)
|
||||
TangemRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
contentLead = TangemRowContentLead.Start,
|
||||
titleSlot = {
|
||||
SimpleTextField(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = memoField.value,
|
||||
onValueChange = memoField.onValueChange,
|
||||
placeholder = resourceReference(R.string.send_optional_field),
|
||||
)
|
||||
},
|
||||
endSlot = {
|
||||
if (memoField.value.isNotEmpty()) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = { memoField.onValueChange("") }),
|
||||
imageVector = Icons.ic_cross_circle_20_filled,
|
||||
tint = TangemTheme.colors3.icon.tertiary,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
TangemButton(
|
||||
size = TangemButton.Size.X9,
|
||||
text = TextReference.Res(id = R.string.common_paste),
|
||||
onClick = memoField.onPasteClick,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_MemoRow() {
|
||||
TangemThemePreviewRedesign {
|
||||
MemoRow(
|
||||
memoField = AddAddressUM.MemoFieldUM(
|
||||
isVisible = true,
|
||||
value = "123456",
|
||||
label = resourceReference(R.string.send_destination_tag_field),
|
||||
isError = false,
|
||||
onValueChange = {},
|
||||
onPasteClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +1,99 @@
|
|||
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.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.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,
|
||||
) {
|
||||
val isClickable = chosenNetworkStateUM is AddAddressUM.ChosenNetworkStateUM.SelectNetwork ||
|
||||
chosenNetworkStateUM is AddAddressUM.ChosenNetworkStateUM.Result && chosenNetworkStateUM.isClickable
|
||||
TangemRow(
|
||||
onClick = if (isClickable) onNetworkSelectClick else null,
|
||||
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)
|
||||
when (chosenNetworkStateUM) {
|
||||
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20)
|
||||
AddAddressUM.ChosenNetworkStateUM.SelectNetwork -> SelectNetworkTextBlock()
|
||||
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkRow(chosenNetworkStateUM = chosenNetworkStateUM)
|
||||
AddAddressUM.ChosenNetworkStateUM.Hidden -> Unit
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectNetworkButton(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (chosenNetworkStateUM) {
|
||||
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList)
|
||||
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader()
|
||||
AddAddressUM.ChosenNetworkStateUM.Empty -> {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
text = stringResourceSafe(R.string.address_book_select_network),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
ChevronIcon()
|
||||
}
|
||||
}
|
||||
private fun SelectNetworkTextBlock() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_select_network),
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
ChevronIcon()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
|
||||
private fun NetworkRow(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM.Result) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
NetworkIconsResolver(
|
||||
networks = chosenNetworkStateUM.networkUMList,
|
||||
showChevron = chosenNetworkStateUM.isClickable,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>, showChevron: Boolean) {
|
||||
when (networks.size) {
|
||||
0 -> Unit
|
||||
1 -> {
|
||||
|
|
@ -100,16 +105,16 @@ 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()
|
||||
if (showChevron) ChevronIcon()
|
||||
}
|
||||
// 3 and any larger count share the same rendering: up to MAX_VISIBLE_NETWORKS overlapping
|
||||
// icons, plus a "+N" badge that appears only when there are more than that.
|
||||
else -> {
|
||||
OverlappingNetworkIcons(networks)
|
||||
ChevronIcon()
|
||||
if (showChevron) ChevronIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -120,7 +125,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 +133,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
|
|||
modifier = Modifier
|
||||
.padding(start = NetworkIconStep * index)
|
||||
.networkIconRing()
|
||||
.size(NetworkIconSize),
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
if (remaining > 0) {
|
||||
|
|
@ -137,12 +142,13 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
|
|||
.padding(start = NetworkIconStep * visible.size)
|
||||
.networkIconRing()
|
||||
.background(color = TangemTheme.colors3.bg.tertiary)
|
||||
.size(NetworkIconSize),
|
||||
.heightIn(min = 20.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,48 +166,57 @@ 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 = 4.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.SelectNetwork,
|
||||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(
|
||||
onNetworkSelectClick = {},
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = persistentListOf(
|
||||
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
||||
),
|
||||
isClickable = false,
|
||||
),
|
||||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(
|
||||
onNetworkSelectClick = {},
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = persistentListOf(
|
||||
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
||||
NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22),
|
||||
NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22),
|
||||
),
|
||||
isClickable = true,
|
||||
),
|
||||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(
|
||||
onNetworkSelectClick = {},
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = List(15) {
|
||||
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
|
||||
}.toImmutableList(),
|
||||
isClickable = true,
|
||||
),
|
||||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading)
|
||||
SpacerH12()
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
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.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal data class AddAddressUM(
|
||||
val addressField: AddressFieldUM,
|
||||
val memoField: MemoFieldUM,
|
||||
val buttonUM: TangemButtonUM,
|
||||
val chosenNetworkStateUM: ChosenNetworkStateUM,
|
||||
val title: TextReference,
|
||||
val onAddressChange: (String) -> Unit,
|
||||
val onAddressClear: () -> Unit,
|
||||
val onPasteClick: () -> Unit,
|
||||
val onQrClick: () -> Unit,
|
||||
val onBackClick: () -> Unit,
|
||||
val onNetworkClick: () -> Unit,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Optional memo / destination-tag input shown below the address only when a chosen network supports transaction
|
||||
* extras. [isVisible] toggles the whole field; [label] adapts to memo vs destination tag.
|
||||
*/
|
||||
@Immutable
|
||||
data class MemoFieldUM(
|
||||
val isVisible: Boolean,
|
||||
val value: String,
|
||||
val label: TextReference,
|
||||
val isError: Boolean,
|
||||
val onValueChange: (String) -> Unit,
|
||||
val onPasteClick: () -> Unit,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
sealed interface ChosenNetworkStateUM {
|
||||
|
||||
/** No address entered yet, or the address matched nothing — the network selector is not shown. */
|
||||
data object Hidden : ChosenNetworkStateUM
|
||||
|
||||
/** A non-blank address is being validated against the supported networks. */
|
||||
data object Loading : ChosenNetworkStateUM
|
||||
|
||||
/**
|
||||
* The address matched several networks but the user hasn't picked any yet — the row prompts them to open the
|
||||
* selection screen instead of showing network icons.
|
||||
*/
|
||||
data object SelectNetwork : ChosenNetworkStateUM
|
||||
|
||||
/**
|
||||
* A valid address resolved to [networkUMList] (the currently selected networks). [isClickable] is `false` when
|
||||
* the address matched only a single network — there is nothing to choose, so the network-selection screen is
|
||||
* not opened.
|
||||
*/
|
||||
data class Result(
|
||||
val networkUMList: ImmutableList<NetworkUM>,
|
||||
val isClickable: Boolean,
|
||||
) : 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.addressinfo
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.ComposableBottomSheetComponent
|
||||
import com.tangem.features.addressbook.addressinfo.model.AddressInfoModel
|
||||
import com.tangem.features.addressbook.addressinfo.ui.AddressInfoBottomSheet
|
||||
|
||||
internal class DefaultAddressInfoComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
private val params: Params,
|
||||
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: AddressInfoModel = getOrCreateModel(params)
|
||||
|
||||
override fun dismiss() = params.onDismiss()
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
AddressInfoBottomSheet(state = state, onDismiss = ::dismiss)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val address: String,
|
||||
val networkCount: Int,
|
||||
val onEditAddress: () -> Unit,
|
||||
val onDeleteAddress: () -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.tangem.features.addressbook.addressinfo.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.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.message.SnackbarMessage
|
||||
import com.tangem.features.addressbook.addressinfo.DefaultAddressInfoComponent
|
||||
import com.tangem.features.addressbook.addressinfo.ui.state.AddressInfoUM
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class AddressInfoModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val clipboardManager: ClipboardManager,
|
||||
private val messageSender: UiMessageSender,
|
||||
) : Model() {
|
||||
|
||||
private val params: DefaultAddressInfoComponent.Params = paramsContainer.require()
|
||||
|
||||
val state: StateFlow<AddressInfoUM> = MutableStateFlow(
|
||||
AddressInfoUM(
|
||||
address = params.address,
|
||||
networkCount = params.networkCount,
|
||||
onCopy = ::onCopy,
|
||||
onEditAddress = params.onEditAddress,
|
||||
onDeleteAddress = params.onDeleteAddress,
|
||||
),
|
||||
)
|
||||
|
||||
private fun onCopy() {
|
||||
clipboardManager.setText(text = params.address, isSensitive = false)
|
||||
messageSender.send(SnackbarMessage(resourceReference(R.string.address_book_address_copied)))
|
||||
params.onDismiss()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
package com.tangem.features.addressbook.addressinfo.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
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.components.bottomsheets.TangemBottomSheetType
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds2.button.Close
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
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_copy_20
|
||||
import com.tangem.features.addressbook.addressinfo.ui.state.AddressInfoUM
|
||||
import com.tangem.features.addressbook.impl.R
|
||||
|
||||
@Composable
|
||||
internal fun AddressInfoBottomSheet(state: AddressInfoUM, onDismiss: () -> Unit) {
|
||||
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = onDismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
),
|
||||
type = TangemBottomSheetType.Modal,
|
||||
containerColor = TangemTheme.colors3.bg.secondary,
|
||||
title = {
|
||||
TangemTopNavigation(
|
||||
windowInsets = WindowInsets(0),
|
||||
blurBackground = false,
|
||||
endButton = { TangemButton.Close(onClick = onDismiss) },
|
||||
)
|
||||
},
|
||||
content = {
|
||||
AddressInfoContent(
|
||||
address = state.address,
|
||||
networkCount = state.networkCount,
|
||||
onCopy = state.onCopy,
|
||||
onEditAddress = state.onEditAddress,
|
||||
onDeleteAddress = state.onDeleteAddress,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun AddressInfoContent(
|
||||
address: String,
|
||||
networkCount: Int,
|
||||
onCopy: () -> Unit,
|
||||
onEditAddress: () -> Unit,
|
||||
onDeleteAddress: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AddressHeader(address = address, networkCount = networkCount)
|
||||
SpacerH(8.dp)
|
||||
TangemButton(
|
||||
onClick = onCopy,
|
||||
iconEnd = TangemIconUM.Icon(imageVector = Icons.ic_copy_20),
|
||||
text = resourceReference(R.string.common_copy_address),
|
||||
variant = TangemButton.Variant.Secondary,
|
||||
size = TangemButton.Size.X9,
|
||||
)
|
||||
SpacerH(48.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CircleShape)
|
||||
.background(TangemTheme.colors3.bg.opaque.primary)
|
||||
.clickableSingle(onClick = onDeleteAddress),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(vertical = 14.dp),
|
||||
text = stringResourceSafe(R.string.address_book_remove_address),
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.status.error,
|
||||
)
|
||||
}
|
||||
SpacerH(8.dp)
|
||||
TangemButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onEditAddress,
|
||||
text = resourceReference(R.string.address_book_edit_address),
|
||||
variant = TangemButton.Variant.Secondary,
|
||||
size = TangemButton.Size.X12,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressHeader(address: String, networkCount: Int) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Ident(text = address),
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
SpacerH(32.dp)
|
||||
Text(
|
||||
text = address,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
SpacerH(8.dp)
|
||||
Text(
|
||||
text = pluralReference(
|
||||
id = R.plurals.common_networks_count,
|
||||
count = networkCount,
|
||||
formatArgs = wrappedList(networkCount),
|
||||
).resolveReference(),
|
||||
style = TangemTheme.typography3.subheading.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun Preview_AddressInfoContent() {
|
||||
TangemThemePreviewRedesign {
|
||||
AddressInfoContent(
|
||||
address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
|
||||
networkCount = 3,
|
||||
onCopy = {},
|
||||
onEditAddress = {},
|
||||
onDeleteAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.features.addressbook.addressinfo.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
internal data class AddressInfoUM(
|
||||
val address: String,
|
||||
val networkCount: Int,
|
||||
val onCopy: () -> Unit,
|
||||
val onEditAddress: () -> Unit,
|
||||
val onDeleteAddress: () -> Unit,
|
||||
)
|
||||
|
|
@ -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,172 @@
|
|||
package com.tangem.features.addressbook.addressselector.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
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.components.bottomsheets.TangemBottomSheetType
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
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,
|
||||
type = TangemBottomSheetType.Modal,
|
||||
title = {
|
||||
TangemTopNavigation(
|
||||
title = resourceReference(R.string.address_book_choose_address),
|
||||
contentAlign = TangemTopNavigation.ContentAlign.Center,
|
||||
blurBackground = false,
|
||||
onClose = onDismiss,
|
||||
)
|
||||
},
|
||||
content = {
|
||||
val density = LocalDensity.current
|
||||
var buttonHeight by remember { mutableStateOf(0.dp) }
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
AddressSelectorList(
|
||||
contact = contact,
|
||||
onAddressClick = onAddressClick,
|
||||
bottomContentPadding = buttonHeight,
|
||||
)
|
||||
TangemButton(
|
||||
onClick = onDismiss,
|
||||
text = resourceReference(R.string.common_cancel),
|
||||
variant = TangemButton.Variant.Secondary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { size ->
|
||||
with(density) { buttonHeight = size.height.toDp() }
|
||||
}
|
||||
.padding(16.dp),
|
||||
size = TangemButton.Size.X12,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressSelectorList(
|
||||
contact: MatchedContact,
|
||||
onAddressClick: (MatchedContact.ContactAddress) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
bottomContentPadding: Dp = 0.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = bottomContentPadding)
|
||||
.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,206 @@
|
|||
package com.tangem.features.addressbook.analytics
|
||||
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_TYPE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
|
||||
|
||||
private const val ADDRESS_BOOK_CATEGORY = "Address Book"
|
||||
|
||||
private const val WALLET_ID = "Wallet Id"
|
||||
private const val CONTACT_ID = "Contact Id"
|
||||
private const val MODE = "Mode"
|
||||
private const val CONTACTS_COUNT = "Contacts Count"
|
||||
private const val ACTION = "Action"
|
||||
|
||||
sealed class AddressBookEvents(
|
||||
event: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
) : AnalyticsEvent(ADDRESS_BOOK_CATEGORY, event, params) {
|
||||
|
||||
// region Contact creation
|
||||
data object SaveToButtonClicked : AddressBookEvents(event = "Button - Save To")
|
||||
|
||||
data object AddressScreenOpened : AddressBookEvents(event = "Address Screen Opened")
|
||||
|
||||
class ContactListScreenOpened(
|
||||
walletId: String,
|
||||
source: Source,
|
||||
contactsCount: Int,
|
||||
) : AddressBookEvents(
|
||||
event = "Contact List Screen Opened",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId,
|
||||
SOURCE to source.value,
|
||||
CONTACTS_COUNT to contactsCount.toString(),
|
||||
),
|
||||
) {
|
||||
enum class Source(val value: String) {
|
||||
Settings("Settings"),
|
||||
SendFlow("Send Flow"),
|
||||
}
|
||||
}
|
||||
|
||||
class AddContactTapped(
|
||||
walletId: String,
|
||||
source: Source,
|
||||
) : AddressBookEvents(
|
||||
event = "Add Contact Tapped",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId,
|
||||
SOURCE to source.value,
|
||||
),
|
||||
) {
|
||||
enum class Source(val value: String) {
|
||||
Settings("Settings"),
|
||||
SendSuccess("Send Flow"),
|
||||
}
|
||||
}
|
||||
|
||||
class ContactSaved(
|
||||
walletId: String,
|
||||
contactId: String,
|
||||
mode: Mode,
|
||||
) : AddressBookEvents(
|
||||
event = "Contact Saved",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId,
|
||||
CONTACT_ID to contactId,
|
||||
MODE to mode.value,
|
||||
),
|
||||
) {
|
||||
enum class Mode(val value: String) {
|
||||
Create("Create"),
|
||||
Edit("Edit"),
|
||||
}
|
||||
}
|
||||
|
||||
class SaveErrorShown(
|
||||
walletId: String,
|
||||
contactId: String?,
|
||||
errorType: ErrorType,
|
||||
) : AddressBookEvents(
|
||||
event = "Save Error Shown",
|
||||
params = buildMap {
|
||||
put(WALLET_ID, walletId)
|
||||
contactId?.let { put(CONTACT_ID, it) }
|
||||
put(ERROR_TYPE, errorType.value)
|
||||
},
|
||||
) {
|
||||
enum class ErrorType(val value: String) {
|
||||
Network("Network"),
|
||||
Server("Server"),
|
||||
Signing("Signing"),
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Contact editing
|
||||
class ContactScreenOpened(
|
||||
walletId: String,
|
||||
contactId: String,
|
||||
) : AddressBookEvents(
|
||||
event = "Contact Screen Opened",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId,
|
||||
CONTACT_ID to contactId,
|
||||
),
|
||||
)
|
||||
// endregion
|
||||
|
||||
// region Send flow
|
||||
class SendFlowWidgetShown(
|
||||
walletId: String,
|
||||
) : AddressBookEvents(
|
||||
event = "Send Flow Widget Shown",
|
||||
params = mapOf(WALLET_ID to walletId),
|
||||
)
|
||||
|
||||
class ContactSelectedInSend(
|
||||
walletId: String,
|
||||
contactId: String,
|
||||
) : AddressBookEvents(
|
||||
event = "Contact Selected",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId,
|
||||
CONTACT_ID to contactId,
|
||||
),
|
||||
)
|
||||
|
||||
class AddressSubstitutedInSend(
|
||||
walletId: String,
|
||||
contactId: String,
|
||||
) : AddressBookEvents(
|
||||
event = "Address Substituted In Send",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId,
|
||||
CONTACT_ID to contactId,
|
||||
),
|
||||
)
|
||||
// endregion
|
||||
|
||||
// region Input errors
|
||||
class AddressInvalid(
|
||||
walletId: String,
|
||||
contactId: String?,
|
||||
) : AddressBookEvents(
|
||||
event = "Address Invalid",
|
||||
params = buildMap {
|
||||
put(WALLET_ID, walletId)
|
||||
put(CONTACT_ID, contactId.orEmpty())
|
||||
},
|
||||
)
|
||||
|
||||
class DuplicateNameErrorShown(
|
||||
walletId: String,
|
||||
contactId: String?,
|
||||
) : AddressBookEvents(
|
||||
event = "Duplicate Name Error Shown",
|
||||
params = buildMap {
|
||||
put(WALLET_ID, walletId)
|
||||
contactId?.let { put(CONTACT_ID, it) }
|
||||
},
|
||||
)
|
||||
// endregion
|
||||
|
||||
// region Choose network
|
||||
class SelectAllNetworksTapped(
|
||||
walletId: String,
|
||||
action: Action,
|
||||
) : AddressBookEvents(
|
||||
event = "Select All Networks Tapped",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId,
|
||||
ACTION to action.value,
|
||||
),
|
||||
) {
|
||||
enum class Action(val value: String) {
|
||||
SelectAll("Select All"),
|
||||
ClearAll("Clear All"),
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Deletion
|
||||
class AddressRemoved(
|
||||
walletId: String,
|
||||
contactId: String,
|
||||
) : AddressBookEvents(
|
||||
event = "Address Removed",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId,
|
||||
CONTACT_ID to contactId,
|
||||
),
|
||||
)
|
||||
|
||||
class ContactDeleted(
|
||||
walletId: String,
|
||||
contactId: String,
|
||||
) : AddressBookEvents(
|
||||
event = "Contact Deleted",
|
||||
params = mapOf(
|
||||
WALLET_ID to walletId,
|
||||
CONTACT_ID to contactId,
|
||||
),
|
||||
)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -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,77 @@
|
|||
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.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
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.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.common.ContactMatcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@ModelScoped
|
||||
@Suppress("LongParameterList")
|
||||
internal class ContactsBlockModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: ContactsBlockStateController,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
|
||||
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
|
||||
getWalletsUseCase: GetWalletsUseCase,
|
||||
) : Model() {
|
||||
|
||||
private var isWidgetShownReported = false
|
||||
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
|
||||
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
modelScope.launch(context = dispatchers.default) { syncAddressBooksUseCase() }
|
||||
|
||||
combine(
|
||||
params.queryFlow.flatMapLatest { query ->
|
||||
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
|
||||
.map { verified -> verified.map { it.contact } }
|
||||
},
|
||||
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)
|
||||
reportWidgetShownIfNeeded(matched.isNotEmpty())
|
||||
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 = ::onContactClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun reportWidgetShownIfNeeded(isVisible: Boolean) {
|
||||
if (isVisible && !isWidgetShownReported) {
|
||||
isWidgetShownReported = true
|
||||
analyticsSender.sendSendFlowWidgetShown(scope = modelScope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onContactClick(contact: MatchedContact) {
|
||||
analyticsSender.sendContactSelectedInSend(contactId = contact.contactId, scope = modelScope)
|
||||
params.onContactClick(contact)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,171 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.common.extensions.calculateSha256
|
||||
import com.tangem.common.extensions.toHexString
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.addressbook.AddressBookSendAnalytics
|
||||
import com.tangem.features.addressbook.analytics.AddressBookEvents
|
||||
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
|
||||
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
internal class AddressBookAnalyticsSender @Inject constructor(
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val dispatcherProvider: CoroutineDispatcherProvider,
|
||||
) : AddressBookSendAnalytics {
|
||||
|
||||
fun sendContactListScreenOpened(source: Source, contactsCount: Int, scope: CoroutineScope) {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.ContactListScreenOpened(
|
||||
walletId = selectedWalletId(),
|
||||
source = source,
|
||||
contactsCount = contactsCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendContactScreenOpened(contactId: String, scope: CoroutineScope) {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.ContactScreenOpened(walletId = selectedWalletId(), contactId = contactId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendSendFlowWidgetShown(scope: CoroutineScope) {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(AddressBookEvents.SendFlowWidgetShown(walletId = selectedWalletId()))
|
||||
}
|
||||
}
|
||||
|
||||
fun sendContactSelectedInSend(contactId: String, scope: CoroutineScope) {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.ContactSelectedInSend(walletId = selectedWalletId(), contactId = contactId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddressSubstitutedInSend(walletId: UserWalletId, contactId: String) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.AddressSubstitutedInSend(walletId = walletId.hashForAnalytics(), contactId = contactId),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendAddressInvalid(walletId: UserWalletId, contactId: String) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.AddressInvalid(walletId = walletId.hashForAnalytics(), contactId = contactId),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendDuplicateNameErrorShown(walletId: UserWalletId, contactId: String?) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.DuplicateNameErrorShown(walletId = walletId.hashForAnalytics(), contactId = contactId),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendAddressRemoved(walletId: UserWalletId, contactId: String) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.AddressRemoved(walletId = walletId.hashForAnalytics(), contactId = contactId),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendContactDeleted(walletId: UserWalletId, contactId: String) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.ContactDeleted(walletId = walletId.hashForAnalytics(), contactId = contactId),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendSelectAllNetworksTapped(action: AddressBookEvents.SelectAllNetworksTapped.Action, scope: CoroutineScope) {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.SelectAllNetworksTapped(walletId = selectedWalletId(), action = action),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendAddContactTapped(fromSendSuccess: Boolean, scope: CoroutineScope) {
|
||||
scope.launch(dispatcherProvider.default) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.AddContactTapped(
|
||||
walletId = selectedWalletId(),
|
||||
source = if (fromSendSuccess) {
|
||||
AddressBookEvents.AddContactTapped.Source.SendSuccess
|
||||
} else {
|
||||
AddressBookEvents.AddContactTapped.Source.Settings
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendContactSaved(walletId: UserWalletId, contactId: String, isEdit: Boolean) {
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.ContactSaved(
|
||||
walletId = walletId.hashForAnalytics(),
|
||||
contactId = contactId,
|
||||
mode = if (isEdit) {
|
||||
AddressBookEvents.ContactSaved.Mode.Edit
|
||||
} else {
|
||||
AddressBookEvents.ContactSaved.Mode.Create
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when a save failure is surfaced to the user. [contactId] is set only in edit mode. Validation failures
|
||||
* ([SaveContactError.Name]) are shown inline rather than as a save error, so they do not produce this event.
|
||||
*/
|
||||
fun sendSaveErrorShown(walletId: UserWalletId, contactId: String?, error: SaveContactError) {
|
||||
val errorType = error.toErrorType() ?: return
|
||||
analyticsEventHandler.send(
|
||||
AddressBookEvents.SaveErrorShown(
|
||||
walletId = walletId.hashForAnalytics(),
|
||||
contactId = contactId,
|
||||
errorType = errorType,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun SaveContactError.toErrorType(): ErrorType? = when (this) {
|
||||
is SaveContactError.Signing -> ErrorType.Signing
|
||||
// Network means the backend was unreachable; every other backend outcome (5xx, 412, other codes) is Server.
|
||||
is SaveContactError.Backend -> when (error) {
|
||||
AddressBookSyncError.Network -> ErrorType.Network
|
||||
else -> ErrorType.Server
|
||||
}
|
||||
is SaveContactError.Name -> null
|
||||
}
|
||||
|
||||
fun sendSaveToButtonClicked() {
|
||||
analyticsEventHandler.send(AddressBookEvents.SaveToButtonClicked)
|
||||
}
|
||||
|
||||
fun sendAddressScreenOpened() {
|
||||
analyticsEventHandler.send(AddressBookEvents.AddressScreenOpened)
|
||||
}
|
||||
|
||||
private suspend fun selectedWalletId(): String = userWalletsListRepository.selectedUserWallet
|
||||
.filterNotNull()
|
||||
.first()
|
||||
.walletId
|
||||
.hashForAnalytics()
|
||||
|
||||
/** Analytics must never receive a raw wallet id — send its SHA-256 (uppercase hex) instead. */
|
||||
private fun UserWalletId.hashForAnalytics(): String = value.calculateSha256().toHexString()
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
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 com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
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,
|
||||
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.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,
|
||||
),
|
||||
portfolioSelectorComponentFactory = portfolioSelectorComponentFactory,
|
||||
)
|
||||
is AddressBookRoute.AddAddress -> DefaultAddAddressComponent(
|
||||
appComponentContext = context,
|
||||
params = DefaultAddAddressComponent.Params(
|
||||
walletId = route.walletId,
|
||||
excludeContactId = route.excludeContactId,
|
||||
prefillAddress = route.prefillAddress,
|
||||
prefillNetworkIds = route.prefillNetworkIds,
|
||||
prefillMemo = route.prefillMemo,
|
||||
onBackClick = clickIntents::onAddAddressBack,
|
||||
onSelectNetworksClick = clickIntents::onSelectNetworksClick,
|
||||
onConfirm = clickIntents::onAddressConfirmed,
|
||||
),
|
||||
)
|
||||
is AddressBookRoute.SelectNetworks -> DefaultSelectNetworksComponent(
|
||||
appComponentContext = context,
|
||||
params = DefaultSelectNetworksComponent.Params(
|
||||
matchedNetworkIds = route.matchedNetworkIds,
|
||||
selectedNetworkIds = route.selectedNetworkIds,
|
||||
onBackClick = clickIntents::onSelectNetworksBack,
|
||||
onDone = clickIntents::onNetworksSelected,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** 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,32 @@
|
|||
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(walletId: String, excludeContactId: String?, prefill: ValidatedAddress?)
|
||||
|
||||
fun onAddAddressBack()
|
||||
|
||||
fun onAddressConfirmed(address: ValidatedAddress, replaces: String?)
|
||||
|
||||
fun onSelectNetworksClick(matchedNetworkIds: List<String>, selectedNetworkIds: List<String>)
|
||||
|
||||
fun onSelectNetworksBack()
|
||||
|
||||
fun onNetworksSelected(selectedNetworkIds: Set<String>)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
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 an address 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<ConfirmedAddress?>
|
||||
field = MutableStateFlow<ConfirmedAddress?>(null)
|
||||
|
||||
fun setConfirmedAddress(confirmed: ConfirmedAddress) {
|
||||
confirmedAddress.value = confirmed
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
confirmedAddress.value = null
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ConfirmedAddress(
|
||||
val address: ValidatedAddress,
|
||||
val replaces: String?,
|
||||
)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.memo.MemoState
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import com.tangem.blockchainsdk.BlockchainSDKFactory
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class AddressMemoValidator @Inject constructor(
|
||||
private val blockchainSDKFactory: BlockchainSDKFactory,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
|
||||
suspend fun isValid(blockchain: Blockchain, memo: String): Boolean = withContext(dispatchers.io) {
|
||||
val factory = blockchainSDKFactory.getMemoValidatorFactorySync() ?: return@withContext true
|
||||
when (val result = factory.create(blockchain).validateMemo(memo)) {
|
||||
is Result.Success -> when (result.data) {
|
||||
MemoState.Valid,
|
||||
MemoState.NotSupported,
|
||||
-> true
|
||||
MemoState.Invalid -> false
|
||||
}
|
||||
is Result.Failure -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
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.addresses.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.displayNetworkName(),
|
||||
)
|
||||
}.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,
|
||||
)
|
||||
|
||||
/** Display network name is derived from [AddressEntry.networkId] — it is not stored in the encrypted payload. */
|
||||
private fun AddressEntry.displayNetworkName(): String =
|
||||
Blockchain.fromNetworkId(networkId.value)?.fullName ?: networkId.value
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
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,
|
||||
private val selectNetworksResultHolder: SelectNetworksResultHolder,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
) : AddressBookComponent, AppComponentContext by context {
|
||||
|
||||
private val navigation = StackNavigation<AddressBookRoute>()
|
||||
|
||||
init {
|
||||
// Drop any results left over from a previous session before the (possibly preloaded) stack starts collecting.
|
||||
resultHolder.clear()
|
||||
selectNetworksResultHolder.clear()
|
||||
if (params.addressBookOpenMode is AddressBookOpenMode.WithContactCreation) {
|
||||
analyticsSender.sendAddContactTapped(fromSendSuccess = true, scope = componentScope)
|
||||
}
|
||||
}
|
||||
|
||||
private val clickIntents = object : AddressBookClickIntents {
|
||||
|
||||
override fun onContactClick(contactId: ContactId) {
|
||||
navigation.pushNew(AddressBookRoute.EditContact(contactId = contactId.value))
|
||||
}
|
||||
|
||||
override fun onAddContactClick() {
|
||||
analyticsSender.sendAddContactTapped(fromSendSuccess = false, scope = componentScope)
|
||||
navigation.pushNew(AddressBookRoute.EditContact())
|
||||
}
|
||||
|
||||
override fun onEditContactBack() {
|
||||
navigation.pop { isPopped ->
|
||||
if (!isPopped) router.pop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddAddressClick(walletId: String, excludeContactId: String?, prefill: ValidatedAddress?) {
|
||||
navigation.pushNew(
|
||||
AddressBookRoute.AddAddress(
|
||||
walletId = walletId,
|
||||
excludeContactId = excludeContactId,
|
||||
prefillAddress = prefill?.address,
|
||||
prefillNetworkIds = prefill?.networkIds.orEmpty(),
|
||||
prefillMemo = prefill?.memo,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAddAddressBack() {
|
||||
navigation.pop()
|
||||
}
|
||||
|
||||
override fun onAddressConfirmed(address: ValidatedAddress, replaces: String?) {
|
||||
resultHolder.setConfirmedAddress(ConfirmedAddress(address = address, replaces = replaces))
|
||||
navigation.pop()
|
||||
}
|
||||
|
||||
override fun onSelectNetworksClick(matchedNetworkIds: List<String>, selectedNetworkIds: List<String>) {
|
||||
navigation.pushNew(
|
||||
AddressBookRoute.SelectNetworks(
|
||||
matchedNetworkIds = matchedNetworkIds,
|
||||
selectedNetworkIds = selectedNetworkIds,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSelectNetworksBack() {
|
||||
navigation.pop()
|
||||
}
|
||||
|
||||
override fun onNetworksSelected(selectedNetworkIds: Set<String>) {
|
||||
selectNetworksResultHolder.setSelectedNetworkIds(selectedNetworkIds)
|
||||
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.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,30 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Carries the set of network ids confirmed on the SelectNetworks screen back to the AddAddress screen.
|
||||
*
|
||||
* The two screens live in independent model scopes, so a shared singleton holder hands the result over instead of
|
||||
* routing it through navigation. Only the "Done" action sets a result; the producer calls [setSelectedNetworkIds], the
|
||||
* consumer observes [selectedNetworkIds] and calls [clear] after applying it so it is not re-applied on resubscription.
|
||||
*
|
||||
* Mirrors [AddressBookResultHolder].
|
||||
*/
|
||||
@Singleton
|
||||
internal class SelectNetworksResultHolder @Inject constructor() {
|
||||
|
||||
val selectedNetworkIds: StateFlow<Set<String>?>
|
||||
field = MutableStateFlow<Set<String>?>(null)
|
||||
|
||||
fun setSelectedNetworkIds(networkIds: Set<String>) {
|
||||
selectedNetworkIds.value = networkIds
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
selectedNetworkIds.value = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Finds every supported mainnet network whose address format matches a given address.
|
||||
*
|
||||
* The match runs over the whole SDK blockchain set (minus testnets and excluded chains), not just the networks already
|
||||
* added to the wallet — entering/scanning an address must surface every network it could belong to.
|
||||
*/
|
||||
internal class SupportedNetworksMatcher @Inject constructor(
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
) {
|
||||
|
||||
private val supportedBlockchains: List<Blockchain> = Blockchain.entries
|
||||
.filter { !it.isTestnet() && it !in excludedBlockchains }
|
||||
|
||||
fun match(address: String): List<Blockchain> {
|
||||
if (address.isBlank()) return emptyList()
|
||||
return supportedBlockchains.filter { blockchain ->
|
||||
runCatching { blockchain.validateAddress(address) }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.tangem.features.addressbook.common.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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, modifier: Modifier = Modifier) {
|
||||
TangemRow(
|
||||
modifier = modifier,
|
||||
onClick = contact.onClick,
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
contentLead = TangemRowContentLead.Start,
|
||||
startSlot = {
|
||||
AccountIcon(
|
||||
name = stringReference(contact.name),
|
||||
icon = contact.icon,
|
||||
size = AccountIconSize.ContactDefault,
|
||||
)
|
||||
},
|
||||
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,11 @@
|
|||
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.*
|
||||
import com.tangem.features.addressbook.addressselector.DefaultAddressSelectorComponent
|
||||
import com.tangem.features.addressbook.block.DefaultAddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
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 +22,25 @@ 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
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindAddressBookSendAnalytics(impl: AddressBookAnalyticsSender): AddressBookSendAnalytics
|
||||
}
|
||||
|
|
@ -3,8 +3,11 @@ 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.addressinfo.model.AddressInfoModel
|
||||
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 com.tangem.features.addressbook.selectnetworks.model.SelectNetworksModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -20,6 +23,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)
|
||||
|
|
@ -29,4 +37,14 @@ internal interface AddressBookModelModule {
|
|||
@IntoMap
|
||||
@ClassKey(AddAddressModel::class)
|
||||
fun bindAddAddressModel(model: AddAddressModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(SelectNetworksModel::class)
|
||||
fun bindSelectNetworksModel(model: SelectNetworksModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(AddressInfoModel::class)
|
||||
fun bindAddressInfoModel(model: AddressInfoModel): Model
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,36 +5,88 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.slot.childSlot
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.addressinfo.DefaultAddressInfoComponent
|
||||
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
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
|
||||
internal class DefaultEditContactComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted params: EditContactComponent.Params,
|
||||
) : EditContactComponent, AppComponentContext by context {
|
||||
internal class DefaultEditContactComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: EditContactModel = getOrCreateModel(params)
|
||||
|
||||
private val portfolioSelectorSlot = childSlot(
|
||||
source = model.portfolioSelectorNavigation,
|
||||
serializer = Unit.serializer(),
|
||||
key = "edit_contact_portfolio_selector_slot",
|
||||
handleBackButton = false,
|
||||
childFactory = { _, componentContext -> portfolioSelectorChild(componentContext) },
|
||||
)
|
||||
|
||||
private val addressInfoSlot = childSlot(
|
||||
source = model.addressInfoNavigation,
|
||||
serializer = String.serializer(),
|
||||
key = "edit_contact_address_info_slot",
|
||||
handleBackButton = false,
|
||||
childFactory = { address, componentContext -> addressInfoChild(address, componentContext) },
|
||||
)
|
||||
|
||||
private fun portfolioSelectorChild(componentContext: ComponentContext): ComposableBottomSheetComponent =
|
||||
portfolioSelectorComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = PortfolioSelectorComponent.Params(
|
||||
portfolioFetcher = model.portfolioFetcher,
|
||||
controller = model.portfolioSelectorController,
|
||||
bsCallback = model.portfolioSelectorCallback,
|
||||
settings = PortfolioSelectorComponent.Settings(isWalletSelectionOnly = true),
|
||||
),
|
||||
)
|
||||
|
||||
private fun addressInfoChild(address: String, componentContext: ComponentContext): ComposableBottomSheetComponent =
|
||||
DefaultAddressInfoComponent(
|
||||
appComponentContext = childByContext(componentContext),
|
||||
params = model.createAddressInfoParams(address),
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
val selectorSlot by portfolioSelectorSlot.subscribeAsState()
|
||||
val infoSlot by addressInfoSlot.subscribeAsState()
|
||||
BackHandler {
|
||||
when {
|
||||
selectorSlot.child != null -> model.portfolioSelectorCallback.onBack()
|
||||
infoSlot.child != null -> model.addressInfoNavigation.dismiss()
|
||||
else -> state.onCloseClick()
|
||||
}
|
||||
}
|
||||
EditContactContent(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
)
|
||||
BackHandler(onBack = state.onCloseClick)
|
||||
selectorSlot.child?.instance?.BottomSheet()
|
||||
infoSlot.child?.instance?.BottomSheet()
|
||||
}
|
||||
|
||||
@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: (walletId: String, excludeContactId: String?, prefill: ValidatedAddress?) -> 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,
|
||||
)
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.tangem.features.addressbook.editcontact.model
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.util.UUID
|
||||
|
||||
internal class ContactAddressEntriesConverter {
|
||||
|
||||
fun convert(addresses: List<ValidatedAddress>): List<AddressEntry> {
|
||||
return addresses.flatMap { address ->
|
||||
address.networkIds.map { rawId -> address.toAddressEntry(rawId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun toValidatedAddresses(entries: List<AddressEntry>): List<ValidatedAddress> {
|
||||
return entries
|
||||
.groupBy { it.address }
|
||||
.map { (address, group) ->
|
||||
ValidatedAddress(
|
||||
address = address,
|
||||
networkIds = group.map { it.networkId.value }.toImmutableList(),
|
||||
memo = group.firstNotNullOfOrNull { it.memo },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ValidatedAddress.toAddressEntry(rawId: String): AddressEntry {
|
||||
val blockchain = Blockchain.fromNetworkId(rawId)
|
||||
val hasExtrasSupport = blockchain?.getSupportedTransactionExtras()?.isTxExtrasSupported() == true
|
||||
return AddressEntry(
|
||||
id = AddressEntryId(UUID.randomUUID().toString()),
|
||||
address = address,
|
||||
networkId = Network.RawID(rawId),
|
||||
memo = memo?.takeIf { hasExtrasSupport },
|
||||
signature = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +1,570 @@
|
|||
package com.tangem.features.addressbook.editcontact.model
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.message.DialogMessage
|
||||
import com.tangem.core.ui.message.EventMessageAction
|
||||
import com.tangem.core.ui.message.SnackbarMessage
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
import com.tangem.domain.addressbook.interactor.SaveContactInteractor
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
|
||||
import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
|
||||
import com.tangem.domain.addressbook.validation.ContactNameValidator
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
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.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.wallet.isLocked
|
||||
import com.tangem.features.addressbook.addressinfo.DefaultAddressInfoComponent
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
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.*
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.converter.ContactNameErrorConverter
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
|
||||
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.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "NamedArguments")
|
||||
@ModelScoped
|
||||
internal class EditContactModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: EditContactStateController,
|
||||
private val resultHolder: AddressBookResultHolder,
|
||||
private val messageSender: UiMessageSender,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val contactNameValidator: ContactNameValidator,
|
||||
private val saveContactInteractor: SaveContactInteractor,
|
||||
private val getContactByIdUseCase: GetContactByIdUseCase,
|
||||
private val deleteContactUseCase: DeleteContactUseCase,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
val portfolioSelectorController: PortfolioSelectorController,
|
||||
portfolioFetcherFactory: PortfolioFetcher.Factory,
|
||||
) : Model() {
|
||||
|
||||
private val params: EditContactComponent.Params = paramsContainer.require()
|
||||
// region State
|
||||
|
||||
val state: StateFlow<EditContactUM>
|
||||
field = MutableStateFlow(getInitialState())
|
||||
private val params: DefaultEditContactComponent.Params = paramsContainer.require()
|
||||
|
||||
/** The contact being edited (null for a new contact). Drives create-vs-update and the delete/discard rules. */
|
||||
private val loadedContact = MutableStateFlow<Contact?>(null)
|
||||
|
||||
/** The editor's starting point: the loaded contact once available, otherwise the empty (or predefined) new contact. */
|
||||
private val newContactBaseline = EditSnapshot(
|
||||
name = "",
|
||||
colorName = CryptoPortfolioIcon.Color.entries.first().name,
|
||||
addresses = listOfNotNull(params.predefinedAddress).map { it.address to it.networkIds.toSet() },
|
||||
)
|
||||
|
||||
/** The in-flight save coroutine — its [Job.isActive] drives both the re-entrancy guard and the button state. */
|
||||
private var saveJob: Job? = null
|
||||
|
||||
val state: StateFlow<EditContactUM> get() = stateController.uiState
|
||||
|
||||
val portfolioSelectorNavigation = SlotNavigation<Unit>()
|
||||
val addressInfoNavigation = SlotNavigation<String>()
|
||||
|
||||
val portfolioFetcher: PortfolioFetcher by lazy {
|
||||
portfolioFetcherFactory.create(
|
||||
mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = false),
|
||||
scope = modelScope,
|
||||
)
|
||||
}
|
||||
|
||||
/** The wallet picked in the selector, if any — single source of the wallet the contact is being saved to. */
|
||||
private val pickedWallet: StateFlow<UserWallet?> =
|
||||
portfolioSelectorController.selectedAccountWithData(portfolioFetcher)
|
||||
.map { it?.first }
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
/**
|
||||
* The wallet the contact is saved to. For an existing contact it is fixed to the contact's wallet; for a new
|
||||
* contact it follows the selector pick and falls back to the app's currently selected wallet.
|
||||
*/
|
||||
private val selectedWallet: StateFlow<UserWallet?> = combine(
|
||||
pickedWallet,
|
||||
loadedContact,
|
||||
userWalletsListRepository.selectedUserWallet,
|
||||
userWalletsListRepository.userWallets,
|
||||
) { picked, contact, currentSelected, wallets ->
|
||||
when {
|
||||
contact != null -> wallets?.firstOrNull { it.walletId == contact.walletId }
|
||||
picked != null -> picked
|
||||
else -> currentSelected
|
||||
}
|
||||
}.stateIn(modelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback {
|
||||
override val onDismiss: () -> Unit = { portfolioSelectorNavigation.dismiss() }
|
||||
override val onBack: () -> Unit = { portfolioSelectorNavigation.dismiss() }
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
init {
|
||||
updateInitialState()
|
||||
prefillPredefinedAddress()
|
||||
subscribeToConfirmedAddresses()
|
||||
dismissSelectorOnPick()
|
||||
observeWalletBlock()
|
||||
observeNameValidation()
|
||||
observeSaveButton()
|
||||
loadExistingContact()
|
||||
sendContactScreenOpenedEvent()
|
||||
}
|
||||
|
||||
// region Initialization
|
||||
|
||||
private fun updateInitialState() {
|
||||
stateController.update(
|
||||
UpdateEditContactInitialStateTransformer(
|
||||
isExistingContact = params.contactId != null,
|
||||
onNameChange = ::onNameChange,
|
||||
onColorSelect = ::onColorSelect,
|
||||
onCloseClick = ::onCloseClick,
|
||||
onAddAddressClick = ::onAddAddressClick,
|
||||
onAddressClick = ::onAddressClick,
|
||||
onSaveClick = ::onSaveClick,
|
||||
onDeleteClick = if (params.contactId != null) ::onDeleteClick else null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** In WithContactCreation mode the contact opens with the already-known address attached. */
|
||||
private fun prefillPredefinedAddress() {
|
||||
params.predefinedAddress?.let(::addAddress)
|
||||
}
|
||||
|
||||
/** Loads an existing contact and prefills the editor. A new contact needs nothing — its baseline is empty. */
|
||||
private fun loadExistingContact() {
|
||||
val contactId = params.contactId ?: return
|
||||
modelScope.launch {
|
||||
val contact = getContactByIdUseCase(contactId).first()
|
||||
if (contact == null) {
|
||||
params.onBackClick()
|
||||
return@launch
|
||||
}
|
||||
loadedContact.value = contact
|
||||
prefillFromContact(contact)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prefillFromContact(contact: Contact) {
|
||||
stateController.update(UpdateContactNameTransformer(name = contact.name.value))
|
||||
CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor }
|
||||
?.let { stateController.update(SelectContactColorTransformer(color = it)) }
|
||||
val addresses = ContactAddressEntriesConverter().toValidatedAddresses(contact.addresses)
|
||||
stateController.update(SetValidatedAddressesTransformer(addresses = addresses, maxAddresses = MAX_ADDRESSES))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Subscriptions
|
||||
|
||||
private fun subscribeToConfirmedAddresses() {
|
||||
resultHolder.confirmedAddress
|
||||
.filterNotNull()
|
||||
.onEach { confirmed ->
|
||||
// Edit-address: the result carries the entry it supersedes, so we swap it in place.
|
||||
confirmed.replaces?.let { old ->
|
||||
stateController.update(
|
||||
RemoveValidatedAddressTransformer(address = old, maxAddresses = MAX_ADDRESSES),
|
||||
)
|
||||
}
|
||||
addAddress(confirmed.address)
|
||||
resultHolder.clear()
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
/** Closes the wallet selector as soon as the user picks a wallet in it. */
|
||||
private fun dismissSelectorOnPick() {
|
||||
pickedWallet
|
||||
.filterNotNull()
|
||||
.onEach { portfolioSelectorNavigation.dismiss() }
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun observeWalletBlock() {
|
||||
combine(selectedWallet, userWalletsListRepository.userWallets) { wallet, wallets ->
|
||||
UpdateWalletBlockTransformer(
|
||||
walletName = wallet?.name.orEmpty(),
|
||||
isChangeable = isWalletChangeable(wallets),
|
||||
onClick = ::onWalletBlockClick,
|
||||
)
|
||||
}
|
||||
.onEach(stateController::update)
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
private fun observeNameValidation() {
|
||||
combine(
|
||||
stateController.uiState.map { it.name }.distinctUntilChanged().debounce(NAME_DEBOUNCE_MS),
|
||||
selectedWallet.mapNotNull { it?.walletId }.distinctUntilChanged(),
|
||||
) { name, walletId -> name to walletId }
|
||||
.mapLatest { (name, walletId) -> walletId to validateName(name, walletId) }
|
||||
.distinctUntilChanged()
|
||||
.onEach { (walletId, error) ->
|
||||
if (error == ContactNameValidationError.Duplicate) {
|
||||
analyticsSender.sendDuplicateNameErrorShown(
|
||||
walletId = walletId,
|
||||
contactId = params.contactId?.value,
|
||||
)
|
||||
}
|
||||
stateController.update(UpdateNameErrorTransformer(error?.let(ContactNameErrorConverter()::convert)))
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun observeSaveButton() {
|
||||
// Recompute on input changes and on wallet changes (the wallet type drives the button's Tangem-logo icon).
|
||||
combine(
|
||||
stateController.uiState
|
||||
.map { state ->
|
||||
SaveButtonInputs(
|
||||
name = state.name,
|
||||
hasNameError = state.nameError != null,
|
||||
hasAddresses = state.addresses.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged(),
|
||||
selectedWallet.map { it is UserWallet.Cold }.distinctUntilChanged(),
|
||||
) { _, _ -> }
|
||||
.onEach { refreshSaveButton() }
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Clicks
|
||||
|
||||
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),
|
||||
stateController.update(SelectContactColorTransformer(color = color))
|
||||
}
|
||||
|
||||
private fun onWalletBlockClick() {
|
||||
if (isWalletChangeable(userWalletsListRepository.userWallets.value)) {
|
||||
analyticsSender.sendSaveToButtonClicked()
|
||||
portfolioSelectorNavigation.activate(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAddAddressClick() {
|
||||
if (stateController.uiState.value.addresses.size >= MAX_ADDRESSES) {
|
||||
messageSender.send(
|
||||
DialogMessage(
|
||||
title = resourceReference(R.string.address_book_max_networks_alert_title),
|
||||
message = resourceReference(R.string.address_book_max_networks_alert_description),
|
||||
firstActionBuilder = {
|
||||
EventMessageAction(
|
||||
title = resourceReference(R.string.common_ok),
|
||||
onClick = onDismissRequest,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val walletId = selectedWallet.value?.walletId?.stringValue ?: return
|
||||
params.onAddAddressClick(walletId, params.contactId?.value, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSaveClick() {
|
||||
if (saveJob?.isActive == true) return
|
||||
val userWallet = selectedWallet.value ?: return
|
||||
val ui = stateController.uiState.value
|
||||
val addresses = ContactAddressEntriesConverter().convert(ui.addresses)
|
||||
val existing = loadedContact.value
|
||||
|
||||
saveJob = modelScope.launch {
|
||||
val result = if (existing != null) {
|
||||
saveContactInteractor.updateContact(
|
||||
userWallet = userWallet,
|
||||
contact = existing,
|
||||
name = ui.name,
|
||||
iconColor = ui.colors.selected.name,
|
||||
addresses = addresses,
|
||||
)
|
||||
} else {
|
||||
saveContactInteractor.createContact(
|
||||
userWallet = userWallet,
|
||||
name = ui.name,
|
||||
iconColor = ui.colors.selected.name,
|
||||
addresses = addresses,
|
||||
)
|
||||
}
|
||||
result.fold(
|
||||
ifLeft = { error ->
|
||||
handleSaveError(error)
|
||||
analyticsSender.sendSaveErrorShown(
|
||||
walletId = userWallet.walletId,
|
||||
contactId = params.contactId?.value,
|
||||
error = error,
|
||||
)
|
||||
saveJob?.cancel()
|
||||
refreshSaveButton()
|
||||
},
|
||||
ifRight = { contact ->
|
||||
if (existing == null) {
|
||||
messageSender.send(
|
||||
SnackbarMessage(
|
||||
message = resourceReference(R.string.address_book_create_success_message),
|
||||
startIconId = R.drawable.ic_success_20,
|
||||
),
|
||||
)
|
||||
}
|
||||
analyticsSender.sendContactSaved(
|
||||
walletId = userWallet.walletId,
|
||||
contactId = contact.id.value,
|
||||
isEdit = params.contactId != null,
|
||||
)
|
||||
params.onBackClick()
|
||||
},
|
||||
)
|
||||
}
|
||||
refreshSaveButton()
|
||||
}
|
||||
|
||||
private fun onCloseClick() {
|
||||
if (isDirty()) showDiscardDialog() else params.onBackClick()
|
||||
}
|
||||
|
||||
private fun onDeleteClick() {
|
||||
showDeleteContactDialog(fromLastAddressRemoval = false)
|
||||
}
|
||||
|
||||
private fun showDeleteContactDialog(fromLastAddressRemoval: Boolean) {
|
||||
messageSender.send(
|
||||
DialogMessage(
|
||||
message = resourceReference(R.string.address_book_delete_contact_description),
|
||||
firstActionBuilder = {
|
||||
EventMessageAction(
|
||||
title = resourceReference(R.string.common_delete),
|
||||
isWarning = true,
|
||||
onClick = { deleteContact(fromLastAddressRemoval = fromLastAddressRemoval) },
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onAddressClick(address: ValidatedAddress) {
|
||||
addressInfoNavigation.activate(address.address)
|
||||
}
|
||||
|
||||
fun createAddressInfoParams(address: String): DefaultAddressInfoComponent.Params {
|
||||
val entry = stateController.uiState.value.addresses.firstOrNull { it.address == address }
|
||||
return DefaultAddressInfoComponent.Params(
|
||||
address = address,
|
||||
networkCount = entry?.networkIds?.size ?: 0,
|
||||
onEditAddress = { onEditAddress(address) },
|
||||
onDeleteAddress = { onDeleteAddress(address) },
|
||||
onDismiss = { addressInfoNavigation.dismiss() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun onEditAddress(address: String) {
|
||||
val entry = stateController.uiState.value.addresses.firstOrNull { it.address == address } ?: return
|
||||
val walletId = selectedWallet.value?.walletId?.stringValue ?: return
|
||||
addressInfoNavigation.dismiss()
|
||||
params.onAddAddressClick(walletId, params.contactId?.value, entry)
|
||||
}
|
||||
|
||||
private fun onDeleteAddress(address: String) {
|
||||
addressInfoNavigation.dismiss()
|
||||
val isLastAddress = stateController.uiState.value.addresses.size <= 1
|
||||
if (isLastAddress && params.contactId != null) {
|
||||
// Removing the last address deletes the whole contact — analytics is emitted from the confirmed delete.
|
||||
showDeleteContactDialog(fromLastAddressRemoval = true)
|
||||
} else {
|
||||
contactWalletId()?.let { walletId ->
|
||||
analyticsSender.sendAddressRemoved(walletId = walletId, contactId = params.contactId?.value.orEmpty())
|
||||
}
|
||||
stateController.update(RemoveValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Helpers
|
||||
|
||||
private fun sendContactScreenOpenedEvent() {
|
||||
analyticsSender.sendContactScreenOpened(contactId = params.contactId?.value.orEmpty(), scope = modelScope)
|
||||
}
|
||||
|
||||
private fun addAddress(address: ValidatedAddress) {
|
||||
stateController.update(AddValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
|
||||
}
|
||||
|
||||
private fun isWalletChangeable(wallets: List<UserWallet>?): Boolean {
|
||||
val unlockedWalletsCount = wallets.orEmpty().count { !it.isLocked }
|
||||
return params.contactId == null && unlockedWalletsCount > 1
|
||||
}
|
||||
|
||||
private suspend fun validateName(name: String, walletId: UserWalletId): ContactNameValidationError? {
|
||||
if (name.isBlank()) return null
|
||||
if (name == loadedContact.value?.name?.value) return null
|
||||
val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null
|
||||
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
|
||||
return error
|
||||
}
|
||||
|
||||
private fun refreshSaveButton() {
|
||||
val ui = stateController.uiState.value
|
||||
val isSaving = saveJob?.isActive == true
|
||||
val isEnabled = ui.name.isNotBlank() && ui.nameError == null && ui.addresses.isNotEmpty() && !isSaving
|
||||
stateController.update(
|
||||
UpdateSaveButtonTransformer(
|
||||
isEnabled = isEnabled,
|
||||
isLoading = isSaving,
|
||||
isColdWallet = selectedWallet.value is UserWallet.Cold,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleSaveError(error: SaveContactError) {
|
||||
when (error) {
|
||||
is SaveContactError.Name -> stateController.update(
|
||||
UpdateNameErrorTransformer(ContactNameErrorConverter().convert(error.error)),
|
||||
)
|
||||
else -> messageSender.send(
|
||||
DialogMessage(
|
||||
title = resourceReference(R.string.common_something_went_wrong),
|
||||
message = resourceReference(R.string.address_book_creating_error),
|
||||
firstActionBuilder = {
|
||||
EventMessageAction(
|
||||
title = resourceReference(R.string.common_ok),
|
||||
onClick = onDismissRequest,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAddAddress() {
|
||||
params.onAddAddressClick(::addAddress)
|
||||
}
|
||||
|
||||
private fun addAddress(address: ValidatedAddress) {
|
||||
state.update { it.copy(addresses = (it.addresses + address).toImmutableList()) }
|
||||
}
|
||||
|
||||
private fun getInitialState(): EditContactUM {
|
||||
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
|
||||
val selectedColor = colors.first()
|
||||
val titleResId = if (params.contactId == null) {
|
||||
R.string.address_book_new_contact
|
||||
} else {
|
||||
R.string.address_book_contact
|
||||
private fun deleteContact(fromLastAddressRemoval: Boolean) {
|
||||
val contactId = params.contactId ?: return
|
||||
val walletId = contactWalletId()
|
||||
// The address removal precedes the backend delete; the contact-deleted event follows a successful response.
|
||||
if (fromLastAddressRemoval && walletId != null) {
|
||||
analyticsSender.sendAddressRemoved(walletId = walletId, contactId = contactId.value)
|
||||
}
|
||||
return EditContactUM(
|
||||
title = resourceReference(titleResId),
|
||||
name = "",
|
||||
namePlaceholder = resourceReference(R.string.address_book_new_contact),
|
||||
portfolioIcon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = selectedColor,
|
||||
modelScope.launch {
|
||||
deleteContactUseCase(contactId).fold(
|
||||
ifLeft = { showDeleteError() },
|
||||
ifRight = {
|
||||
if (walletId != null) {
|
||||
analyticsSender.sendContactDeleted(walletId = walletId, contactId = contactId.value)
|
||||
}
|
||||
params.onBackClick()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun contactWalletId(): UserWalletId? = loadedContact.value?.walletId ?: selectedWallet.value?.walletId
|
||||
|
||||
private fun showDiscardDialog() {
|
||||
messageSender.send(
|
||||
DialogMessage(
|
||||
title = resourceReference(R.string.address_book_unsaved_changes),
|
||||
message = resourceReference(R.string.address_book_unsaved_changes_description),
|
||||
firstActionBuilder = {
|
||||
EventMessageAction(
|
||||
title = resourceReference(R.string.address_book_keep_editing),
|
||||
onClick = onDismissRequest,
|
||||
)
|
||||
},
|
||||
secondActionBuilder = {
|
||||
EventMessageAction(
|
||||
title = resourceReference(R.string.address_book_discard),
|
||||
isWarning = true,
|
||||
onClick = params.onBackClick,
|
||||
)
|
||||
},
|
||||
),
|
||||
colors = EditContactUM.Colors(
|
||||
selected = selectedColor,
|
||||
list = colors,
|
||||
onColorSelect = ::onColorSelect,
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
onNameChange = ::onNameChange,
|
||||
onCloseClick = params.onBackClick,
|
||||
onAddAddressClick = ::requestAddAddress,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showDeleteError() {
|
||||
messageSender.send(
|
||||
DialogMessage(
|
||||
title = resourceReference(R.string.common_something_went_wrong),
|
||||
message = resourceReference(R.string.address_book_deleting_error),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Dirty when the current editor differs from its baseline — the loaded contact, or the empty new contact. */
|
||||
private fun isDirty(): Boolean {
|
||||
val baseline = loadedContact.value?.toSnapshot() ?: newContactBaseline
|
||||
return currentSnapshot() != baseline
|
||||
}
|
||||
|
||||
private fun currentSnapshot(): EditSnapshot {
|
||||
val ui = stateController.uiState.value
|
||||
return EditSnapshot(
|
||||
name = ui.name,
|
||||
colorName = ui.colors.selected.name,
|
||||
addresses = ui.addresses.map { it.address to it.networkIds.toSet() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun Contact.toSnapshot(): EditSnapshot = EditSnapshot(
|
||||
name = name.value,
|
||||
colorName = iconColor,
|
||||
addresses = ContactAddressEntriesConverter().toValidatedAddresses(addresses)
|
||||
.map { it.address to it.networkIds.toSet() },
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
// region Models
|
||||
|
||||
private data class SaveButtonInputs(
|
||||
val name: String,
|
||||
val hasNameError: Boolean,
|
||||
val hasAddresses: Boolean,
|
||||
)
|
||||
|
||||
private data class EditSnapshot(
|
||||
val name: String,
|
||||
val colorName: String,
|
||||
val addresses: List<Pair<String, Set<String>>>,
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
private companion object {
|
||||
const val MAX_ADDRESSES = 20
|
||||
const val NAME_DEBOUNCE_MS = 300L
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
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.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.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),
|
||||
nameError = null,
|
||||
portfolioIcon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = selectedColor,
|
||||
),
|
||||
colors = EditContactUM.Colors(
|
||||
selected = selectedColor,
|
||||
list = colors,
|
||||
onColorSelect = {},
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
walletBlock = EditContactUM.WalletBlockUM(
|
||||
walletName = "",
|
||||
isChangeable = false,
|
||||
onClick = {},
|
||||
),
|
||||
isAddAddressEnabled = true,
|
||||
saveButton = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_save_contact),
|
||||
type = TangemButtonType.Primary,
|
||||
isEnabled = false,
|
||||
onClick = {},
|
||||
),
|
||||
onNameChange = {},
|
||||
onCloseClick = {},
|
||||
onAddAddressClick = {},
|
||||
onAddressClick = {},
|
||||
onDeleteClick = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
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,
|
||||
private val maxAddresses: Int,
|
||||
) : 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
|
||||
val addresses = (prevState.addresses + address).toImmutableList()
|
||||
return prevState.copy(
|
||||
addresses = addresses,
|
||||
isAddAddressEnabled = addresses.size < maxAddresses,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal class RemoveValidatedAddressTransformer(
|
||||
private val address: String,
|
||||
private val maxAddresses: Int,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
val addresses = prevState.addresses.filterNot { it.address == address }.toImmutableList()
|
||||
return prevState.copy(
|
||||
addresses = addresses,
|
||||
isAddAddressEnabled = addresses.size < maxAddresses,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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 SetValidatedAddressesTransformer(
|
||||
private val addresses: List<ValidatedAddress>,
|
||||
private val maxAddresses: Int,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
return prevState.copy(
|
||||
addresses = addresses.toImmutableList(),
|
||||
isAddAddressEnabled = addresses.size < maxAddresses,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,43 @@
|
|||
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.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
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.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
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,
|
||||
private val onAddressClick: (ValidatedAddress) -> Unit,
|
||||
private val onSaveClick: () -> Unit,
|
||||
private val onDeleteClick: (() -> 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),
|
||||
saveButton = prevState.saveButton.copy(onClick = onSaveClick),
|
||||
onNameChange = onNameChange,
|
||||
onCloseClick = onCloseClick,
|
||||
onAddAddressClick = onAddAddressClick,
|
||||
onAddressClick = onAddressClick,
|
||||
onDeleteClick = onDeleteClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class UpdateNameErrorTransformer(private val error: TextReference?) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
return prevState.copy(nameError = error)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_logo_tangem_24
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class UpdateSaveButtonTransformer(
|
||||
private val isEnabled: Boolean,
|
||||
private val isLoading: Boolean,
|
||||
private val isColdWallet: Boolean,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
return prevState.copy(
|
||||
saveButton = prevState.saveButton.copy(
|
||||
isEnabled = isEnabled,
|
||||
isLoading = isLoading,
|
||||
tangemIconUM = TangemIconUM.Icon(imageVector = Icons.ic_logo_tangem_24).takeIf { isColdWallet },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
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 UpdateWalletBlockTransformer(
|
||||
private val walletName: String,
|
||||
private val isChangeable: Boolean,
|
||||
private val onClick: () -> Unit,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
return prevState.copy(
|
||||
walletBlock = EditContactUM.WalletBlockUM(
|
||||
walletName = walletName,
|
||||
isChangeable = isChangeable,
|
||||
onClick = onClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers.converter
|
||||
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class ContactNameErrorConverter : Converter<ContactNameValidationError, TextReference> {
|
||||
|
||||
override fun convert(value: ContactNameValidationError): TextReference = when (value) {
|
||||
ContactNameValidationError.Duplicate -> resourceReference(R.string.address_book_name_taken_error)
|
||||
is ContactNameValidationError.Format -> when (value.error) {
|
||||
ContactName.Error.ExceedsMaxLength -> resourceReference(R.string.address_book_name_max_chars_error)
|
||||
ContactName.Error.InvalidCharacters -> resourceReference(R.string.address_book_name_invalid_chars_error)
|
||||
ContactName.Error.Empty -> resourceReference(R.string.address_book_name_empty_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,23 @@
|
|||
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.foundation.text.TextAutoSize
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
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.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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,159 +25,328 @@ 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.components.haze.hazeSourceTangem
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
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.ds2.topnavigation.TangemTopNavigation
|
||||
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_logo_tangem_24
|
||||
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
|
||||
|
||||
@Composable
|
||||
internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(color = TangemTheme.colors3.bg.primary)
|
||||
.systemBarsPadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
.background(color = TangemTheme.colors3.bg.primary),
|
||||
) {
|
||||
TangemTopBar(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
title = state.title,
|
||||
endContent = {
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
|
||||
onClick = state.onCloseClick,
|
||||
size = TangemButton.Size.X11,
|
||||
variant = TangemButton.Variant.Material,
|
||||
)
|
||||
},
|
||||
)
|
||||
var topBarHeightPx by remember { mutableIntStateOf(0) }
|
||||
val topBarHeight = with(LocalDensity.current) { topBarHeightPx.toDp() }
|
||||
|
||||
Column(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
.fillMaxSize()
|
||||
.hazeSourceTangem()
|
||||
.background(color = TangemTheme.colors3.bg.primary)
|
||||
.imePadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
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 ->
|
||||
val minContentHeight = maxHeight
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
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,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(min = minContentHeight)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = topBarHeight + 12.dp, bottom = 12.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ContactSummary(state = state)
|
||||
ContactColor(colors = state.colors)
|
||||
BlockCard(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
ContactAddresses(addresses = state.addresses, onAddressClick = state.onAddressClick)
|
||||
AddAddressRow(isEnabled = state.isAddAddressEnabled, onClick = state.onAddAddressClick)
|
||||
}
|
||||
WalletBlock(walletBlock = state.walletBlock)
|
||||
state.onDeleteClick?.let {
|
||||
DeleteContactButton(onClick = it)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
SaveButton(saveButton = state.saveButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TangemTopNavigation(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.onSizeChanged { topBarHeightPx = it.height },
|
||||
title = state.title,
|
||||
onClose = state.onCloseClick,
|
||||
contentAlign = TangemTopNavigation.ContentAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddAddressRow(onClick: () -> Unit) {
|
||||
Row(
|
||||
private fun SaveButton(saveButton: TangemButtonUM) {
|
||||
TangemButton(
|
||||
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,
|
||||
)
|
||||
}
|
||||
.padding(top = 16.dp),
|
||||
text = saveButton.text,
|
||||
iconEnd = saveButton.tangemIconUM,
|
||||
onClick = saveButton.onClick,
|
||||
isEnabled = saveButton.isEnabled,
|
||||
isLoading = saveButton.isLoading,
|
||||
size = TangemButton.Size.X12,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>, onAddressClick: (ValidatedAddress) -> Unit) {
|
||||
addresses.fastForEach { entry ->
|
||||
AddressRow(entry = entry, onClick = { onAddressClick(entry) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressRow(entry: ValidatedAddress, onClick: () -> Unit) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
onClick = onClick,
|
||||
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(isEnabled: Boolean, onClick: () -> Unit) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
onClick = onClick,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Icon(
|
||||
imageVector = Icons.ic_sign_plus_20,
|
||||
tintReference = {
|
||||
if (isEnabled) {
|
||||
TangemTheme.colors3.icon.brand
|
||||
} else {
|
||||
TangemTheme.colors3.icon.tertiary
|
||||
}
|
||||
},
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = if (isEnabled) {
|
||||
TangemTheme.colors3.bg.status.infoSubtle
|
||||
} else {
|
||||
TangemTheme.colors3.bg.opaque.secondary
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
.padding(8.dp),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
if (isEnabled) {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_add_address),
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
subtitleSlot = {
|
||||
if (isEnabled) {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_add_address_description),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_add_address_description),
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletBlock(walletBlock: EditContactUM.WalletBlockUM, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.fillMaxWidth()
|
||||
.clickableSingle(
|
||||
onClick = walletBlock.onClick,
|
||||
enabled = walletBlock.isChangeable,
|
||||
)
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = stringResourceSafe(R.string.address_book_save_to_wallet_title),
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
},
|
||||
endSlot = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = walletBlock.walletName,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
minFontSize = TangemTheme.typography3.caption.medium.fontSize,
|
||||
maxFontSize = TangemTheme.typography3.body.medium.fontSize,
|
||||
),
|
||||
)
|
||||
if (walletBlock.isChangeable) {
|
||||
WalletChevronIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(vertical = 10.dp, horizontal = 16.dp),
|
||||
text = stringResourceSafe(R.string.address_book_save_wallet_to_description),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteContactButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.fillMaxWidth()
|
||||
.clickableSingle(onClick = onClick)
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_delete_contact),
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.status.error,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletChevronIcon() {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(20.dp),
|
||||
tint = TangemTheme.colors3.icon.secondary,
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_select_18_24),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
|
||||
@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.ContactLarge,
|
||||
)
|
||||
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 +358,18 @@ private fun ContactSummary(state: EditContactUM) {
|
|||
color = TangemTheme.colors3.text.primary,
|
||||
placeholderColor = TangemTheme.colors3.text.tertiary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
if (state.nameError != null) {
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
text = state.nameError.resolveReference(),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.status.error,
|
||||
)
|
||||
}
|
||||
|
||||
SpacerH(8.dp)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,16 +379,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 +402,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 {
|
||||
|
|
@ -251,6 +434,7 @@ private fun Preview_EditContactContent() {
|
|||
title = stringReference("New contact"),
|
||||
name = "",
|
||||
namePlaceholder = stringReference("New contact"),
|
||||
nameError = null,
|
||||
portfolioIcon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = colors.first(),
|
||||
|
|
@ -260,10 +444,30 @@ private fun Preview_EditContactContent() {
|
|||
list = colors,
|
||||
onColorSelect = {},
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
addresses = persistentListOf(
|
||||
ValidatedAddress(
|
||||
address = "0x1234567890abcdef1234567890abcdef12345678",
|
||||
networkIds = persistentListOf("ethereum", "bsc", "polygon"),
|
||||
),
|
||||
),
|
||||
walletBlock = EditContactUM.WalletBlockUM(
|
||||
walletName = "Main Wallet",
|
||||
isChangeable = true,
|
||||
onClick = {},
|
||||
),
|
||||
isAddAddressEnabled = true,
|
||||
saveButton = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.common_save),
|
||||
type = TangemButtonType.Primary,
|
||||
isEnabled = true,
|
||||
tangemIconUM = TangemIconUM.Icon(imageVector = Icons.ic_logo_tangem_24),
|
||||
onClick = {},
|
||||
),
|
||||
onNameChange = {},
|
||||
onCloseClick = {},
|
||||
onAddAddressClick = {},
|
||||
onAddressClick = {},
|
||||
onDeleteClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,29 @@
|
|||
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.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal data class EditContactUM(
|
||||
val title: TextReference,
|
||||
val name: String,
|
||||
val namePlaceholder: TextReference,
|
||||
val nameError: TextReference?,
|
||||
val portfolioIcon: AccountIconUM.CryptoPortfolio,
|
||||
val colors: Colors,
|
||||
val addresses: ImmutableList<ValidatedAddress>,
|
||||
val walletBlock: WalletBlockUM,
|
||||
val isAddAddressEnabled: Boolean,
|
||||
val saveButton: TangemButtonUM,
|
||||
val onNameChange: (String) -> Unit,
|
||||
val onCloseClick: () -> Unit,
|
||||
val onAddAddressClick: () -> Unit,
|
||||
val onAddressClick: (ValidatedAddress) -> Unit,
|
||||
val onDeleteClick: (() -> Unit)?,
|
||||
) {
|
||||
|
||||
data class Colors(
|
||||
|
|
@ -22,4 +31,10 @@ internal data class EditContactUM(
|
|||
val list: ImmutableList<CryptoPortfolioIcon.Color>,
|
||||
val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
|
||||
)
|
||||
|
||||
data class WalletBlockUM(
|
||||
val walletName: String,
|
||||
val isChangeable: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
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.
|
||||
* [memo] is an optional destination tag / memo entered for networks that support transaction extras (XRP, Stellar, TON,
|
||||
* …). It is `null` when the matched networks don't support extras or the user left it empty.
|
||||
*/
|
||||
@Immutable
|
||||
data class ValidatedAddress(
|
||||
val address: String,
|
||||
val networkIds: ImmutableList<String>,
|
||||
val memo: String? = null,
|
||||
)
|
||||
|
|
@ -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,82 @@
|
|||
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.AddressBookListShimmer
|
||||
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,
|
||||
is AddressBookListUM.Loading -> AddressBookListShimmer(
|
||||
onBackClick = router::pop,
|
||||
modifier = modifier,
|
||||
modifier = modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
|
||||
onAddContactClick = addressBookListUM.onAddClick,
|
||||
onBackClick = router::pop,
|
||||
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
|
||||
* @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,188 @@
|
|||
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.addressbook.usecase.SyncAddressBooksUseCase
|
||||
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.analytics.AddressBookEvents.ContactListScreenOpened.Source
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
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.state.transformers.UpdateAddressBookListQueryTransformer
|
||||
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 kotlinx.coroutines.launch
|
||||
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
|
||||
* - [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,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
|
||||
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
|
||||
private val 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.getVerifiedContacts(query = "", userWalletId = null)
|
||||
.shareIn(modelScope, SharingStarted.Lazily, replay = 1)
|
||||
|
||||
init {
|
||||
modelScope.launch {
|
||||
syncAddressBooksUseCase()
|
||||
sendContactListScreenOpenedEvent()
|
||||
observeContacts()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun observeContacts() {
|
||||
val matchedContacts = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) {
|
||||
allContacts
|
||||
} else {
|
||||
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
|
||||
}
|
||||
}
|
||||
combine(
|
||||
allContacts,
|
||||
matchedContacts,
|
||||
searchQuery,
|
||||
selectedWalletId,
|
||||
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
|
||||
) { all, matched, query, selected, wallets ->
|
||||
ListInputs(
|
||||
allContacts = all,
|
||||
matchedContacts = matched,
|
||||
query = query,
|
||||
selectedWalletId = selected,
|
||||
wallets = wallets,
|
||||
)
|
||||
}
|
||||
.onEach(::updateState)
|
||||
.flowOn(dispatchers.default)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fun deliverSelection(contact: SelectedContact) {
|
||||
contactSelectionTrigger.trigger(contact)
|
||||
selectorNavigation.dismiss()
|
||||
router.pop()
|
||||
}
|
||||
|
||||
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,
|
||||
onContactClick = params.onContactClick,
|
||||
onPickContact = ::onPickContact,
|
||||
onQueryChange = ::onQueryChange,
|
||||
onActiveChange = ::onActiveChange,
|
||||
onClearQuery = ::onClearQuery,
|
||||
onChipSelected = ::onChipSelected,
|
||||
onAddContactClick = params.onAddContactClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onQueryChange(query: String) {
|
||||
searchQuery.value = query
|
||||
updateSearchBar(query = query, isActive = searchActive.value)
|
||||
}
|
||||
|
||||
private fun onActiveChange(active: Boolean) {
|
||||
searchActive.value = active
|
||||
updateSearchBar(query = searchQuery.value, isActive = active)
|
||||
}
|
||||
|
||||
private fun onClearQuery() {
|
||||
searchQuery.value = ""
|
||||
updateSearchBar(query = "", isActive = searchActive.value)
|
||||
}
|
||||
|
||||
private fun updateSearchBar(query: String, isActive: Boolean) {
|
||||
stateController.update(UpdateAddressBookListQueryTransformer(query = query, isActive = isActive))
|
||||
}
|
||||
|
||||
private fun onChipSelected(walletId: String?) {
|
||||
if (selectedWalletId.value == walletId) return
|
||||
selectedWalletId.value = walletId
|
||||
}
|
||||
|
||||
private fun onPickContact(contact: MatchedContact) {
|
||||
// Reported on tap, before the address is substituted; onPickContact is only invoked in selector (Send) mode.
|
||||
analyticsSender.sendContactSelectedInSend(contactId = contact.contactId, scope = modelScope)
|
||||
val singleEntry = contact.entries.singleOrNull()
|
||||
if (singleEntry != null) {
|
||||
deliverSelection(contact.toSelectedContact(singleEntry))
|
||||
} else {
|
||||
selectorNavigation.activate(contact)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendContactListScreenOpenedEvent() {
|
||||
val contacts = allContacts.first()
|
||||
analyticsSender.sendContactListScreenOpened(
|
||||
source = params.mode.toAnalyticsSource(),
|
||||
contactsCount = contacts.size,
|
||||
scope = modelScope,
|
||||
)
|
||||
}
|
||||
|
||||
private fun AddressBookRoute.ListMode.toAnalyticsSource(): Source = when (this) {
|
||||
AddressBookRoute.ListMode.Default -> Source.Settings
|
||||
is AddressBookRoute.ListMode.Selector -> Source.SendFlow
|
||||
}
|
||||
|
||||
private data class ListInputs(
|
||||
val allContacts: List<VerifiedContact>,
|
||||
val matchedContacts: List<VerifiedContact>,
|
||||
val query: String,
|
||||
val selectedWalletId: String?,
|
||||
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.Loading
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
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 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 = (prevState as? 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 = false,
|
||||
onActiveChange = onActiveChange,
|
||||
onClearClick = onClearQuery,
|
||||
onCloseClick = {
|
||||
onClearQuery()
|
||||
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,19 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class UpdateAddressBookListQueryTransformer(
|
||||
private val query: String,
|
||||
private val isActive: Boolean,
|
||||
) : Transformer<AddressBookListUM> {
|
||||
|
||||
override fun transform(prevState: AddressBookListUM): AddressBookListUM = when (prevState) {
|
||||
is AddressBookListUM.Content -> prevState.copy(
|
||||
searchBar = prevState.searchBar.copy(query = query, isActive = isActive),
|
||||
)
|
||||
is AddressBookListUM.Empty,
|
||||
AddressBookListUM.Loading,
|
||||
-> prevState
|
||||
}
|
||||
}
|
||||
|
|
@ -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.addresses.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,25 @@ 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.ds2.topnavigation.TangemTopNavigation
|
||||
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_sign_plus_20
|
||||
|
||||
@Composable
|
||||
internal fun AddressBookEmptyScreen(
|
||||
tangemButtonUM: TangemButtonUM,
|
||||
onAddContactClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -36,31 +34,18 @@ internal fun AddressBookEmptyScreen(
|
|||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TangemTopBar(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
TangemTopNavigation(
|
||||
title = resourceReference(R.string.address_book_title),
|
||||
startContent = {
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
|
||||
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,
|
||||
contentAlign = TangemTopNavigation.ContentAlign.Center,
|
||||
blurBackground = false,
|
||||
onBack = onBackClick,
|
||||
)
|
||||
NoContactInfo(onAddClick = onAddContactClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.NoContactInfo() {
|
||||
private fun ColumnScope.NoContactInfo(onAddClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
|
|
@ -68,18 +53,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 +86,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 +95,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,160 @@
|
|||
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.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.platform.LocalDensity
|
||||
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.decorations.roundedShapeItemDecoration
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
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.ContentMode
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
internal fun AddressBookListScreen(
|
||||
state: AddressBookListUM.Content,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
TangemTopNavigation(
|
||||
title = resourceReference(R.string.address_book_title),
|
||||
contentAlign = TangemTopNavigation.ContentAlign.Center,
|
||||
blurBackground = false,
|
||||
onBack = when (state.contentMode) {
|
||||
is ContentMode.Default -> onBackClick
|
||||
ContentMode.Select -> null
|
||||
},
|
||||
endButton = {
|
||||
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(),
|
||||
contentPadding = PaddingValues(bottom = 12.dp + bottomBarHeight),
|
||||
) {
|
||||
itemsIndexed(items = state.contacts, key = { _, contact -> contact.id }) { index, contact ->
|
||||
ContactRow(
|
||||
contact = contact,
|
||||
modifier = Modifier.roundedShapeItemDecoration(
|
||||
currentIndex = index,
|
||||
lastIndex = state.contacts.lastIndex,
|
||||
radius = 24.dp,
|
||||
backgroundColor = TangemTheme.colors3.bg.secondary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
.imePadding()
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.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(24.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 32.dp),
|
||||
text = stringResourceSafe(R.string.address_book_search_no_results),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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,79 @@
|
|||
package com.tangem.features.addressbook.list.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
||||
@Composable
|
||||
internal fun AddressBookListShimmer(onBackClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
TangemTopNavigation(
|
||||
title = resourceReference(R.string.address_book_title),
|
||||
contentAlign = TangemTopNavigation.ContentAlign.Center,
|
||||
blurBackground = false,
|
||||
onBack = onBackClick,
|
||||
)
|
||||
ProvideTangemShimmer {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 12.dp)
|
||||
.background(
|
||||
color = TangemTheme.colors3.bg.secondary,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
),
|
||||
) {
|
||||
repeat(SHIMMER_ROW_COUNT) { ContactRowShimmer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactRowShimmer() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RectangleShimmer(
|
||||
modifier = Modifier.size(40.dp),
|
||||
radius = 32.dp,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RectangleShimmer(modifier = Modifier.size(width = 140.dp, height = 16.dp))
|
||||
RectangleShimmer(modifier = Modifier.size(width = 90.dp, height = 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SHIMMER_ROW_COUNT = 3
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_AddressBookListShimmer() {
|
||||
TangemThemePreviewRedesign {
|
||||
AddressBookListShimmer(
|
||||
onBackClick = {},
|
||||
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,42 @@
|
|||
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 {
|
||||
|
||||
/** Initial state while the address books are being (re-)synced on open — rendered as shimmer placeholders. */
|
||||
data object Loading : 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,66 @@
|
|||
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()
|
||||
|
||||
/**
|
||||
* Address entry screen. [walletId] / [excludeContactId] scope the `network + address` duplicate check to the target
|
||||
* wallet (excluding the contact being edited). When [prefillAddress] is set the screen opens pre-filled (edit-address
|
||||
* flow): [prefillNetworkIds] restores the previously chosen networks and [prefillMemo] the memo.
|
||||
*/
|
||||
@Serializable
|
||||
data class AddAddress(
|
||||
val walletId: String? = null,
|
||||
val excludeContactId: String? = null,
|
||||
val prefillAddress: String? = null,
|
||||
val prefillNetworkIds: kotlin.collections.List<String> = emptyList(),
|
||||
val prefillMemo: String? = null,
|
||||
) : AddressBookRoute()
|
||||
|
||||
/**
|
||||
* Network-selection screen. [matchedNetworkIds] are the networks the entered address already resolved to (computed
|
||||
* once on the AddAddress screen and passed in, so this screen never re-validates the address against every chain).
|
||||
* [selectedNetworkIds] carries the current selection so it can be restored; empty means nothing is pre-selected.
|
||||
*/
|
||||
@Serializable
|
||||
data class SelectNetworks(
|
||||
val matchedNetworkIds: kotlin.collections.List<String>,
|
||||
val selectedNetworkIds: kotlin.collections.List<String> = emptyList(),
|
||||
) : AddressBookRoute()
|
||||
|
||||
/** How the contacts list is shown — agnostic of which feature opened it. */
|
||||
@Serializable
|
||||
sealed interface ListMode {
|
||||
|
||||
@Serializable
|
||||
data object Default : ListMode
|
||||
|
||||
/** Pick a contact that has an address in [networkId]. */
|
||||
@Serializable
|
||||
data class Selector(val networkId: String) : ListMode
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.tangem.features.addressbook.selectnetworks
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.features.addressbook.selectnetworks.model.SelectNetworksModel
|
||||
import com.tangem.features.addressbook.selectnetworks.ui.SelectNetworksContent
|
||||
|
||||
internal class DefaultSelectNetworksComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: SelectNetworksModel = getOrCreateModel(params)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
SelectNetworksContent(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
)
|
||||
BackHandler(onBack = state.onBackClick)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val matchedNetworkIds: List<String>,
|
||||
val selectedNetworkIds: List<String>,
|
||||
val onBackClick: () -> Unit,
|
||||
val onDone: (selectedNetworkIds: Set<String>) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
package com.tangem.features.addressbook.selectnetworks.model
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.features.addressbook.analytics.AddressBookEvents
|
||||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
|
||||
import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController
|
||||
import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateNetworksContentTransformer
|
||||
import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateSelectNetworksInitialStateTransformer
|
||||
import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateSelectNetworksSearchBarTransformer
|
||||
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("NamedArguments")
|
||||
@ModelScoped
|
||||
internal class SelectNetworksModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: SelectNetworksStateController,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
) : Model() {
|
||||
|
||||
private val params: DefaultSelectNetworksComponent.Params = paramsContainer.require()
|
||||
private val query = MutableStateFlow("")
|
||||
private val isSearchActive = MutableStateFlow(false)
|
||||
|
||||
private val matchedBlockchains: List<Blockchain> = params.matchedNetworkIds.mapNotNull(Blockchain::fromNetworkId)
|
||||
|
||||
private val selectedNetworks = MutableStateFlow(
|
||||
params.selectedNetworkIds.toSet().intersect(
|
||||
matchedBlockchains.map { blockchain -> blockchain.toNetworkId() }.toSet(),
|
||||
),
|
||||
)
|
||||
|
||||
val state: StateFlow<SelectNetworksUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
updateInitialState()
|
||||
subscribeToContent()
|
||||
}
|
||||
|
||||
private fun updateInitialState() {
|
||||
stateController.update(
|
||||
UpdateSelectNetworksInitialStateTransformer(
|
||||
onQueryChange = ::onQueryChange,
|
||||
onActiveChange = ::onActiveChange,
|
||||
onBackClick = params.onBackClick,
|
||||
onDoneClick = ::onDoneClick,
|
||||
onSelectAllClick = ::onSelectAllClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun subscribeToContent() {
|
||||
combine(query, selectedNetworks, isSearchActive) { query, selection, isSearchActive ->
|
||||
UpdateNetworksContentTransformer(
|
||||
matchedBlockchains = matchedBlockchains,
|
||||
query = query,
|
||||
selectedNetworkIds = selection,
|
||||
isSearchActive = isSearchActive,
|
||||
onToggle = ::onToggle,
|
||||
)
|
||||
}
|
||||
.onEach(stateController::update)
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun onQueryChange(value: String) {
|
||||
query.value = value
|
||||
updateSearchBar(query = value, isActive = isSearchActive.value)
|
||||
}
|
||||
|
||||
private fun onActiveChange(isActive: Boolean) {
|
||||
isSearchActive.value = isActive
|
||||
updateSearchBar(query = query.value, isActive = isActive)
|
||||
}
|
||||
|
||||
/** Reflects the search field immediately on the caller (main) thread, decoupled from the content recomputation. */
|
||||
private fun updateSearchBar(query: String, isActive: Boolean) {
|
||||
stateController.update(UpdateSelectNetworksSearchBarTransformer(query = query, isActive = isActive))
|
||||
}
|
||||
|
||||
private fun onToggle(networkId: String) {
|
||||
val current = selectedNetworks.value
|
||||
selectedNetworks.value = if (networkId in current) current - networkId else current + networkId
|
||||
}
|
||||
|
||||
private fun onSelectAllClick() {
|
||||
val allNetworkIds = matchedBlockchains.map { blockchain -> blockchain.toNetworkId() }.toSet()
|
||||
val isSelectingAll = allNetworkIds.isEmpty() || !selectedNetworks.value.containsAll(allNetworkIds)
|
||||
|
||||
analyticsSender.sendSelectAllNetworksTapped(
|
||||
action = if (isSelectingAll) {
|
||||
AddressBookEvents.SelectAllNetworksTapped.Action.SelectAll
|
||||
} else {
|
||||
AddressBookEvents.SelectAllNetworksTapped.Action.ClearAll
|
||||
},
|
||||
scope = modelScope,
|
||||
)
|
||||
|
||||
selectedNetworks.value = if (isSelectingAll) allNetworkIds else emptySet()
|
||||
}
|
||||
|
||||
private fun onDoneClick() {
|
||||
val selected = selectedNetworks.value
|
||||
if (selected.isEmpty()) return
|
||||
params.onDone(selected)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.tangem.features.addressbook.selectnetworks.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.ds2.search.TangemSearch
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class SelectNetworksStateController @Inject constructor() {
|
||||
|
||||
val uiState: StateFlow<SelectNetworksUM>
|
||||
field = MutableStateFlow(value = getInitialState())
|
||||
|
||||
fun update(transformer: Transformer<SelectNetworksUM>) {
|
||||
uiState.update(function = transformer::transform)
|
||||
}
|
||||
|
||||
private fun getInitialState(): SelectNetworksUM = SelectNetworksUM(
|
||||
searchBar = TangemSearch.State(
|
||||
placeholderText = resourceReference(R.string.common_search),
|
||||
query = "",
|
||||
onQueryChange = {},
|
||||
isActive = false,
|
||||
onActiveChange = {},
|
||||
onClearClick = {},
|
||||
onCloseClick = {},
|
||||
),
|
||||
networks = persistentListOf(),
|
||||
selectAllButton = SelectNetworksUM.SelectAllButtonUM.Empty,
|
||||
doneButton = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.common_done),
|
||||
type = TangemButtonType.Primary,
|
||||
isEnabled = false,
|
||||
onClick = {},
|
||||
),
|
||||
onBackClick = {},
|
||||
onSelectAllClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.tangem.features.addressbook.selectnetworks.state.transformers
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.features.addressbook.selectnetworks.state.transformers.converter.SelectNetworkItemConverter
|
||||
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal class UpdateNetworksContentTransformer(
|
||||
private val matchedBlockchains: List<Blockchain>,
|
||||
private val query: String,
|
||||
private val selectedNetworkIds: Set<String>,
|
||||
private val isSearchActive: Boolean,
|
||||
private val onToggle: (networkId: String) -> Unit,
|
||||
) : Transformer<SelectNetworksUM> {
|
||||
|
||||
override fun transform(prevState: SelectNetworksUM): SelectNetworksUM {
|
||||
val visible = if (query.isBlank()) {
|
||||
matchedBlockchains
|
||||
} else {
|
||||
matchedBlockchains.filter { blockchain ->
|
||||
blockchain.fullName.contains(query, ignoreCase = true) ||
|
||||
blockchain.currency.contains(query, ignoreCase = true) ||
|
||||
blockchain.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
val networks = visible
|
||||
.map { blockchain ->
|
||||
SelectNetworkItemConverter().convert(
|
||||
SelectNetworkItemConverter.Input(
|
||||
blockchain = blockchain,
|
||||
isSelected = blockchain.toNetworkId() in selectedNetworkIds,
|
||||
onToggle = onToggle,
|
||||
),
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
|
||||
val isAllSelected = matchedBlockchains.isNotEmpty() &&
|
||||
selectedNetworkIds.size == matchedBlockchains.size
|
||||
val selectAllButton = when {
|
||||
isSearchActive -> SelectNetworksUM.SelectAllButtonUM.Empty
|
||||
isAllSelected -> SelectNetworksUM.SelectAllButtonUM.ClearAll
|
||||
else -> SelectNetworksUM.SelectAllButtonUM.SelectAll
|
||||
}
|
||||
|
||||
// Search field is owned by UpdateSelectNetworksSearchBarTransformer and intentionally left untouched here.
|
||||
return prevState.copy(
|
||||
networks = networks,
|
||||
selectAllButton = selectAllButton,
|
||||
doneButton = prevState.doneButton.copy(isEnabled = selectedNetworkIds.isNotEmpty()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.tangem.features.addressbook.selectnetworks.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class UpdateSelectNetworksInitialStateTransformer(
|
||||
private val onQueryChange: (String) -> Unit,
|
||||
private val onActiveChange: (Boolean) -> Unit,
|
||||
private val onBackClick: () -> Unit,
|
||||
private val onDoneClick: () -> Unit,
|
||||
private val onSelectAllClick: () -> Unit,
|
||||
) : Transformer<SelectNetworksUM> {
|
||||
|
||||
override fun transform(prevState: SelectNetworksUM): SelectNetworksUM {
|
||||
return prevState.copy(
|
||||
searchBar = prevState.searchBar.copy(
|
||||
onQueryChange = onQueryChange,
|
||||
onActiveChange = onActiveChange,
|
||||
onCloseClick = {
|
||||
onQueryChange("")
|
||||
onActiveChange(false)
|
||||
},
|
||||
onClearClick = { onQueryChange("") },
|
||||
),
|
||||
doneButton = prevState.doneButton.copy(onClick = onDoneClick),
|
||||
onBackClick = onBackClick,
|
||||
onSelectAllClick = onSelectAllClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.features.addressbook.selectnetworks.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class UpdateSelectNetworksSearchBarTransformer(
|
||||
private val query: String,
|
||||
private val isActive: Boolean,
|
||||
) : Transformer<SelectNetworksUM> {
|
||||
|
||||
override fun transform(prevState: SelectNetworksUM): SelectNetworksUM {
|
||||
return prevState.copy(
|
||||
searchBar = prevState.searchBar.copy(query = query, isActive = isActive),
|
||||
)
|
||||
}
|
||||
}
|
||||
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