Updated on 2026-08-14
This commit is contained in:
commit
b2da68216a
501 changed files with 16767 additions and 5720 deletions
|
|
@ -10,6 +10,10 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/* Project - Common */
|
||||
api(projects.common.routing)
|
||||
implementation(projects.common.ui)
|
||||
|
||||
/* Project - Domain */
|
||||
implementation(projects.domain.models)
|
||||
|
||||
|
|
@ -19,4 +23,7 @@ dependencies {
|
|||
|
||||
/* Compose */
|
||||
implementation(deps.compose.runtime)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.common.routing.entity.AddressBookOpenMode
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
|
||||
|
|
@ -7,5 +8,5 @@ interface AddressBookComponent : ComposableContentComponent {
|
|||
|
||||
interface Factory : ComponentFactory<Params, AddressBookComponent>
|
||||
|
||||
data class Params(val predefinedAddress: String?)
|
||||
data class Params(val addressBookOpenMode: AddressBookOpenMode)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The contacts block shown on the Send address-entry screen, below the "recent" block. Lists up to five contacts that
|
||||
* have an address in [Params.network], filtered live by [Params.queryFlow] (the recipient-input text, matched against
|
||||
* contact name or address). Hidden when there are no matching contacts.
|
||||
*/
|
||||
interface AddressBookContactsBlockComponent : ComposableContentComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, AddressBookContactsBlockComponent>
|
||||
|
||||
/**
|
||||
* @property userWalletId the sending wallet whose address book is shown
|
||||
* @property network the current send network; only contacts with an address in this network are shown
|
||||
* @property queryFlow the live recipient-input text used to filter the block
|
||||
* @property onContactClick invoked with the tapped contact and its network-matching entries; the host decides
|
||||
* whether to apply it directly (single entry) or open the address selector (multiple entries)
|
||||
* @property onSeeAllClick invoked when the user taps "See all" to open the full address book in selection mode
|
||||
*/
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val network: Network,
|
||||
val queryFlow: StateFlow<String>,
|
||||
val onContactClick: (MatchedContact) -> Unit,
|
||||
val onSeeAllClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
|
||||
/**
|
||||
* Bottom sheet shown when a picked contact has more than one address in the target network. Lets the user choose a
|
||||
* concrete address; the chosen one is returned via [Params.onAddressSelected] as a [SelectedContact].
|
||||
*/
|
||||
interface AddressSelectorComponent : ComposableBottomSheetComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, AddressSelectorComponent>
|
||||
|
||||
data class Params(
|
||||
val contact: MatchedContact,
|
||||
val onAddressSelected: (SelectedContact) -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
/**
|
||||
* Delivers a contact picked in the full address-book list (opened in selection mode) back to whatever feature
|
||||
* requested the selection. The picker and the requesting feature live in independent model scopes, so a one-shot
|
||||
* [SharedFlow] is used instead of a retained holder: nothing is kept after emission, so there is nothing to clear.
|
||||
*
|
||||
* Mirrors the `SwapChooseTokenNetworkTrigger`/`Listener` pattern.
|
||||
*/
|
||||
interface ContactSelectionTrigger {
|
||||
|
||||
fun trigger(contact: SelectedContact)
|
||||
}
|
||||
|
||||
interface ContactSelectionListener {
|
||||
|
||||
val resultFlow: SharedFlow<SelectedContact>
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* A contact together with its address entries that match a given network. Emitted when a contact is tapped during
|
||||
* selection; the host decides what to do with it:
|
||||
* - exactly one [entries] item → build a [SelectedContact] and proceed straight away;
|
||||
* - more than one → open the address selector so the user picks a concrete address first.
|
||||
*/
|
||||
|
||||
data class MatchedContact(
|
||||
val contactId: String,
|
||||
val name: String,
|
||||
val icon: AccountIconUM.CryptoPortfolio,
|
||||
val networkId: String,
|
||||
val entries: ImmutableList<ContactAddress>,
|
||||
) {
|
||||
|
||||
/** Resolves this contact to a concrete pick using one of its [entries]. */
|
||||
fun toSelectedContact(entry: ContactAddress): SelectedContact = SelectedContact(
|
||||
contactId = contactId,
|
||||
name = name,
|
||||
icon = icon,
|
||||
address = entry.address,
|
||||
networkId = networkId,
|
||||
memo = entry.memo,
|
||||
)
|
||||
|
||||
/** A single network-matching address of the contact. */
|
||||
data class ContactAddress(
|
||||
val address: String,
|
||||
val memo: String?,
|
||||
val networkName: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
|
||||
/**
|
||||
* A single resolved address-book pick. Produced once the concrete address within a contact is known — either directly
|
||||
* (the contact has a single matching-network address) or after the user chose one in the address selector.
|
||||
*
|
||||
* Feature-agnostic: any feature that opens the address book for selection receives this result.
|
||||
*
|
||||
* @property contactId the id of the source [com.tangem.domain.addressbook.model.Contact]
|
||||
* @property name the contact name to display
|
||||
* @property icon the contact avatar (initials + color), reusing the account icon UI model
|
||||
* @property address the chosen on-chain address
|
||||
* @property networkId raw id of the network the address belongs to
|
||||
* @property memo optional memo/destination tag (only meaningful for networks that support it)
|
||||
*/
|
||||
data class SelectedContact(
|
||||
val contactId: String,
|
||||
val name: String,
|
||||
val icon: AccountIconUM.CryptoPortfolio,
|
||||
val address: String,
|
||||
val networkId: String,
|
||||
val memo: String?,
|
||||
)
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.tangem.features.addressbook.addaddress
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
|
||||
internal interface AddAddressComponent : ComposableContentComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, AddAddressComponent>
|
||||
|
||||
data class Params(
|
||||
val onBackClick: () -> Unit,
|
||||
val onConfirm: (ValidatedAddress) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -7,16 +7,15 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.features.addressbook.addaddress.model.AddAddressModel
|
||||
import com.tangem.features.addressbook.addaddress.ui.AddAddressContent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
|
||||
internal class DefaultAddAddressComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted params: AddAddressComponent.Params,
|
||||
) : AddAddressComponent, AppComponentContext by context {
|
||||
internal class DefaultAddAddressComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: AddAddressModel = getOrCreateModel(params)
|
||||
|
||||
|
|
@ -30,11 +29,8 @@ internal class DefaultAddAddressComponent @AssistedInject constructor(
|
|||
BackHandler(onBack = state.onBackClick)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddAddressComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddAddressComponent.Params,
|
||||
): DefaultAddAddressComponent
|
||||
}
|
||||
data class Params(
|
||||
val onBackClick: () -> Unit,
|
||||
val onConfirm: (ValidatedAddress) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,30 +1,21 @@
|
|||
package com.tangem.features.addressbook.addaddress.model
|
||||
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.common.ui.extensions.iconResId
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.features.addressbook.addaddress.AddAddressComponent
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
|
||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
|
||||
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddAddressInitialStateTransformer
|
||||
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressInputTransformer
|
||||
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.map
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@ModelScoped
|
||||
|
|
@ -33,12 +24,12 @@ internal class AddAddressModel @Inject constructor(
|
|||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val clipboardManager: ClipboardManager,
|
||||
private val stateController: AddAddressStateController,
|
||||
) : Model() {
|
||||
|
||||
private val params: AddAddressComponent.Params = paramsContainer.require()
|
||||
private val params: DefaultAddAddressComponent.Params = paramsContainer.require()
|
||||
|
||||
val state: StateFlow<AddAddressUM>
|
||||
field = MutableStateFlow(getInitialState())
|
||||
val state: StateFlow<AddAddressUM> get() = stateController.uiState
|
||||
|
||||
private val availableCoins: StateFlow<List<CryptoCurrency.Coin>> = multiAccountListSupplier()
|
||||
.map { accountLists ->
|
||||
|
|
@ -47,6 +38,7 @@ internal class AddAddressModel @Inject constructor(
|
|||
.filterIsInstance<CryptoCurrency.Coin>()
|
||||
.distinctBy { it.network.id }
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val addressInput = state
|
||||
|
|
@ -55,94 +47,44 @@ internal class AddAddressModel @Inject constructor(
|
|||
.debounce(ADD_ADDRESS_DEBOUNCE)
|
||||
|
||||
init {
|
||||
subscribeToAddressInput()
|
||||
updateInitialState()
|
||||
subscribeToAddressValidation()
|
||||
}
|
||||
|
||||
private fun onAddressChange(value: String, isPasted: Boolean = false) {
|
||||
state.update { oldState ->
|
||||
oldState.copy(
|
||||
addressField = oldState.addressField.copy(
|
||||
value = value,
|
||||
isValuePasted = isPasted,
|
||||
isError = false,
|
||||
error = null,
|
||||
),
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading,
|
||||
)
|
||||
}
|
||||
private fun updateInitialState() {
|
||||
stateController.update(
|
||||
UpdateAddAddressInitialStateTransformer(
|
||||
onAddressChange = { onAddressChange(value = it) },
|
||||
onAddressClear = { onAddressChange("") },
|
||||
onPasteClick = ::onPaste,
|
||||
onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
|
||||
onBackClick = params.onBackClick,
|
||||
onConfirmClick = ::validateAndConfirm,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun subscribeToAddressInput() {
|
||||
private fun onAddressChange(value: String) {
|
||||
stateController.update(UpdateAddressInputTransformer(value = value))
|
||||
}
|
||||
|
||||
private fun subscribeToAddressValidation() {
|
||||
combine(addressInput, availableCoins) { input, coins ->
|
||||
getUniqueNetworks(input, coins)
|
||||
UpdateAddressValidationTransformer(address = input, coins = coins)
|
||||
}
|
||||
.onEach { availableNetworks ->
|
||||
state.update { oldState ->
|
||||
oldState.copy(
|
||||
availableNetworks = availableNetworks,
|
||||
chosenNetworkStateUM = createChosenNetworkState(availableNetworks),
|
||||
)
|
||||
}
|
||||
}
|
||||
.onEach(stateController::update)
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun createChosenNetworkState(availableNetworks: ImmutableList<Network>): AddAddressUM.ChosenNetworkStateUM {
|
||||
return if (availableNetworks.isEmpty()) {
|
||||
AddAddressUM.ChosenNetworkStateUM.Empty
|
||||
} else {
|
||||
AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = availableNetworks
|
||||
.map { network ->
|
||||
AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM(
|
||||
networkName = network.name,
|
||||
iconResId = network.iconResId,
|
||||
)
|
||||
}
|
||||
.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUniqueNetworks(input: String, coins: List<CryptoCurrency.Coin>): ImmutableList<Network> {
|
||||
return coins
|
||||
.filter { it.network.toBlockchain().validateAddress(input) }
|
||||
.map { it.network }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
private fun onPaste() {
|
||||
onAddressChange(value = clipboardManager.getText().orEmpty(), isPasted = true)
|
||||
onAddressChange(value = clipboardManager.getText().orEmpty())
|
||||
}
|
||||
|
||||
private fun validateAndConfirm() {
|
||||
// TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm
|
||||
// TODO Address book ([REDACTED_TASK_KEY]): navigate to the network-selection with the address and its matching networks.
|
||||
}
|
||||
|
||||
private fun getInitialState(): AddAddressUM = AddAddressUM(
|
||||
addressField = AddressFieldUM(
|
||||
value = "",
|
||||
placeholder = resourceReference(R.string.common_address),
|
||||
label = resourceReference(R.string.address_book_enter_address),
|
||||
isError = false,
|
||||
error = null,
|
||||
isValuePasted = false,
|
||||
),
|
||||
availableNetworks = persistentListOf(),
|
||||
buttonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
type = TangemButtonType.Primary,
|
||||
isEnabled = false,
|
||||
onClick = ::validateAndConfirm,
|
||||
),
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
|
||||
onAddressChange = { onAddressChange(value = it) },
|
||||
onAddressClear = { onAddressChange("") },
|
||||
onPasteClick = ::onPaste,
|
||||
onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
|
||||
onBackClick = params.onBackClick,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val ADD_ADDRESS_DEBOUNCE = 500L
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
package com.tangem.features.addressbook.addaddress.state
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class AddAddressStateController @Inject constructor() {
|
||||
|
||||
val uiState: StateFlow<AddAddressUM>
|
||||
field = MutableStateFlow(value = getInitialState())
|
||||
|
||||
fun update(transformer: Transformer<AddAddressUM>) {
|
||||
uiState.update(function = transformer::transform)
|
||||
}
|
||||
|
||||
private fun getInitialState(): AddAddressUM = AddAddressUM(
|
||||
addressField = AddressFieldUM(
|
||||
value = "",
|
||||
placeholder = resourceReference(R.string.address_book_enter_address),
|
||||
label = resourceReference(R.string.common_address),
|
||||
isError = false,
|
||||
),
|
||||
buttonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
type = TangemButtonType.Primary,
|
||||
isEnabled = false,
|
||||
onClick = {},
|
||||
),
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
|
||||
onAddressChange = {},
|
||||
onAddressClear = {},
|
||||
onPasteClick = {},
|
||||
onQrClick = {},
|
||||
onBackClick = {},
|
||||
onNetworkClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.tangem.features.addressbook.addaddress.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Wires the callbacks owned by [com.tangem.features.addressbook.addaddress.model.AddAddressModel] into the initial
|
||||
* state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController].
|
||||
*/
|
||||
internal class UpdateAddAddressInitialStateTransformer(
|
||||
private val onAddressChange: (String) -> Unit,
|
||||
private val onAddressClear: () -> Unit,
|
||||
private val onPasteClick: () -> Unit,
|
||||
private val onQrClick: () -> Unit,
|
||||
private val onBackClick: () -> Unit,
|
||||
private val onConfirmClick: () -> Unit,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
return prevState.copy(
|
||||
onAddressChange = onAddressChange,
|
||||
onAddressClear = onAddressClear,
|
||||
onPasteClick = onPasteClick,
|
||||
onQrClick = onQrClick,
|
||||
onBackClick = onBackClick,
|
||||
buttonUM = prevState.buttonUM.copy(onClick = onConfirmClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.tangem.features.addressbook.addaddress.state.transformers
|
||||
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Updates the address field with a freshly entered/pasted [value] and clears any previous error, restoring the default
|
||||
* label. The actual (re)validation runs after a debounce — see [UpdateAddressValidationTransformer].
|
||||
*/
|
||||
internal class UpdateAddressInputTransformer(
|
||||
private val value: String,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
return prevState.copy(
|
||||
addressField = prevState.addressField.copy(
|
||||
value = value,
|
||||
isError = false,
|
||||
label = resourceReference(R.string.common_address),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.features.addressbook.addaddress.state.transformers
|
||||
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Validates [address] against the wallet's [coins] and reflects the result in the UI.
|
||||
*
|
||||
* The network is not chosen on this screen (it is selected on the next screen), so the address is valid when it matches
|
||||
* at least one of the available networks — the same blockchain check the Send flow uses. An invalid (non-empty,
|
||||
* matching nothing) address surfaces the error in the field label and disables the confirm button.
|
||||
*/
|
||||
internal class UpdateAddressValidationTransformer(
|
||||
private val address: String,
|
||||
private val coins: List<CryptoCurrency.Coin>,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
val hasMatchedAnyNetwork = address.isNotBlank() &&
|
||||
coins.any { it.network.toBlockchain().validateAddress(address) }
|
||||
val isError = address.isNotBlank() && !hasMatchedAnyNetwork
|
||||
val label = if (isError) {
|
||||
resourceReference(R.string.address_book_invalid_address_error)
|
||||
} else {
|
||||
resourceReference(R.string.common_address)
|
||||
}
|
||||
return prevState.copy(
|
||||
addressField = prevState.addressField.copy(isError = isError, label = label),
|
||||
buttonUM = prevState.buttonUM.copy(isEnabled = hasMatchedAnyNetwork),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,14 +3,15 @@ package com.tangem.features.addressbook.addaddress.ui
|
|||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.ds.button.PrimaryTangemButton
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
|
|
@ -20,9 +21,8 @@ import com.tangem.core.ui.extensions.TextReference
|
|||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
|
||||
|
||||
@Composable
|
||||
internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifier) {
|
||||
|
|
@ -34,7 +34,6 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
|
|||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TangemTopBar(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
title = resourceReference(R.string.address_book_add_address),
|
||||
startContent = {
|
||||
TangemButton(
|
||||
|
|
@ -47,14 +46,23 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
|
|||
)
|
||||
|
||||
RecipientRow(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
addressField = state.addressField,
|
||||
onValueChange = state.onAddressChange,
|
||||
onAddressClear = state.onAddressClear,
|
||||
onQrClick = state.onQrClick,
|
||||
onPasteClick = state.onPasteClick,
|
||||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(state.chosenNetworkStateUM)
|
||||
SpacerH(20.dp)
|
||||
NetworkBlock(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.fillMaxWidth()
|
||||
.background(color = TangemTheme.colors3.bg.secondary),
|
||||
chosenNetworkStateUM = state.chosenNetworkStateUM,
|
||||
onNetworkSelectClick = state.onNetworkClick,
|
||||
)
|
||||
PrimaryButton(state.buttonUM)
|
||||
}
|
||||
}
|
||||
|
|
@ -62,13 +70,15 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
|
|||
@Composable
|
||||
private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
PrimaryTangemButton(
|
||||
TangemButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
buttonUM = buttonUM,
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
onClick = buttonUM.onClick,
|
||||
isEnabled = buttonUM.isEnabled,
|
||||
isLoading = buttonUM.isLoading,
|
||||
size = TangemButton.Size.X12,
|
||||
text = buttonUM.text,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +94,6 @@ private fun Preview_AddAddressContent() {
|
|||
placeholder = resourceReference(R.string.address_book_enter_address),
|
||||
label = resourceReference(R.string.common_address),
|
||||
),
|
||||
availableNetworks = persistentListOf(),
|
||||
buttonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
type = TangemButtonType.Primary,
|
||||
|
|
@ -97,6 +106,7 @@ private fun Preview_AddAddressContent() {
|
|||
onPasteClick = {},
|
||||
onQrClick = {},
|
||||
onBackClick = {},
|
||||
onNetworkClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,86 +1,95 @@
|
|||
package com.tangem.features.addressbook.addaddress.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEachIndexed
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.components.SpacerW
|
||||
import com.tangem.core.ui.ds2.loader.TangemLoader
|
||||
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.clickableSingle
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
|
||||
import com.tangem.utils.StringsSigns
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
private const val MAX_VISIBLE_NETWORKS = 3
|
||||
private val NetworkIconSize = 24.dp
|
||||
|
||||
// Horizontal advance per icon. Smaller than the icon size so icons overlap; the bg-colored ring on
|
||||
// the icon drawn on top carves the crescent cut-out from the icon below.
|
||||
private val NetworkIconStep = 18.dp
|
||||
|
||||
@Composable
|
||||
internal fun NetworkBlock(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
|
||||
internal fun NetworkBlock(
|
||||
onNetworkSelectClick: () -> Unit,
|
||||
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.fillMaxWidth()
|
||||
.background(color = TangemTheme.colors3.bg.secondary)
|
||||
.padding(horizontal = 4.dp),
|
||||
modifier = modifier,
|
||||
titleSlot = {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.common_network),
|
||||
style = TangemTheme.typography.body2,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
)
|
||||
},
|
||||
endSlot = {
|
||||
SelectNetworkButton(chosenNetworkStateUM)
|
||||
SelectNetworkButton(
|
||||
onNetworkSelectClick = onNetworkSelectClick,
|
||||
chosenNetworkStateUM = chosenNetworkStateUM,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectNetworkButton(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
|
||||
private fun SelectNetworkButton(
|
||||
onNetworkSelectClick: () -> Unit,
|
||||
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickableSingle(
|
||||
onClick = onNetworkSelectClick,
|
||||
enabled = chosenNetworkStateUM !is AddAddressUM.ChosenNetworkStateUM.Loading,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (chosenNetworkStateUM) {
|
||||
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList)
|
||||
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader()
|
||||
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20)
|
||||
AddAddressUM.ChosenNetworkStateUM.Empty -> {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
text = stringResourceSafe(R.string.address_book_select_network),
|
||||
style = TangemTheme.typography.body2,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
SpacerW(4.dp)
|
||||
ChevronIcon()
|
||||
}
|
||||
}
|
||||
|
|
@ -100,7 +109,7 @@ private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
|
|||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
text = network.networkName,
|
||||
style = TangemTheme.typography.body2,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
ChevronIcon()
|
||||
|
|
@ -120,7 +129,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
|
|||
val remaining = networks.size - visible.size
|
||||
|
||||
Box(modifier = Modifier.wrapContentWidth()) {
|
||||
visible.forEachIndexed { index, network ->
|
||||
visible.fastForEachIndexed { index, network ->
|
||||
Image(
|
||||
painter = painterResource(id = network.iconResId),
|
||||
contentDescription = null,
|
||||
|
|
@ -128,7 +137,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
|
|||
modifier = Modifier
|
||||
.padding(start = NetworkIconStep * index)
|
||||
.networkIconRing()
|
||||
.size(NetworkIconSize),
|
||||
.size(24.dp),
|
||||
)
|
||||
}
|
||||
if (remaining > 0) {
|
||||
|
|
@ -137,12 +146,13 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
|
|||
.padding(start = NetworkIconStep * visible.size)
|
||||
.networkIconRing()
|
||||
.background(color = TangemTheme.colors3.bg.tertiary)
|
||||
.size(NetworkIconSize),
|
||||
.heightIn(min = 24.dp)
|
||||
.padding(vertical = 2.dp, horizontal = 4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "+$remaining",
|
||||
style = TangemTheme.typography.caption1,
|
||||
text = "${StringsSigns.PLUS}$remaining",
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
}
|
||||
|
|
@ -160,20 +170,23 @@ private fun Modifier.networkIconRing(): Modifier = this
|
|||
|
||||
@Composable
|
||||
private fun ChevronIcon() {
|
||||
Image(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
painter = painterResource(id = R.drawable.ic_select_18_24),
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(20.dp),
|
||||
tint = TangemTheme.colors3.icon.secondary,
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_select_18_24),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun Preview_NetworkBlock() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column {
|
||||
NetworkBlock(
|
||||
onNetworkSelectClick = {},
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = persistentListOf(
|
||||
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
||||
|
|
@ -182,6 +195,7 @@ private fun Preview_NetworkBlock() {
|
|||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(
|
||||
onNetworkSelectClick = {},
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = persistentListOf(
|
||||
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
||||
|
|
@ -192,6 +206,7 @@ private fun Preview_NetworkBlock() {
|
|||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(
|
||||
onNetworkSelectClick = {},
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = List(15) {
|
||||
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
|
||||
|
|
@ -199,9 +214,9 @@ private fun Preview_NetworkBlock() {
|
|||
),
|
||||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading)
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, onNetworkSelectClick = {})
|
||||
SpacerH12()
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty)
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, onNetworkSelectClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,7 @@ package com.tangem.features.addressbook.addaddress.ui
|
|||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -19,7 +15,6 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.components.SpacerW8
|
||||
import com.tangem.core.ui.components.fields.SimpleTextField
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
|
|
@ -28,13 +23,14 @@ import com.tangem.core.ui.ds2.row.TangemRow
|
|||
import com.tangem.core.ui.ds2.row.TangemRowContentLead
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
|
||||
import com.tangem.core.ui.res.generated.icons.ic_scan_20
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
|
||||
|
||||
@Composable
|
||||
internal fun RecipientRow(
|
||||
|
|
@ -43,19 +39,23 @@ internal fun RecipientRow(
|
|||
onAddressClear: () -> Unit,
|
||||
onQrClick: () -> Unit,
|
||||
onPasteClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
|
||||
text = stringResourceSafe(R.string.common_address),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
text = addressField.label.resolveReference(),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = if (addressField.isError) {
|
||||
TangemTheme.colors3.text.status.error
|
||||
} else {
|
||||
TangemTheme.colors3.text.secondary
|
||||
},
|
||||
)
|
||||
TangemRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -64,7 +64,7 @@ internal fun RecipientRow(
|
|||
startSlot = {
|
||||
TangemIcon(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(TangemTheme.colors3.bg.tertiary),
|
||||
tangemIconUM = TangemIconUM.Ident(text = addressField.value),
|
||||
|
|
@ -72,43 +72,55 @@ internal fun RecipientRow(
|
|||
},
|
||||
titleSlot = {
|
||||
SimpleTextField(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
value = addressField.value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = TextReference.Res(R.string.address_book_enter_address),
|
||||
singleLine = false,
|
||||
placeholder = addressField.placeholder,
|
||||
)
|
||||
},
|
||||
endSlot = {
|
||||
if (addressField.value.isNotEmpty()) {
|
||||
Icon(
|
||||
modifier = Modifier.clickable(onClick = onAddressClear),
|
||||
imageVector = Icons.ic_cross_circle_20_filled,
|
||||
tint = TangemTheme.colors3.icon.tertiary,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
Row {
|
||||
TangemButton(
|
||||
variant = TangemButton.Variant.Secondary,
|
||||
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_qrcode_scaner_24),
|
||||
onClick = onQrClick,
|
||||
)
|
||||
SpacerW8()
|
||||
TangemButton(
|
||||
variant = TangemButton.Variant.Primary,
|
||||
text = TextReference.Res(id = R.string.common_paste),
|
||||
onClick = onPasteClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
RecipientEndSlot(
|
||||
hasValue = addressField.value.isNotEmpty(),
|
||||
onAddressClear = onAddressClear,
|
||||
onQrClick = onQrClick,
|
||||
onPasteClick = onPasteClick,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipientEndSlot(
|
||||
hasValue: Boolean,
|
||||
onAddressClear: () -> Unit,
|
||||
onQrClick: () -> Unit,
|
||||
onPasteClick: () -> Unit,
|
||||
) {
|
||||
if (hasValue) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onAddressClear),
|
||||
imageVector = Icons.ic_cross_circle_20_filled,
|
||||
tint = TangemTheme.colors3.icon.tertiary,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TangemButton(
|
||||
variant = TangemButton.Variant.Secondary,
|
||||
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_scan_20),
|
||||
onClick = onQrClick,
|
||||
)
|
||||
TangemButton(
|
||||
text = TextReference.Res(id = R.string.common_paste),
|
||||
onClick = onPasteClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
package com.tangem.features.addressbook.addaddress.contract
|
||||
package com.tangem.features.addressbook.addaddress.ui.state
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.domain.models.network.Network
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal data class AddAddressUM(
|
||||
val addressField: AddressFieldUM,
|
||||
val availableNetworks: ImmutableList<Network>,
|
||||
val buttonUM: TangemButtonUM,
|
||||
val chosenNetworkStateUM: ChosenNetworkStateUM,
|
||||
val onAddressChange: (String) -> Unit,
|
||||
|
|
@ -16,15 +15,14 @@ internal data class AddAddressUM(
|
|||
val onPasteClick: () -> Unit,
|
||||
val onQrClick: () -> Unit,
|
||||
val onBackClick: () -> Unit,
|
||||
val onNetworkClick: () -> Unit,
|
||||
) {
|
||||
@Immutable
|
||||
sealed class ChosenNetworkStateUM {
|
||||
data object Loading : ChosenNetworkStateUM()
|
||||
data object Empty : ChosenNetworkStateUM()
|
||||
data class Result(
|
||||
val networkUMList: ImmutableList<NetworkUM>,
|
||||
) : ChosenNetworkStateUM() {
|
||||
sealed interface ChosenNetworkStateUM {
|
||||
data object Loading : ChosenNetworkStateUM
|
||||
data object Empty : ChosenNetworkStateUM
|
||||
|
||||
data class Result(val networkUMList: ImmutableList<NetworkUM>) : ChosenNetworkStateUM {
|
||||
data class NetworkUM(
|
||||
val networkName: String,
|
||||
@DrawableRes val iconResId: Int,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.addressbook.addaddress.contract
|
||||
package com.tangem.features.addressbook.addaddress.ui.state
|
||||
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
|
|
@ -7,7 +7,4 @@ internal data class AddressFieldUM(
|
|||
val placeholder: TextReference,
|
||||
val label: TextReference,
|
||||
val isError: Boolean = false,
|
||||
val error: TextReference? = null,
|
||||
val isValuePasted: Boolean = false,
|
||||
val blockchainAddress: String? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.features.addressbook.addressselector
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.addressselector.ui.AddressSelectorBottomSheet
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultAddressSelectorComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: AddressSelectorComponent.Params,
|
||||
) : AddressSelectorComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
override fun dismiss() = params.onDismiss()
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
AddressSelectorBottomSheet(
|
||||
contact = params.contact,
|
||||
onAddressClick = { entry -> params.onAddressSelected(params.contact.toSelectedContact(entry)) },
|
||||
onDismiss = ::dismiss,
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressSelectorComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressSelectorComponent.Params,
|
||||
): DefaultAddressSelectorComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package com.tangem.features.addressbook.addressselector.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBarType
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowText
|
||||
import com.tangem.core.ui.ds2.row.TangemRowTextRole
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.impl.R
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
internal fun AddressSelectorBottomSheet(
|
||||
contact: MatchedContact,
|
||||
onAddressClick: (MatchedContact.ContactAddress) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = onDismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
),
|
||||
containerColor = TangemTheme.colors3.bg.primary,
|
||||
title = {
|
||||
TangemTopBar(
|
||||
title = resourceReference(R.string.address_book_choose_address),
|
||||
type = TangemTopBarType.BottomSheet,
|
||||
endContent = {
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
|
||||
onClick = onDismiss,
|
||||
size = TangemButton.Size.X11,
|
||||
variant = TangemButton.Variant.Material,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { AddressSelectorList(contact = contact, onAddressClick = onAddressClick) },
|
||||
footer = {
|
||||
TangemButton(
|
||||
onClick = onDismiss,
|
||||
text = resourceReference(R.string.common_cancel),
|
||||
variant = TangemButton.Variant.Secondary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressSelectorList(
|
||||
contact: MatchedContact,
|
||||
onAddressClick: (MatchedContact.ContactAddress) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = TangemTheme.colors3.bg.secondary,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
contact.entries.fastForEach { entry ->
|
||||
AddressRow(entry = entry, onClick = { onAddressClick(entry) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressRow(entry: MatchedContact.ContactAddress, onClick: () -> Unit) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
onClick = onClick,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Ident(entry.address),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = entry.address,
|
||||
role = TangemRowTextRole.Title,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = entry.networkName,
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_AddressSelectorList() {
|
||||
TangemThemePreviewRedesign {
|
||||
AddressSelectorList(
|
||||
contact = MatchedContact(
|
||||
contactId = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkId = "ethereum",
|
||||
entries = persistentListOf(
|
||||
MatchedContact.ContactAddress(
|
||||
address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
|
||||
memo = null,
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
MatchedContact.ContactAddress(
|
||||
address = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
|
||||
memo = "12345",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
),
|
||||
onAddressClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.features.addressbook.block
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.block.model.ContactsBlockModel
|
||||
import com.tangem.features.addressbook.block.ui.ContactsBlock
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultAddressBookContactsBlockComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted params: AddressBookContactsBlockComponent.Params,
|
||||
) : AddressBookContactsBlockComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: ContactsBlockModel = getOrCreateModel(params)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
ContactsBlock(state = state, modifier = modifier)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressBookContactsBlockComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressBookContactsBlockComponent.Params,
|
||||
): DefaultAddressBookContactsBlockComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.tangem.features.addressbook.block.model
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.block.state.ContactsBlockStateController
|
||||
import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.features.addressbook.common.ContactMatcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@ModelScoped
|
||||
internal class ContactsBlockModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: ContactsBlockStateController,
|
||||
getContactsUseCase: GetContactsUseCase,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
|
||||
|
||||
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
params.queryFlow
|
||||
.flatMapLatest { query -> getContactsUseCase(query = query, userWalletId = params.userWalletId) }
|
||||
.onEach { contacts ->
|
||||
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
|
||||
stateController.update(
|
||||
UpdateContactsBlockStateTransformer(
|
||||
matched = matched,
|
||||
onSeeAllClick = params.onSeeAllClick,
|
||||
onContactClick = params.onContactClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.addressbook.block.state
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class ContactsBlockStateController @Inject constructor() {
|
||||
|
||||
val uiState: StateFlow<ContactsBlockUM>
|
||||
field = MutableStateFlow<ContactsBlockUM>(value = ContactsBlockUM.Hidden)
|
||||
|
||||
fun update(transformer: Transformer<ContactsBlockUM>) {
|
||||
uiState.update(function = transformer::transform)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.tangem.features.addressbook.block.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/** Builds the Send contacts block from the network-matching contacts; an empty result hides the block. */
|
||||
internal class UpdateContactsBlockStateTransformer(
|
||||
private val matched: List<MatchedContact>,
|
||||
private val onSeeAllClick: () -> Unit,
|
||||
private val onContactClick: (MatchedContact) -> Unit,
|
||||
) : Transformer<ContactsBlockUM> {
|
||||
|
||||
override fun transform(prevState: ContactsBlockUM): ContactsBlockUM {
|
||||
return if (matched.isEmpty()) {
|
||||
ContactsBlockUM.Hidden
|
||||
} else {
|
||||
ContactsBlockUM.Content(
|
||||
contacts = matched
|
||||
.take(MAX_CONTACTS)
|
||||
.map { it.toRowUM() }.toImmutableList(),
|
||||
onSeeAllClick = onSeeAllClick,
|
||||
shouldShowSeeAll = matched.size > MAX_CONTACTS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MatchedContact.toRowUM(): ContactUM = ContactUM(
|
||||
id = contactId,
|
||||
name = name,
|
||||
icon = icon,
|
||||
networkAddressCount = entries.size,
|
||||
onClick = { onContactClick(this) },
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MAX_CONTACTS = 5
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package com.tangem.features.addressbook.block.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
|
||||
import com.tangem.features.addressbook.common.ui.ContactRow
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
internal fun ContactsBlock(state: ContactsBlockUM, modifier: Modifier = Modifier) {
|
||||
if (state !is ContactsBlockUM.Content) return
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
Header(onSeeAllClick = state.onSeeAllClick, shouldShowSeeAll = state.shouldShowSeeAll)
|
||||
state.contacts.forEach { contact ->
|
||||
ContactRow(contact = contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(onSeeAllClick: () -> Unit, shouldShowSeeAll: Boolean) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 16.dp, bottom = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_title),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (shouldShowSeeAll) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.common_view_all),
|
||||
color = TangemTheme.colors3.text.brand,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
modifier = Modifier.clickable(onClick = onSeeAllClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_ContactsBlock() {
|
||||
TangemThemePreviewRedesign {
|
||||
ContactsBlock(
|
||||
state = ContactsBlockUM.Content(
|
||||
contacts = persistentListOf(
|
||||
ContactUM(
|
||||
id = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = 1,
|
||||
onClick = {},
|
||||
),
|
||||
ContactUM(
|
||||
id = "2",
|
||||
name = "Alice",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.UFOGreen,
|
||||
),
|
||||
networkAddressCount = 3,
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
onSeeAllClick = {},
|
||||
shouldShowSeeAll = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.features.addressbook.block.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/** UI state of the Send contacts block. [Hidden] is rendered as nothing (no matching contacts / feature off). */
|
||||
@Immutable
|
||||
internal sealed interface ContactsBlockUM {
|
||||
|
||||
data object Hidden : ContactsBlockUM
|
||||
|
||||
data class Content(
|
||||
val shouldShowSeeAll: Boolean,
|
||||
val contacts: ImmutableList<ContactUM>,
|
||||
val onSeeAllClick: () -> Unit,
|
||||
) : ContactsBlockUM
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Builds the child screens of the address book feature for a given [AddressBookRoute], wiring their callbacks to the
|
||||
* container's [AddressBookClickIntents]. Mirrors the `FeedEntryChildFactory` pattern used by the feed feature.
|
||||
*/
|
||||
internal class AddressBookChildFactory @Inject constructor(
|
||||
private val addressSelectorFactory: AddressSelectorComponent.Factory,
|
||||
) {
|
||||
|
||||
fun createChild(
|
||||
route: AddressBookRoute,
|
||||
context: AppComponentContext,
|
||||
clickIntents: AddressBookClickIntents,
|
||||
): ComposableContentComponent = when (route) {
|
||||
is AddressBookRoute.List -> DefaultAddressBookListComponent(
|
||||
appComponentContext = context,
|
||||
params = DefaultAddressBookListComponent.Params(
|
||||
mode = route.mode,
|
||||
onContactClick = { clickIntents.onContactClick(ContactId(it)) },
|
||||
onAddContactClick = clickIntents::onAddContactClick,
|
||||
),
|
||||
addressSelectorFactory = addressSelectorFactory,
|
||||
)
|
||||
is AddressBookRoute.EditContact -> DefaultEditContactComponent(
|
||||
appComponentContext = context,
|
||||
params = DefaultEditContactComponent.Params(
|
||||
contactId = route.contactId?.let(::ContactId),
|
||||
predefinedAddress = buildPredefinedAddress(route),
|
||||
onBackClick = clickIntents::onEditContactBack,
|
||||
onAddAddressClick = clickIntents::onAddAddressClick,
|
||||
),
|
||||
)
|
||||
AddressBookRoute.AddAddress -> DefaultAddAddressComponent(
|
||||
appComponentContext = context,
|
||||
params = DefaultAddAddressComponent.Params(
|
||||
onBackClick = clickIntents::onAddAddressBack,
|
||||
onConfirm = clickIntents::onAddressConfirmed,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */
|
||||
private fun buildPredefinedAddress(route: AddressBookRoute.EditContact): ValidatedAddress? {
|
||||
val address = route.predefinedAddress ?: return null
|
||||
val networkId = route.predefinedNetworkId ?: return null
|
||||
return ValidatedAddress(address = address, networkIds = persistentListOf(networkId))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
|
||||
/**
|
||||
* Navigation/click contract that the container ([DefaultAddressBookComponent]) implements and passes down to its
|
||||
* children through [AddressBookChildFactory]. Keeping all cross-screen intents in one place removes the need for the
|
||||
* children to know about each other or about navigation.
|
||||
*
|
||||
* Result delivery (the confirmed address) is handled out-of-band by [AddressBookResultHolder], not by this contract.
|
||||
*/
|
||||
internal interface AddressBookClickIntents {
|
||||
|
||||
fun onContactClick(contactId: ContactId)
|
||||
|
||||
fun onAddContactClick()
|
||||
|
||||
fun onEditContactBack()
|
||||
|
||||
fun onAddAddressClick()
|
||||
|
||||
fun onAddAddressBack()
|
||||
|
||||
fun onAddressConfirmed(address: ValidatedAddress)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Carries a [ValidatedAddress] confirmed on the AddAddress screen over to the EditContact screen.
|
||||
*
|
||||
* The two screens live in independent model scopes, so a shared singleton holder is used to hand the result over
|
||||
* instead of routing it through navigation/click intents. The producer calls [setConfirmedAddress]; the consumer
|
||||
* observes [confirmedAddress] and calls [clear] after applying the value so it is not re-applied on resubscription.
|
||||
*/
|
||||
@Singleton
|
||||
internal class AddressBookResultHolder @Inject constructor() {
|
||||
|
||||
val confirmedAddress: StateFlow<ValidatedAddress?>
|
||||
field = MutableStateFlow<ValidatedAddress?>(null)
|
||||
|
||||
fun setConfirmedAddress(address: ValidatedAddress) {
|
||||
confirmedAddress.value = address
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
confirmedAddress.value = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* Maps contacts to [MatchedContact]s for a given [networkId], keeping only those that have at least one address in that
|
||||
* network (with just the matching entries). Name/address query filtering is done upstream by `GetContactsUseCase`.
|
||||
*/
|
||||
internal object ContactMatcher {
|
||||
|
||||
private val DEFAULT_ICON_COLOR = CryptoPortfolioIcon.Color.Azure
|
||||
|
||||
fun match(contacts: List<Contact>, networkId: String): List<MatchedContact> {
|
||||
return contacts.mapNotNull { contact ->
|
||||
val entries = contact.addressEntries.filter { it.networkId.value == networkId }
|
||||
if (entries.isEmpty()) return@mapNotNull null
|
||||
|
||||
MatchedContact(
|
||||
contactId = contact.id.value,
|
||||
name = contact.name.value,
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = contact.resolveIconColor(),
|
||||
),
|
||||
networkId = networkId,
|
||||
entries = entries.map { entry ->
|
||||
MatchedContact.ContactAddress(
|
||||
address = entry.address,
|
||||
memo = entry.memo,
|
||||
networkName = entry.networkName,
|
||||
)
|
||||
}.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Contact.resolveIconColor(): CryptoPortfolioIcon.Color =
|
||||
CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor } ?: DEFAULT_ICON_COLOR
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.stack.Children
|
||||
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
|
||||
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.stack.StackNavigation
|
||||
import com.arkivanov.decompose.router.stack.childStack
|
||||
import com.arkivanov.decompose.router.stack.pop
|
||||
import com.arkivanov.decompose.router.stack.pushNew
|
||||
import com.tangem.common.routing.entity.AddressBookOpenMode
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.AddressBookComponent
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted private val params: AddressBookComponent.Params,
|
||||
private val childFactory: AddressBookChildFactory,
|
||||
private val resultHolder: AddressBookResultHolder,
|
||||
) : AddressBookComponent, AppComponentContext by context {
|
||||
|
||||
private val navigation = StackNavigation<AddressBookRoute>()
|
||||
|
||||
init {
|
||||
// Drop any address left over from a previous session before the (possibly preloaded) stack starts collecting.
|
||||
resultHolder.clear()
|
||||
}
|
||||
|
||||
private val clickIntents = object : AddressBookClickIntents {
|
||||
|
||||
override fun onContactClick(contactId: ContactId) {
|
||||
navigation.pushNew(AddressBookRoute.EditContact(contactId = contactId.value))
|
||||
}
|
||||
|
||||
override fun onAddContactClick() {
|
||||
navigation.pushNew(AddressBookRoute.EditContact())
|
||||
}
|
||||
|
||||
override fun onEditContactBack() {
|
||||
navigation.pop()
|
||||
}
|
||||
|
||||
override fun onAddAddressClick() {
|
||||
navigation.pushNew(AddressBookRoute.AddAddress)
|
||||
}
|
||||
|
||||
override fun onAddAddressBack() {
|
||||
navigation.pop()
|
||||
}
|
||||
|
||||
override fun onAddressConfirmed(address: ValidatedAddress) {
|
||||
resultHolder.setConfirmedAddress(address)
|
||||
navigation.pop()
|
||||
}
|
||||
}
|
||||
|
||||
private val contentStack = childStack(
|
||||
key = "address_book_stack",
|
||||
source = navigation,
|
||||
serializer = AddressBookRoute.serializer(),
|
||||
initialStack = ::initialStack,
|
||||
handleBackButton = false,
|
||||
childFactory = ::screenChild,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val childStack by contentStack.subscribeAsState()
|
||||
Children(
|
||||
modifier = modifier,
|
||||
stack = childStack,
|
||||
animation = stackAnimation(slide()),
|
||||
) { child ->
|
||||
child.instance.Content(Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent {
|
||||
return childFactory.createChild(
|
||||
route = config,
|
||||
context = childByContext(componentContext),
|
||||
clickIntents = clickIntents,
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialStack(): List<AddressBookRoute> = when (val mode = params.addressBookOpenMode) {
|
||||
AddressBookOpenMode.Default -> listOf(AddressBookRoute.List())
|
||||
is AddressBookOpenMode.ContactSelection -> listOf(
|
||||
AddressBookRoute.List(mode = AddressBookRoute.ListMode.Selector(networkId = mode.networkId)),
|
||||
)
|
||||
is AddressBookOpenMode.WithContactCreation -> listOf(
|
||||
AddressBookRoute.List(),
|
||||
// Address + network are already known, so open the new contact with that address attached — no AddAddress.
|
||||
AddressBookRoute.EditContact(
|
||||
predefinedAddress = mode.address,
|
||||
predefinedNetworkId = mode.networkId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressBookComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressBookComponent.Params,
|
||||
): DefaultAddressBookComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
package com.tangem.features.addressbook
|
||||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.addressbook.AddressBookFeatureToggles
|
||||
|
||||
internal class DefaultAddressBookFeatureToggles(
|
||||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.features.addressbook.ContactSelectionListener
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.SelectedContact
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One-shot delivery of a contact picked on the full address-book list back to the Send flow.
|
||||
*
|
||||
* Implements both [ContactSelectionTrigger] (the list emits) and [ContactSelectionListener] (Send collects). The flow
|
||||
* is no-replay with a 1-item buffer so [trigger] is non-blocking ([tryEmit]) — the picker can always close even if the
|
||||
* collector is momentarily absent; nothing is retained for late subscribers, so there is no stale value to clear.
|
||||
*/
|
||||
@Singleton
|
||||
internal class DefaultContactSelectionTrigger @Inject constructor() :
|
||||
ContactSelectionTrigger,
|
||||
ContactSelectionListener {
|
||||
|
||||
private val mutableResultFlow = MutableSharedFlow<SelectedContact>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
override val resultFlow: SharedFlow<SelectedContact> = mutableResultFlow
|
||||
|
||||
override fun trigger(contact: SelectedContact) {
|
||||
mutableResultFlow.tryEmit(contact)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.tangem.features.addressbook.common.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.common.ui.account.AccountIcon
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.ds2.row.*
|
||||
import com.tangem.core.ui.extensions.pluralStringResourceSafe
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
|
||||
@Composable
|
||||
internal fun ContactRow(contact: ContactUM) {
|
||||
TangemRow(
|
||||
onClick = contact.onClick,
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
contentLead = TangemRowContentLead.Start,
|
||||
startSlot = {
|
||||
AccountIcon(
|
||||
name = stringReference(contact.name),
|
||||
icon = contact.icon,
|
||||
size = AccountIconSize.Contact,
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = contact.name,
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = pluralStringResourceSafe(
|
||||
R.plurals.address_book_addresses,
|
||||
contact.networkAddressCount,
|
||||
contact.networkAddressCount,
|
||||
),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_ContactRow() {
|
||||
TangemThemePreviewRedesign {
|
||||
ContactRow(
|
||||
ContactUM(
|
||||
id = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = 1,
|
||||
onClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package com.tangem.features.addressbook.component
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal sealed class AddressBookRoute {
|
||||
|
||||
@Serializable
|
||||
data object List : AddressBookRoute()
|
||||
|
||||
/**
|
||||
* if [contactId] is not null we should fetch existing contact
|
||||
*/
|
||||
@Serializable
|
||||
data class EditContact(
|
||||
val contactId: String? = null,
|
||||
) : AddressBookRoute()
|
||||
|
||||
@Serializable
|
||||
data object AddAddress : AddressBookRoute()
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
package com.tangem.features.addressbook.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.stack.Children
|
||||
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.stack.StackNavigation
|
||||
import com.arkivanov.decompose.router.stack.childStack
|
||||
import com.arkivanov.decompose.router.stack.pop
|
||||
import com.arkivanov.decompose.router.stack.pushNew
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.AddressBookComponent
|
||||
import com.tangem.features.addressbook.addaddress.AddAddressComponent
|
||||
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
import com.tangem.features.addressbook.list.AddressBookListComponent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted private val params: AddressBookComponent.Params,
|
||||
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
|
||||
private val editContactComponentFactory: EditContactComponent.Factory,
|
||||
private val addAddressComponentFactory: AddAddressComponent.Factory,
|
||||
) : AddressBookComponent, AppComponentContext by context {
|
||||
|
||||
private val navigation = StackNavigation<AddressBookRoute>()
|
||||
|
||||
/**
|
||||
* Consumer for the address entered on the [AddressBookRoute.AddAddress] screen, registered by the EditContact
|
||||
* screen when it requests adding an address and invoked when AddAddress confirms. Transient by design — the
|
||||
* entered addresses live only in EditContact's in-memory state until the contact is saved.
|
||||
*/
|
||||
private var pendingAddressSink: ((ValidatedAddress) -> Unit)? = null
|
||||
|
||||
private val contentStack = childStack(
|
||||
key = "address_book_stack",
|
||||
source = navigation,
|
||||
serializer = AddressBookRoute.serializer(),
|
||||
initialConfiguration = AddressBookRoute.List,
|
||||
handleBackButton = false,
|
||||
childFactory = ::screenChild,
|
||||
)
|
||||
|
||||
@Suppress("ReusedModifierInstance")
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val childStack by contentStack.subscribeAsState()
|
||||
|
||||
Children(stack = childStack, animation = stackAnimation()) { child ->
|
||||
child.instance.Content(modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent =
|
||||
when (config) {
|
||||
AddressBookRoute.List -> addressBookListComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = AddressBookListComponent.Params(
|
||||
onContactClick = { contactId ->
|
||||
navigation.pushNew(AddressBookRoute.EditContact(contactId))
|
||||
},
|
||||
onAddContactClick = { navigation.pushNew(AddressBookRoute.EditContact()) },
|
||||
),
|
||||
)
|
||||
is AddressBookRoute.EditContact -> editContactComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = EditContactComponent.Params(
|
||||
contactId = config.contactId?.let(::ContactId),
|
||||
onBackClick = { navigation.pop() },
|
||||
onAddAddressClick = { onResult ->
|
||||
pendingAddressSink = onResult
|
||||
navigation.pushNew(AddressBookRoute.AddAddress)
|
||||
},
|
||||
),
|
||||
)
|
||||
AddressBookRoute.AddAddress -> addAddressComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = AddAddressComponent.Params(
|
||||
onBackClick = {
|
||||
pendingAddressSink = null
|
||||
navigation.pop()
|
||||
},
|
||||
onConfirm = { address ->
|
||||
pendingAddressSink?.invoke(address)
|
||||
pendingAddressSink = null
|
||||
navigation.pop()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressBookComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressBookComponent.Params,
|
||||
): DefaultAddressBookComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
package com.tangem.features.addressbook.di
|
||||
|
||||
import com.tangem.features.addressbook.AddressBookComponent
|
||||
import com.tangem.features.addressbook.addaddress.AddAddressComponent
|
||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||
import com.tangem.features.addressbook.component.DefaultAddressBookComponent
|
||||
import com.tangem.features.addressbook.list.AddressBookListComponent
|
||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
||||
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.ContactSelectionListener
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.addressselector.DefaultAddressSelectorComponent
|
||||
import com.tangem.features.addressbook.block.DefaultAddressBookContactsBlockComponent
|
||||
import com.tangem.features.addressbook.common.DefaultAddressBookComponent
|
||||
import com.tangem.features.addressbook.common.DefaultContactSelectionTrigger
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -24,15 +25,21 @@ internal interface AddressBookComponentModule {
|
|||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindAddressBookListComponentFactory(
|
||||
factory: DefaultAddressBookListComponent.Factory,
|
||||
): AddressBookListComponent.Factory
|
||||
fun bindContactsBlockComponentFactory(
|
||||
factory: DefaultAddressBookContactsBlockComponent.Factory,
|
||||
): AddressBookContactsBlockComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory
|
||||
fun bindAddressSelectorComponentFactory(
|
||||
factory: DefaultAddressSelectorComponent.Factory,
|
||||
): AddressSelectorComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindAddAddressComponentFactory(factory: DefaultAddAddressComponent.Factory): AddAddressComponent.Factory
|
||||
fun bindContactSelectionTrigger(impl: DefaultContactSelectionTrigger): ContactSelectionTrigger
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindContactSelectionListener(impl: DefaultContactSelectionTrigger): ContactSelectionListener
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.tangem.features.addressbook.di
|
|||
import com.tangem.core.decompose.di.ModelComponent
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.addressbook.addaddress.model.AddAddressModel
|
||||
import com.tangem.features.addressbook.block.model.ContactsBlockModel
|
||||
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
||||
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
||||
import dagger.Binds
|
||||
|
|
@ -20,6 +21,11 @@ internal interface AddressBookModelModule {
|
|||
@ClassKey(AddressBookListModel::class)
|
||||
fun bindAddressBookModel(model: AddressBookListModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(ContactsBlockModel::class)
|
||||
fun bindContactsBlockModel(model: ContactsBlockModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(EditContactModel::class)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package com.tangem.features.addressbook.di
|
|||
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.addressbook.AddressBookFeatureToggles
|
||||
import com.tangem.features.addressbook.DefaultAddressBookFeatureToggles
|
||||
import com.tangem.features.addressbook.common.DefaultAddressBookFeatureToggles
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
||||
import com.tangem.features.addressbook.editcontact.ui.EditContactContent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
|
||||
internal class DefaultEditContactComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted params: EditContactComponent.Params,
|
||||
) : EditContactComponent, AppComponentContext by context {
|
||||
internal class DefaultEditContactComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: EditContactModel = getOrCreateModel(params)
|
||||
|
||||
|
|
@ -30,11 +30,10 @@ internal class DefaultEditContactComponent @AssistedInject constructor(
|
|||
BackHandler(onBack = state.onCloseClick)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : EditContactComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: EditContactComponent.Params,
|
||||
): DefaultEditContactComponent
|
||||
}
|
||||
data class Params(
|
||||
val contactId: ContactId?,
|
||||
val predefinedAddress: ValidatedAddress? = null,
|
||||
val onBackClick: () -> Unit,
|
||||
val onAddAddressClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package com.tangem.features.addressbook.editcontact
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
|
||||
internal interface EditContactComponent : ComposableContentComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, EditContactComponent>
|
||||
|
||||
data class Params(
|
||||
val contactId: ContactId?,
|
||||
val onBackClick: () -> Unit,
|
||||
val onAddAddressClick: (onResult: (ValidatedAddress) -> Unit) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package com.tangem.features.addressbook.editcontact.contract
|
||||
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
||||
/**
|
||||
* A recipient address that has been validated and resolved to a [Network] on the AddAddress screen.
|
||||
*
|
||||
* This is the in-progress (pre-save) representation accumulated in [EditContactUM]. It is converted to a domain
|
||||
* `AddressEntry` only when the contact is persisted, since the entry's id and signature are produced at save time.
|
||||
*/
|
||||
data class ValidatedAddress(
|
||||
val address: String,
|
||||
val network: Network,
|
||||
)
|
||||
|
|
@ -1,80 +1,79 @@
|
|||
package com.tangem.features.addressbook.editcontact.model
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
import com.tangem.features.addressbook.common.AddressBookResultHolder
|
||||
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.state.EditContactStateController
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.AddValidatedAddressTransformer
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.SelectContactColorTransformer
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.UpdateContactNameTransformer
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.UpdateEditContactInitialStateTransformer
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class EditContactModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: EditContactStateController,
|
||||
private val resultHolder: AddressBookResultHolder,
|
||||
) : Model() {
|
||||
|
||||
private val params: EditContactComponent.Params = paramsContainer.require()
|
||||
private val params: DefaultEditContactComponent.Params = paramsContainer.require()
|
||||
|
||||
val state: StateFlow<EditContactUM>
|
||||
field = MutableStateFlow(getInitialState())
|
||||
val state: StateFlow<EditContactUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
updateInitialState()
|
||||
prefillPredefinedAddress()
|
||||
subscribeToConfirmedAddresses()
|
||||
}
|
||||
|
||||
/** In WithContactCreation mode the contact opens with the already-known address attached. */
|
||||
private fun prefillPredefinedAddress() {
|
||||
params.predefinedAddress?.let(::addAddress)
|
||||
}
|
||||
|
||||
private fun updateInitialState() {
|
||||
stateController.update(
|
||||
UpdateEditContactInitialStateTransformer(
|
||||
isExistingContact = params.contactId != null,
|
||||
onNameChange = ::onNameChange,
|
||||
onColorSelect = ::onColorSelect,
|
||||
onCloseClick = params.onBackClick,
|
||||
onAddAddressClick = params.onAddAddressClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun subscribeToConfirmedAddresses() {
|
||||
resultHolder.confirmedAddress
|
||||
.filterNotNull()
|
||||
.onEach { address ->
|
||||
addAddress(address)
|
||||
resultHolder.clear()
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun onNameChange(name: String) {
|
||||
state.update { it.copy(name = name) }
|
||||
stateController.update(UpdateContactNameTransformer(name = name))
|
||||
}
|
||||
|
||||
private fun onColorSelect(color: CryptoPortfolioIcon.Color) {
|
||||
state.update { oldState ->
|
||||
oldState.copy(
|
||||
colors = oldState.colors.copy(selected = color),
|
||||
portfolioIcon = oldState.portfolioIcon.copy(color = color),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAddAddress() {
|
||||
params.onAddAddressClick(::addAddress)
|
||||
stateController.update(SelectContactColorTransformer(color = color))
|
||||
}
|
||||
|
||||
private fun addAddress(address: ValidatedAddress) {
|
||||
state.update { it.copy(addresses = (it.addresses + address).toImmutableList()) }
|
||||
}
|
||||
|
||||
private fun getInitialState(): EditContactUM {
|
||||
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
|
||||
val selectedColor = colors.first()
|
||||
val titleResId = if (params.contactId == null) {
|
||||
R.string.address_book_new_contact
|
||||
} else {
|
||||
R.string.address_book_contact
|
||||
}
|
||||
return EditContactUM(
|
||||
title = resourceReference(titleResId),
|
||||
name = "",
|
||||
namePlaceholder = resourceReference(R.string.address_book_new_contact),
|
||||
portfolioIcon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = selectedColor,
|
||||
),
|
||||
colors = EditContactUM.Colors(
|
||||
selected = selectedColor,
|
||||
list = colors,
|
||||
onColorSelect = ::onColorSelect,
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
onNameChange = ::onNameChange,
|
||||
onCloseClick = params.onBackClick,
|
||||
onAddAddressClick = ::requestAddAddress,
|
||||
)
|
||||
stateController.update(AddValidatedAddressTransformer(address = address))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.tangem.features.addressbook.editcontact.state
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class EditContactStateController @Inject constructor() {
|
||||
|
||||
val uiState: StateFlow<EditContactUM>
|
||||
field = MutableStateFlow(value = getInitialState())
|
||||
|
||||
fun update(transformer: Transformer<EditContactUM>) {
|
||||
uiState.update(function = transformer::transform)
|
||||
}
|
||||
|
||||
private fun getInitialState(): EditContactUM {
|
||||
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
|
||||
val selectedColor = colors.first()
|
||||
return EditContactUM(
|
||||
title = TextReference.EMPTY,
|
||||
name = "",
|
||||
namePlaceholder = resourceReference(R.string.address_book_new_contact),
|
||||
portfolioIcon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = selectedColor,
|
||||
),
|
||||
colors = EditContactUM.Colors(
|
||||
selected = selectedColor,
|
||||
list = colors,
|
||||
onColorSelect = {},
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
onNameChange = {},
|
||||
onCloseClick = {},
|
||||
onAddAddressClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal class AddValidatedAddressTransformer(
|
||||
private val address: ValidatedAddress,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
// Skip duplicates: an address is identified by its string value (it already carries all its networks).
|
||||
if (prevState.addresses.any { it.address == address.address }) return prevState
|
||||
return prevState.copy(
|
||||
addresses = (prevState.addresses + address).toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class SelectContactColorTransformer(
|
||||
private val color: CryptoPortfolioIcon.Color,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
return prevState.copy(
|
||||
colors = prevState.colors.copy(selected = color),
|
||||
portfolioIcon = prevState.portfolioIcon.copy(color = color),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class UpdateContactNameTransformer(
|
||||
private val name: String,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
return prevState.copy(name = name)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Wires the title (derived from whether an existing contact is being edited) and the callbacks owned by
|
||||
* [com.tangem.features.addressbook.editcontact.model.EditContactModel] into the initial state.
|
||||
*/
|
||||
internal class UpdateEditContactInitialStateTransformer(
|
||||
private val isExistingContact: Boolean,
|
||||
private val onNameChange: (String) -> Unit,
|
||||
private val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
|
||||
private val onCloseClick: () -> Unit,
|
||||
private val onAddAddressClick: () -> Unit,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
val titleResId = if (isExistingContact) {
|
||||
R.string.address_book_contact
|
||||
} else {
|
||||
R.string.address_book_new_contact
|
||||
}
|
||||
return prevState.copy(
|
||||
title = resourceReference(titleResId),
|
||||
colors = prevState.colors.copy(onColorSelect = onColorSelect),
|
||||
onNameChange = onNameChange,
|
||||
onCloseClick = onCloseClick,
|
||||
onAddAddressClick = onAddAddressClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,16 @@
|
|||
package com.tangem.features.addressbook.editcontact.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
|
|
@ -22,19 +18,27 @@ import com.tangem.common.ui.account.AccountIcon
|
|||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.common.ui.account.getUiColor
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.components.block.BlockCard
|
||||
import com.tangem.core.ui.components.block.TangemBlockCardColors
|
||||
import com.tangem.core.ui.components.fields.AutoSizeTextField
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowText
|
||||
import com.tangem.core.ui.ds2.row.TangemRowTextRole
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
|
@ -63,118 +67,126 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
|
|||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ContactSummary(state = state)
|
||||
ContactColor(colors = state.colors)
|
||||
ContactAddresses(addresses = state.addresses)
|
||||
AddAddressRow(onClick = state.onAddAddressClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) {
|
||||
if (addresses.isEmpty()) return
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
addresses.fastForEach { entry ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
BlockCard(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
Text(
|
||||
text = entry.network.name,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
)
|
||||
Text(
|
||||
text = entry.address,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
maxLines = 1,
|
||||
)
|
||||
ContactAddresses(addresses = state.addresses)
|
||||
AddAddressRow(onClick = state.onAddAddressClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddAddressRow(onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.secondary)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 15.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(TangemTheme.colors3.bg.status.infoSubtle),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(18.dp),
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_plus_24),
|
||||
tint = TangemTheme.colors3.text.status.info,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_add_address),
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_add_address_description),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
)
|
||||
}
|
||||
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) {
|
||||
addresses.fastForEach { entry ->
|
||||
AddressRow(entry = entry)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressRow(entry: ValidatedAddress) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Ident(text = entry.address),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = stringReference(entry.address),
|
||||
role = TangemRowTextRole.Title,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = pluralReference(
|
||||
id = R.plurals.common_networks_count,
|
||||
count = entry.networkIds.size,
|
||||
formatArgs = wrappedList(entry.networkIds.size),
|
||||
),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddAddressRow(onClick: () -> Unit) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
onClick = onClick,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Icon(
|
||||
imageVector = Icons.ic_sign_plus_20,
|
||||
tintReference = { TangemTheme.colors3.icon.brand },
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = TangemTheme.colors3.bg.status.infoSubtle,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
.padding(8.dp),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_add_address_description),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactSummary(state: EditContactUM) {
|
||||
val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
.background(TangemTheme.colors3.bg.secondary)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
SpacerH(20.dp)
|
||||
|
||||
AccountIcon(
|
||||
name = stringReference(avatarName),
|
||||
icon = state.portfolioIcon,
|
||||
size = AccountIconSize.Large,
|
||||
size = AccountIconSize.RedesignLarge,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
SpacerH(28.dp)
|
||||
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_contact_name),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
SpacerH(4.dp)
|
||||
|
||||
AutoSizeTextField(
|
||||
value = state.name,
|
||||
|
|
@ -186,7 +198,7 @@ private fun ContactSummary(state: EditContactUM) {
|
|||
color = TangemTheme.colors3.text.primary,
|
||||
placeholderColor = TangemTheme.colors3.text.tertiary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
SpacerH(8.dp)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,16 +208,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.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
colors.list.fastForEach { color ->
|
||||
val isSelected = color == colors.selected
|
||||
|
|
@ -219,12 +231,12 @@ private fun ContactColor(colors: EditContactUM.Colors) {
|
|||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(47.dp)
|
||||
.size(48.dp)
|
||||
.border(2.dp, color.getUiColor(), shape = CircleShape),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.size(38.dp)
|
||||
.background(color = color.getUiColor(), shape = CircleShape),
|
||||
)
|
||||
} else {
|
||||
|
|
@ -260,7 +272,12 @@ private fun Preview_EditContactContent() {
|
|||
list = colors,
|
||||
onColorSelect = {},
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
addresses = persistentListOf(
|
||||
ValidatedAddress(
|
||||
address = "0x1234567890abcdef1234567890abcdef12345678",
|
||||
networkIds = persistentListOf("ethereum", "bsc", "polygon"),
|
||||
),
|
||||
),
|
||||
onNameChange = {},
|
||||
onCloseClick = {},
|
||||
onAddAddressClick = {},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package com.tangem.features.addressbook.editcontact.contract
|
||||
package com.tangem.features.addressbook.editcontact.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal data class EditContactUM(
|
||||
val title: TextReference,
|
||||
val name: String,
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.tangem.features.addressbook.editcontact.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* A recipient address validated on the AddAddress screen, together with the networks it resolves to.
|
||||
*
|
||||
* A single address can belong to several networks (e.g. the same address across EVM chains), so it carries a list of
|
||||
* [networkIds]. This is the in-progress (pre-save) representation accumulated in [EditContactUM]; the [networkIds] are
|
||||
* used to rebuild the domain `AddressEntry`s when the contact is persisted.
|
||||
*/
|
||||
@Immutable
|
||||
data class ValidatedAddress(
|
||||
val address: String,
|
||||
val networkIds: ImmutableList<String>,
|
||||
)
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package com.tangem.features.addressbook.list
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
|
||||
internal interface AddressBookListComponent : ComposableContentComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, AddressBookListComponent>
|
||||
|
||||
data class Params(
|
||||
val onContactClick: (String) -> Unit,
|
||||
val onAddContactClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,43 +1,77 @@
|
|||
package com.tangem.features.addressbook.list
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.slot.childSlot
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.features.addressbook.list.contract.AddressBookListUM
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
||||
import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import com.tangem.features.addressbook.list.ui.AddressBookListScreen
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
|
||||
internal class DefaultAddressBookListComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted val params: AddressBookListComponent.Params,
|
||||
) : AddressBookListComponent, AppComponentContext by context {
|
||||
internal class DefaultAddressBookListComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
addressSelectorFactory: AddressSelectorComponent.Factory,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: AddressBookListModel = getOrCreateModel(params)
|
||||
|
||||
private val selectorSlot = childSlot(
|
||||
source = model.selectorNavigation,
|
||||
serializer = null,
|
||||
key = "address_selector_slot",
|
||||
handleBackButton = true,
|
||||
childFactory = { contact, componentContext ->
|
||||
addressSelectorFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = AddressSelectorComponent.Params(
|
||||
contact = contact,
|
||||
onAddressSelected = model::deliverSelection,
|
||||
onDismiss = { model.selectorNavigation.dismiss() },
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
val selector by selectorSlot.subscribeAsState()
|
||||
when (val addressBookListUM = state) {
|
||||
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
|
||||
tangemButtonUM = addressBookListUM.tangemButtonUM,
|
||||
onAddContactClick = addressBookListUM.onAddClick,
|
||||
onBackClick = router::pop,
|
||||
modifier = modifier,
|
||||
modifier = modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
is AddressBookListUM.Content -> AddressBookListScreen(
|
||||
state = addressBookListUM,
|
||||
onBackClick = router::pop,
|
||||
modifier = modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
is AddressBookListUM.AddressList -> TODO("[REDACTED_TASK_KEY]")
|
||||
}
|
||||
selector.child?.instance?.BottomSheet()
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressBookListComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressBookListComponent.Params,
|
||||
): DefaultAddressBookListComponent
|
||||
}
|
||||
/**
|
||||
* @property mode Default (management) or Selector (pick a contact for a network)
|
||||
* @property onContactClick management mode — opens the contact editor (TODO [REDACTED_TASK_KEY])
|
||||
* @property onAddContactClick opens the new-contact editor
|
||||
*/
|
||||
data class Params(
|
||||
val mode: AddressBookRoute.ListMode,
|
||||
val onContactClick: (String) -> Unit,
|
||||
val onAddContactClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.tangem.features.addressbook.list.contract
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal sealed class AddressBookListUM {
|
||||
|
||||
data class Empty(
|
||||
val tangemButtonUM: TangemButtonUM,
|
||||
) : AddressBookListUM()
|
||||
data class AddressList(val contacts: ImmutableList<Contact>) : AddressBookListUM()
|
||||
}
|
||||
|
|
@ -1,43 +1,92 @@
|
|||
package com.tangem.features.addressbook.list.model
|
||||
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.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.usecase.GetContactsUseCase
|
||||
import com.tangem.features.addressbook.ContactSelectionTrigger
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.SelectedContact
|
||||
import com.tangem.features.addressbook.common.ContactMatcher
|
||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListInitialStateTransformer
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListSelectionStateTransformer
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Backs the contacts list. The list content is the same however the address book was opened — the open
|
||||
* [AddressBookRoute.ListMode] only decides what tapping a contact does:
|
||||
* - [AddressBookRoute.ListMode.Default]: browse / manage contacts (full UI is TODO [REDACTED_TASK_KEY]).
|
||||
* - [AddressBookRoute.ListMode.Selector]: pick a recipient for the given network — a single matching address is
|
||||
* returned right away, several open the address selector first.
|
||||
*/
|
||||
@ModelScoped
|
||||
internal class AddressBookListModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: AddressBookListStateController,
|
||||
private val router: Router,
|
||||
private val contactSelectionTrigger: ContactSelectionTrigger,
|
||||
private val getContactsUseCase: GetContactsUseCase,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<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>()
|
||||
|
||||
init {
|
||||
when (val mode = params.mode) {
|
||||
// Browse/manage: full list UI is TODO [REDACTED_TASK_KEY].
|
||||
AddressBookRoute.ListMode.Default -> stateController.update(
|
||||
UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick),
|
||||
)
|
||||
// Pick a recipient: same list, the tap returns the chosen address.
|
||||
is AddressBookRoute.ListMode.Selector -> observeSelectionContacts(networkId = mode.networkId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSelectionContacts(networkId: String) {
|
||||
getContactsUseCase(query = "")
|
||||
.onEach { contacts ->
|
||||
stateController.update(
|
||||
UpdateAddressBookListSelectionStateTransformer(
|
||||
matched = ContactMatcher.match(contacts = contacts, networkId = networkId),
|
||||
onAddContactClick = params.onAddContactClick,
|
||||
onContactClick = ::onPickContact,
|
||||
),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun onPickContact(contact: MatchedContact) {
|
||||
val singleEntry = contact.entries.singleOrNull()
|
||||
if (singleEntry != null) {
|
||||
deliverSelection(contact.toSelectedContact(singleEntry))
|
||||
} else {
|
||||
selectorNavigation.activate(contact)
|
||||
}
|
||||
}
|
||||
|
||||
fun deliverSelection(contact: SelectedContact) {
|
||||
contactSelectionTrigger.trigger(contact)
|
||||
selectorNavigation.dismiss()
|
||||
router.pop()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.features.addressbook.list.state
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class AddressBookListStateController @Inject constructor() {
|
||||
|
||||
val uiState: StateFlow<AddressBookListUM>
|
||||
field = MutableStateFlow(value = getInitialState())
|
||||
|
||||
fun update(transformer: Transformer<AddressBookListUM>) {
|
||||
uiState.update(function = transformer::transform)
|
||||
}
|
||||
|
||||
private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {})
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Wires the "add contact" callback owned by the container into the initial (empty) list state.
|
||||
*/
|
||||
internal class UpdateAddressBookListInitialStateTransformer(
|
||||
private val onAddContactClick: () -> Unit,
|
||||
) : Transformer<AddressBookListUM> {
|
||||
|
||||
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
|
||||
return when (prevState) {
|
||||
is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick)
|
||||
is AddressBookListUM.Content -> prevState.copy(
|
||||
contentMode = ContentMode.Default(onAddClick = onAddContactClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.MatchedContact
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* Builds the contacts list from the [matched] contacts. An empty result falls back to [AddressBookListUM.Empty] so the
|
||||
* user can still add a contact.
|
||||
*/
|
||||
internal class UpdateAddressBookListSelectionStateTransformer(
|
||||
private val matched: List<MatchedContact>,
|
||||
private val onAddContactClick: () -> Unit,
|
||||
private val onContactClick: (MatchedContact) -> Unit,
|
||||
) : Transformer<AddressBookListUM> {
|
||||
|
||||
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
|
||||
return if (matched.isEmpty()) {
|
||||
AddressBookListUM.Empty(onAddClick = onAddContactClick)
|
||||
} else {
|
||||
AddressBookListUM.Content(
|
||||
contacts = matched.map { it.toContactUM() }.toImmutableList(),
|
||||
contentMode = ContentMode.Select,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MatchedContact.toContactUM(): ContactUM = ContactUM(
|
||||
id = contactId,
|
||||
name = name,
|
||||
icon = icon,
|
||||
networkAddressCount = entries.size,
|
||||
onClick = { onContactClick(this) },
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.features.addressbook.list.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
|
|
@ -9,26 +8,26 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.button.PrimaryTangemButton
|
||||
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20
|
||||
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
|
||||
|
||||
@Composable
|
||||
internal fun AddressBookEmptyScreen(
|
||||
tangemButtonUM: TangemButtonUM,
|
||||
onAddContactClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -41,26 +40,19 @@ internal fun AddressBookEmptyScreen(
|
|||
title = resourceReference(R.string.address_book_title),
|
||||
startContent = {
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
|
||||
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_20),
|
||||
onClick = onBackClick,
|
||||
size = TangemButton.Size.X11,
|
||||
variant = TangemButton.Variant.Material,
|
||||
)
|
||||
},
|
||||
)
|
||||
NoContactInfo()
|
||||
PrimaryTangemButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
buttonUM = tangemButtonUM,
|
||||
)
|
||||
NoContactInfo(onAddClick = onAddContactClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.NoContactInfo() {
|
||||
private fun ColumnScope.NoContactInfo(onAddClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
|
|
@ -68,18 +60,24 @@ private fun ColumnScope.NoContactInfo() {
|
|||
) {
|
||||
ContactImage()
|
||||
Text(
|
||||
modifier = Modifier.padding(top = TangemTheme.dimens.spacing24),
|
||||
modifier = Modifier.padding(top = 32.dp),
|
||||
text = stringResourceSafe(R.string.address_book_no_contacts),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.heading.medium,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
text = stringResourceSafe(R.string.address_book_no_contacts_description),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
style = TangemTheme.typography3.subheading.medium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
TangemButton(
|
||||
modifier = Modifier.padding(top = 40.dp),
|
||||
text = resourceReference(R.string.address_book_add_address),
|
||||
onClick = onAddClick,
|
||||
iconEnd = TangemIconUM.Icon(imageVector = Icons.ic_sign_plus_20),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +93,7 @@ private fun ContactImage() {
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_contact_20),
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_address_book_24),
|
||||
contentDescription = stringResourceSafe(R.string.address_book_no_contacts),
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
|
|
@ -104,16 +102,11 @@ private fun ContactImage() {
|
|||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun Preview_AddressBookEmptyScreen() {
|
||||
AddressBookEmptyScreen(
|
||||
tangemButtonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_new_contact),
|
||||
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_plus_24),
|
||||
iconPosition = TangemButtonIconPosition.End,
|
||||
type = TangemButtonType.Secondary,
|
||||
onClick = {},
|
||||
),
|
||||
onBackClick = {},
|
||||
)
|
||||
TangemThemePreviewRedesign {
|
||||
AddressBookEmptyScreen(
|
||||
onAddContactClick = {},
|
||||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package com.tangem.features.addressbook.list.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20
|
||||
import com.tangem.core.ui.res.generated.icons.ic_cross_20
|
||||
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.common.ui.ContactRow
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
internal fun AddressBookListScreen(
|
||||
state: AddressBookListUM.Content,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
TangemTopBar(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
title = resourceReference(R.string.address_book_title),
|
||||
startContent = when (state.contentMode) {
|
||||
is ContentMode.Default -> {
|
||||
{
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_20),
|
||||
onClick = onBackClick,
|
||||
size = TangemButton.Size.X11,
|
||||
variant = TangemButton.Variant.Material,
|
||||
)
|
||||
}
|
||||
}
|
||||
ContentMode.Select -> null
|
||||
},
|
||||
endContent = {
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(
|
||||
imageVector = when (state.contentMode) {
|
||||
is ContentMode.Default -> Icons.ic_sign_plus_20
|
||||
ContentMode.Select -> Icons.ic_cross_20
|
||||
},
|
||||
),
|
||||
onClick = when (state.contentMode) {
|
||||
is ContentMode.Default -> state.contentMode.onAddClick
|
||||
ContentMode.Select -> onBackClick
|
||||
},
|
||||
size = TangemButton.Size.X11,
|
||||
variant = TangemButton.Variant.Material,
|
||||
)
|
||||
},
|
||||
)
|
||||
LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
items(items = state.contacts, key = ContactUM::id) { contact ->
|
||||
ContactRow(contact = contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_AddressBookListScreen() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
AddressBookListScreen(
|
||||
state = AddressBookListUM.Content(
|
||||
contacts = persistentListOf(
|
||||
ContactUM(
|
||||
id = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = 1,
|
||||
onClick = {},
|
||||
),
|
||||
ContactUM(
|
||||
id = "2",
|
||||
name = "Alice",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.UFOGreen,
|
||||
),
|
||||
networkAddressCount = 3,
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
contentMode = ContentMode.Default(onAddClick = {}),
|
||||
),
|
||||
onBackClick = {},
|
||||
)
|
||||
|
||||
AddressBookListScreen(
|
||||
state = AddressBookListUM.Content(
|
||||
contacts = persistentListOf(
|
||||
ContactUM(
|
||||
id = "1",
|
||||
name = "Binance",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = 1,
|
||||
onClick = {},
|
||||
),
|
||||
ContactUM(
|
||||
id = "2",
|
||||
name = "Alice",
|
||||
icon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = CryptoPortfolioIcon.Color.UFOGreen,
|
||||
),
|
||||
networkAddressCount = 3,
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
contentMode = ContentMode.Select,
|
||||
),
|
||||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.tangem.features.addressbook.list.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* UI state of the contacts list. The list itself is the same however the address book was opened — it is either
|
||||
* [Empty] or shows [Content]. How the address book was opened (browse vs. pick a recipient) only changes what a
|
||||
* contact tap does, which is captured by [ContactUM.onClick], not by a separate state.
|
||||
*/
|
||||
@Immutable
|
||||
internal sealed interface AddressBookListUM {
|
||||
|
||||
data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
|
||||
|
||||
data class Content(
|
||||
val contacts: ImmutableList<ContactUM>,
|
||||
val contentMode: ContentMode,
|
||||
) : AddressBookListUM
|
||||
}
|
||||
|
||||
@Immutable
|
||||
internal sealed interface ContentMode {
|
||||
|
||||
data class Default(val onAddClick: () -> Unit) : ContentMode
|
||||
|
||||
data object Select : ContentMode
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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 name: String,
|
||||
val icon: AccountIconUM.CryptoPortfolio,
|
||||
val networkAddressCount: Int,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.tangem.features.addressbook.route
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal sealed class AddressBookRoute {
|
||||
|
||||
/**
|
||||
* The contacts list. [mode] mirrors the entry point: [ListMode.Default] for plain browsing/management, and
|
||||
* [ListMode.Selector] when the list is opened to pick a contact for a given network — a tap then returns the
|
||||
* chosen address instead of opening the editor.
|
||||
*/
|
||||
@Serializable
|
||||
data class List(val mode: ListMode = ListMode.Default) : AddressBookRoute()
|
||||
|
||||
/**
|
||||
* if [contactId] is not null we should fetch existing contact.
|
||||
*
|
||||
* [predefinedAddress] and [predefinedNetworkId] are set only when the feature is opened in
|
||||
* [com.tangem.common.routing.entity.AddressBookOpenMode.WithContactCreation] mode — the address and its
|
||||
* network are already known, so the new contact is opened with that address already attached.
|
||||
*/
|
||||
@Serializable
|
||||
data class EditContact(
|
||||
val contactId: String? = null,
|
||||
val predefinedAddress: String? = null,
|
||||
val predefinedNetworkId: String? = null,
|
||||
) : AddressBookRoute()
|
||||
|
||||
@Serializable
|
||||
data object AddAddress : AddressBookRoute()
|
||||
|
||||
/** How the contacts list is shown — agnostic of which feature opened it. */
|
||||
@Serializable
|
||||
sealed interface ListMode {
|
||||
|
||||
@Serializable
|
||||
data object Default : ListMode
|
||||
|
||||
/** Pick a contact that has an address in [networkId]. */
|
||||
@Serializable
|
||||
data class Selector(val networkId: String) : ListMode
|
||||
}
|
||||
}
|
||||
|
|
@ -3,24 +3,23 @@ package com.tangem.features.addressbook.addaddress.model
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.common.ui.extensions.iconResId
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.account.models.AccountList
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.features.addressbook.addaddress.AddAddressComponent
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.test.mock.MockAccounts
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -28,11 +27,7 @@ import kotlinx.coroutines.test.StandardTestDispatcher
|
|||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.api.*
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
|
|
@ -73,7 +68,6 @@ internal class AddAddressModelTest {
|
|||
|
||||
// Assert
|
||||
assertThat(state.addressField.value).isEmpty()
|
||||
assertThat(state.addressField.isValuePasted).isFalse()
|
||||
assertThat(state.buttonUM.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
|
|
@ -87,13 +81,11 @@ internal class AddAddressModelTest {
|
|||
model.state.value.onAddressChange(address)
|
||||
|
||||
// Assert
|
||||
val field = model.state.value.addressField
|
||||
assertThat(field.value).isEqualTo(address)
|
||||
assertThat(field.isValuePasted).isFalse()
|
||||
assertThat(model.state.value.addressField.value).isEqualTo(address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty field WHEN onPasteClick THEN value marked as pasted`() = runTest {
|
||||
fun `GIVEN empty field WHEN onPasteClick THEN value taken from clipboard`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(testScope = this)
|
||||
val address = "0xABC"
|
||||
|
|
@ -103,13 +95,10 @@ internal class AddAddressModelTest {
|
|||
model.state.value.onPasteClick()
|
||||
|
||||
// Assert
|
||||
val field = model.state.value.addressField
|
||||
assertThat(field.value).isEqualTo(address)
|
||||
assertThat(field.isValuePasted).isTrue()
|
||||
assertThat(model.state.value.addressField.value).isEqualTo(address)
|
||||
}
|
||||
|
||||
// validateAndConfirm() is an unimplemented seam — the button click must NOT emit a result yet.
|
||||
// This guards the foundation and will fail (prompting an update) once validation is wired in.
|
||||
@Test
|
||||
fun `GIVEN typed address WHEN button clicked THEN onConfirm not called yet`() = runTest {
|
||||
// Arrange
|
||||
|
|
@ -127,13 +116,12 @@ internal class AddAddressModelTest {
|
|||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class AddressInput {
|
||||
inner class Validation {
|
||||
|
||||
@Test
|
||||
fun `GIVEN coins available WHEN valid address typed THEN matching network chosen`() = runTest {
|
||||
fun `GIVEN coins available WHEN valid address typed THEN no error AND button enabled`() = runTest {
|
||||
// Arrange
|
||||
every { multiAccountListSupplier.invoke() } returns
|
||||
flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
|
|
@ -143,16 +131,14 @@ internal class AddAddressModelTest {
|
|||
|
||||
// Assert
|
||||
val state = model.state.value
|
||||
assertThat(state.availableNetworks).containsExactly(ethereum.network)
|
||||
assertThat(state.chosenNetworkStateUM)
|
||||
.isEqualTo(resultOf(ethereum.network))
|
||||
assertThat(state.addressField.isError).isFalse()
|
||||
assertThat(state.buttonUM.isEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coins available WHEN address matches no network THEN empty state`() = runTest {
|
||||
fun `GIVEN coins available WHEN address matches no network THEN error AND button disabled`() = runTest {
|
||||
// Arrange
|
||||
every { multiAccountListSupplier.invoke() } returns
|
||||
flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
|
|
@ -162,31 +148,32 @@ internal class AddAddressModelTest {
|
|||
|
||||
// Assert
|
||||
val state = model.state.value
|
||||
assertThat(state.availableNetworks).isEmpty()
|
||||
assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
|
||||
assertThat(state.addressField.isError).isTrue()
|
||||
assertThat(state.addressField.label)
|
||||
.isEqualTo(resourceReference(R.string.address_book_invalid_address_error))
|
||||
assertThat(state.buttonUM.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no coins available WHEN valid address typed THEN empty state`() = runTest {
|
||||
// Arrange — supplier emits no accounts.
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
|
||||
fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest {
|
||||
// Arrange
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum)))
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
||||
model.state.value.onAddressChange("")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val state = model.state.value
|
||||
assertThat(state.availableNetworks).isEmpty()
|
||||
assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
|
||||
assertThat(state.addressField.isError).isFalse()
|
||||
assertThat(state.buttonUM.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
// Covers the "not initialized yet" case: the address is typed before coins load, and the
|
||||
// chosen network must resolve reactively once the supplier emits them.
|
||||
// The address is typed before coins load; validity must resolve reactively once the supplier emits them.
|
||||
@Test
|
||||
fun `GIVEN address typed before coins load WHEN coins emitted THEN network resolved reactively`() = runTest {
|
||||
fun `GIVEN address typed before coins load WHEN coins emitted THEN validated reactively`() = runTest {
|
||||
// Arrange
|
||||
val accountsFlow = MutableStateFlow<List<AccountList>>(emptyList())
|
||||
every { multiAccountListSupplier.invoke() } returns accountsFlow
|
||||
|
|
@ -197,29 +184,19 @@ internal class AddAddressModelTest {
|
|||
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
||||
advanceUntilIdle()
|
||||
// Assert intermediate: nothing to match yet
|
||||
assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
|
||||
assertThat(model.state.value.buttonUM.isEnabled).isFalse()
|
||||
|
||||
// Act — coins arrive later
|
||||
accountsFlow.value = listOf(accountListWith(ethereum, bitcoin))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.state.value.chosenNetworkStateUM)
|
||||
.isEqualTo(resultOf(ethereum.network))
|
||||
val state = model.state.value
|
||||
assertThat(state.buttonUM.isEnabled).isTrue()
|
||||
assertThat(state.addressField.isError).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resultOf(vararg networks: Network) = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = networks
|
||||
.map { network ->
|
||||
AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM(
|
||||
networkName = network.name,
|
||||
iconResId = network.iconResId,
|
||||
)
|
||||
}
|
||||
.toImmutableList(),
|
||||
)
|
||||
|
||||
private fun accountListWith(vararg currencies: CryptoCurrency): AccountList {
|
||||
val walletId = MockAccounts.userWalletId
|
||||
val accounts = listOf(
|
||||
|
|
@ -239,7 +216,7 @@ internal class AddAddressModelTest {
|
|||
private fun createModel(
|
||||
testScope: TestScope,
|
||||
onConfirm: (ValidatedAddress) -> Unit = {},
|
||||
params: AddAddressComponent.Params = AddAddressComponent.Params(
|
||||
params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params(
|
||||
onBackClick = {},
|
||||
onConfirm = onConfirm,
|
||||
),
|
||||
|
|
@ -250,6 +227,7 @@ internal class AddAddressModelTest {
|
|||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||
multiAccountListSupplier = multiAccountListSupplier,
|
||||
clipboardManager = clipboardManager,
|
||||
stateController = AddAddressStateController(),
|
||||
).also { model = it }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class ContactMatcherTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN contacts WHEN match THEN keeps only contacts with an address in the network`() {
|
||||
// Arrange
|
||||
val ethContact = contact("Binance", entry("0xAAA", ETHEREUM), entry("Trx", TRON))
|
||||
val tronOnly = contact("Tron Friend", entry("Trx2", TRON))
|
||||
|
||||
// Act
|
||||
val result = ContactMatcher.match(listOf(ethContact, tronOnly), networkId = ETHEREUM)
|
||||
|
||||
// Assert
|
||||
assertThat(result.map { it.name }).containsExactly("Binance")
|
||||
assertThat(result.single().entries.map { it.address }).containsExactly("0xAAA")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no contact in the network WHEN match THEN returns empty`() {
|
||||
// Arrange
|
||||
val tronOnly = contact("Tron Friend", entry("Trx", TRON))
|
||||
|
||||
// Act
|
||||
val result = ContactMatcher.match(listOf(tronOnly), networkId = ETHEREUM)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with multiple addresses in the network WHEN match THEN all those entries are returned`() {
|
||||
// Arrange
|
||||
val exchange = contact("Exchange", entry("0xAAA", ETHEREUM, memo = "1"), entry("0xBBB", ETHEREUM))
|
||||
|
||||
// Act
|
||||
val result = ContactMatcher.match(listOf(exchange), networkId = ETHEREUM)
|
||||
|
||||
// Assert
|
||||
val entries = result.single().entries
|
||||
assertThat(entries.map { it.address }).containsExactly("0xAAA", "0xBBB")
|
||||
assertThat(entries.first { it.address == "0xAAA" }.memo).isEqualTo("1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with stored color WHEN match THEN avatar color is taken from the contact`() {
|
||||
// Arrange
|
||||
val contact = contact("Binance", entry("0xAAA", ETHEREUM), iconColor = "MexicanPink")
|
||||
|
||||
// Act
|
||||
val result = ContactMatcher.match(listOf(contact), networkId = ETHEREUM)
|
||||
|
||||
// Assert
|
||||
assertThat(result.single().icon.color).isEqualTo(CryptoPortfolioIcon.Color.MexicanPink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with unknown color WHEN match THEN avatar color falls back to default`() {
|
||||
// Arrange
|
||||
val contact = contact("Binance", entry("0xAAA", ETHEREUM), iconColor = "not-a-color")
|
||||
|
||||
// Act
|
||||
val result = ContactMatcher.match(listOf(contact), networkId = ETHEREUM)
|
||||
|
||||
// Assert
|
||||
assertThat(result.single().icon.color).isEqualTo(CryptoPortfolioIcon.Color.Azure)
|
||||
}
|
||||
|
||||
private fun contact(name: String, vararg entries: AddressEntry, iconColor: String = "Azure"): Contact = Contact(
|
||||
id = ContactId(name),
|
||||
walletId = UserWalletId(stringValue = "0001"),
|
||||
name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" },
|
||||
icon = "",
|
||||
iconColor = iconColor,
|
||||
createdAt = "2026-06-10T14:30:00.000Z",
|
||||
updatedAt = "2026-06-10T14:30:00.000Z",
|
||||
addressEntries = entries.toList(),
|
||||
)
|
||||
|
||||
private fun entry(address: String, networkId: String, memo: String? = null): AddressEntry = AddressEntry(
|
||||
id = AddressEntryId(address),
|
||||
address = address,
|
||||
networkId = Network.RawID(networkId),
|
||||
memo = memo,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ETHEREUM = "ethereum"
|
||||
const val TRON = "tron"
|
||||
}
|
||||
}
|
||||
|
|
@ -8,23 +8,36 @@ import com.tangem.core.ui.R
|
|||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
import com.tangem.features.addressbook.common.AddressBookResultHolder
|
||||
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.state.EditContactStateController
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class EditContactModelTest {
|
||||
|
||||
private val resultHolder = AddressBookResultHolder()
|
||||
|
||||
private var model: EditContactModel? = null
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
// Cancels modelScope, stopping the confirmed-addresses collector.
|
||||
model?.onDestroy()
|
||||
model = null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN model created THEN initial state is correct`() = runTest {
|
||||
val expectedColors = CryptoPortfolioIcon.Color.entries.toImmutableList()
|
||||
|
|
@ -57,11 +70,7 @@ internal class EditContactModelTest {
|
|||
@Test
|
||||
fun `GIVEN existing contactId WHEN model created THEN title is contact`() = runTest {
|
||||
// Arrange
|
||||
val params = EditContactComponent.Params(
|
||||
contactId = ContactId(value = "contact-id"),
|
||||
onBackClick = {},
|
||||
onAddAddressClick = {},
|
||||
)
|
||||
val params = createParams(contactId = ContactId(value = "contact-id"))
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this, params = params)
|
||||
|
|
@ -94,38 +103,73 @@ internal class EditContactModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN add address requested WHEN result delivered THEN address appended to state`() = runTest {
|
||||
fun `GIVEN confirmed address set on holder WHEN collected THEN address appended to state`() = runTest {
|
||||
// Arrange
|
||||
var capturedSink: ((ValidatedAddress) -> Unit)? = null
|
||||
val params = EditContactComponent.Params(
|
||||
contactId = null,
|
||||
onBackClick = {},
|
||||
onAddAddressClick = { onResult -> capturedSink = onResult },
|
||||
)
|
||||
val model = createModel(testScope = this, params = params)
|
||||
val validatedAddress = ValidatedAddress(address = "0xABC", network = mockk())
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
val validatedAddress = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))
|
||||
|
||||
// Act
|
||||
model.state.value.onAddAddressClick()
|
||||
capturedSink?.invoke(validatedAddress)
|
||||
resultHolder.setConfirmedAddress(validatedAddress)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.state.value.addresses).containsExactly(validatedAddress)
|
||||
// The value must be consumed so it is not re-applied on resubscription.
|
||||
assertThat(resultHolder.confirmedAddress.value).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same address confirmed twice WHEN collected THEN added only once`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
val validatedAddress = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))
|
||||
|
||||
// Act
|
||||
resultHolder.setConfirmedAddress(validatedAddress)
|
||||
advanceUntilIdle()
|
||||
resultHolder.setConfirmedAddress(validatedAddress)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.state.value.addresses).containsExactly(validatedAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN predefined address WHEN model created THEN address attached`() = runTest {
|
||||
// Arrange
|
||||
val predefined = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum"))
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this, params = createParams(predefinedAddress = predefined))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.state.value.addresses).containsExactly(predefined)
|
||||
}
|
||||
|
||||
private fun createParams(
|
||||
contactId: ContactId? = null,
|
||||
predefinedAddress: ValidatedAddress? = null,
|
||||
): DefaultEditContactComponent.Params = DefaultEditContactComponent.Params(
|
||||
contactId = contactId,
|
||||
predefinedAddress = predefinedAddress,
|
||||
onBackClick = {},
|
||||
onAddAddressClick = {},
|
||||
)
|
||||
|
||||
private fun createModel(
|
||||
testScope: TestScope,
|
||||
params: EditContactComponent.Params = EditContactComponent.Params(
|
||||
contactId = null,
|
||||
onBackClick = {},
|
||||
onAddAddressClick = {},
|
||||
),
|
||||
params: DefaultEditContactComponent.Params = createParams(),
|
||||
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
|
||||
): EditContactModel {
|
||||
return EditContactModel(
|
||||
paramsContainer = paramsContainer,
|
||||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
stateController = EditContactStateController(),
|
||||
resultHolder = resultHolder,
|
||||
).also { model = it }
|
||||
}
|
||||
|
||||
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ import com.tangem.core.ui.res.TangemTheme
|
|||
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||
import com.tangem.features.approval.impl.model.GiveApprovalModel
|
||||
import com.tangem.features.approval.impl.ui.GiveApprovalContent
|
||||
import com.tangem.features.send.api.FeeSelectorBlockComponent
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
|
||||
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.params.FeeSelectorParams
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
|
|||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||
import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback
|
||||
import com.tangem.features.send.api.entity.FeeItem
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import com.tangem.core.ui.extensions.*
|
|||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.features.approval.impl.model.GiveApprovalUM
|
||||
import com.tangem.features.send.api.FeeSelectorBlockComponent
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package com.tangem.features.approval.impl.ui
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.tangem.features.send.api.FeeSelectorBlockComponent
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
|
||||
|
||||
internal class PreviewFeeSelectorBlockComponent : FeeSelectorBlockComponent {
|
||||
override fun updateState(feeSelectorUM: FeeSelectorUM) {
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ internal sealed class DetailsItemUM {
|
|||
override val id: String = "wallet_connect"
|
||||
}
|
||||
|
||||
data class WalletConnectAddressBookBlock(val items: List<Item>) : DetailsItemUM() {
|
||||
data class WalletActionBlock(val items: ImmutableList<Item>) : DetailsItemUM() {
|
||||
override val id: String = "wallet_connect_address_book"
|
||||
|
||||
sealed class Item(open val onClick: () -> Unit) {
|
||||
|
||||
data class WalletConnect(override val onClick: () -> Unit) : Item(onClick)
|
||||
data class AddressBook(override val onClick: () -> Unit) : Item(onClick)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ package com.tangem.features.details.ui
|
|||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.scrollable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -27,19 +28,27 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBar
|
|||
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
|
||||
import com.tangem.core.ui.components.block.BlockCard
|
||||
import com.tangem.core.ui.components.block.BlockItem
|
||||
import com.tangem.core.ui.components.inputrow.InputRowImageBase
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowText
|
||||
import com.tangem.core.ui.ds2.row.TangemRowTextRole
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_chevron_right_20
|
||||
import com.tangem.core.ui.test.DetailsScreenTestTags
|
||||
import com.tangem.features.details.component.preview.PreviewDetailsComponent
|
||||
import com.tangem.features.details.entity.DetailsFooterUM
|
||||
import com.tangem.features.details.entity.DetailsItemUM
|
||||
import com.tangem.features.details.entity.DetailsUM
|
||||
import com.tangem.features.details.impl.R
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
internal fun DetailsScreen(
|
||||
|
|
@ -159,9 +168,9 @@ private fun Block(
|
|||
onClick = model.onClick,
|
||||
)
|
||||
}
|
||||
is DetailsItemUM.WalletConnectAddressBookBlock -> {
|
||||
is DetailsItemUM.WalletActionBlock -> {
|
||||
BlockCard {
|
||||
WalletConnectAddressBookBlockItems(
|
||||
WalletActionsBlock(
|
||||
items = model.items,
|
||||
modifier = itemModifier,
|
||||
)
|
||||
|
|
@ -170,37 +179,106 @@ private fun Block(
|
|||
is DetailsItemUM.UserWalletList -> {
|
||||
userWalletListBlockContent.Content(modifier = itemModifier)
|
||||
}
|
||||
is DetailsItemUM.UnderSectionText -> { /* Handled above */
|
||||
}
|
||||
is DetailsItemUM.UnderSectionText -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletConnectAddressBookBlockItems(
|
||||
items: List<DetailsItemUM.WalletConnectAddressBookBlock.Item>,
|
||||
private fun WalletActionsBlock(
|
||||
items: ImmutableList<DetailsItemUM.WalletActionBlock.Item>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
items.fastForEach { item ->
|
||||
when (item) {
|
||||
is DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect -> InputRowImageBase(
|
||||
modifier = modifier.clickable(onClick = item.onClick).padding(12.dp),
|
||||
iconResVector = R.drawable.ic_wallet_connect_24,
|
||||
iconTint = TangemTheme.colors.icon.primary1,
|
||||
subtitle = TextReference.Res(R.string.wallet_connect_title),
|
||||
caption = TextReference.Res(R.string.wallet_connect_subtitle),
|
||||
is DetailsItemUM.WalletActionBlock.Item.WalletConnect -> WalletConnectActionRow(
|
||||
onClick = item.onClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
is DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook -> InputRowImageBase(
|
||||
modifier = modifier.clickable(onClick = item.onClick).padding(12.dp),
|
||||
iconResVector = R.drawable.ic_contact_20,
|
||||
iconTint = TangemTheme.colors.icon.accent,
|
||||
subtitle = TextReference.Res(R.string.address_book_title),
|
||||
caption = TextReference.Res(R.string.address_book_description),
|
||||
is DetailsItemUM.WalletActionBlock.Item.AddressBook -> AddressBookActionRow(
|
||||
onClick = item.onClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletConnectActionRow(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Image(R.drawable.img_wallet_connect_76),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.wallet_connect_title),
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.wallet_connect_subtitle),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
endSlot = { ActionRowChevron() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressBookActionRow(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Icon(
|
||||
iconRes = R.drawable.ic_address_book_24,
|
||||
tintReference = { TangemTheme.colors3.icon.brand },
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = TangemTheme.colors3.bg.status.infoSubtle,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(8.dp),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_title),
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_description),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
endSlot = { ActionRowChevron() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionRowChevron() {
|
||||
Icon(
|
||||
imageVector = Icons.ic_chevron_right_20,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.secondary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnderSectionTextBlock(text: TextReference, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ internal class ItemsBuilder @Inject constructor(
|
|||
onBuyClick: () -> Unit,
|
||||
): ImmutableList<DetailsItemUM> = buildList {
|
||||
if (isAddressBookAvailable) {
|
||||
buildWalletConnectAddressBookBlock(isWalletConnectAvailable, userWalletId)
|
||||
buildWalletActionBlock(isWalletConnectAvailable, userWalletId)
|
||||
} else {
|
||||
buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
|
||||
}
|
||||
|
|
@ -91,29 +91,29 @@ internal class ItemsBuilder @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun MutableList<DetailsItemUM>.buildWalletConnectAddressBookBlock(
|
||||
private fun MutableList<DetailsItemUM>.buildWalletActionBlock(
|
||||
isWalletConnectAvailable: Boolean,
|
||||
userWalletId: UserWalletId,
|
||||
) {
|
||||
val walletConnectAddressBookItems = buildList {
|
||||
val walletActionItems = buildList {
|
||||
if (isWalletConnectAvailable) add(buildWalletConnectButton(userWalletId))
|
||||
add(buildAddressBookButton())
|
||||
}
|
||||
if (walletConnectAddressBookItems.isNotEmpty()) {
|
||||
add(DetailsItemUM.WalletConnectAddressBookBlock(walletConnectAddressBookItems))
|
||||
}.toImmutableList()
|
||||
if (walletActionItems.isNotEmpty()) {
|
||||
add(DetailsItemUM.WalletActionBlock(walletActionItems))
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildWalletConnectButton(
|
||||
userWalletId: UserWalletId,
|
||||
): DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect {
|
||||
return DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect(
|
||||
): DetailsItemUM.WalletActionBlock.Item.WalletConnect {
|
||||
return DetailsItemUM.WalletActionBlock.Item.WalletConnect(
|
||||
onClick = { router.push(AppRoute.WalletConnectSessions(userWalletId)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildAddressBookButton(): DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook {
|
||||
return DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook(
|
||||
private fun buildAddressBookButton(): DetailsItemUM.WalletActionBlock.Item.AddressBook {
|
||||
return DetailsItemUM.WalletActionBlock.Item.AddressBook(
|
||||
onClick = { router.push(AppRoute.AddressBook()) },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,10 +65,10 @@ internal class ItemsBuilderTest {
|
|||
"support",
|
||||
).inOrder()
|
||||
|
||||
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
|
||||
val block = result.first() as DetailsItemUM.WalletActionBlock
|
||||
assertThat(block.items.map { it::class.java }).containsExactly(
|
||||
DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect::class.java,
|
||||
DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java,
|
||||
DetailsItemUM.WalletActionBlock.Item.WalletConnect::class.java,
|
||||
DetailsItemUM.WalletActionBlock.Item.AddressBook::class.java,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +86,9 @@ internal class ItemsBuilderTest {
|
|||
"support",
|
||||
).inOrder()
|
||||
|
||||
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
|
||||
val block = result.first() as DetailsItemUM.WalletActionBlock
|
||||
assertThat(block.items.map { it::class.java }).containsExactly(
|
||||
DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java,
|
||||
DetailsItemUM.WalletActionBlock.Item.AddressBook::class.java,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -109,9 +109,9 @@ internal class ItemsBuilderTest {
|
|||
fun `GIVEN combined block walletConnect item WHEN clicked THEN router pushes WalletConnectSessions`() {
|
||||
// Arrange
|
||||
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true)
|
||||
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
|
||||
val block = result.first() as DetailsItemUM.WalletActionBlock
|
||||
val walletConnect = block.items
|
||||
.filterIsInstance<DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect>()
|
||||
.filterIsInstance<DetailsItemUM.WalletActionBlock.Item.WalletConnect>()
|
||||
.single()
|
||||
|
||||
// Act
|
||||
|
|
@ -125,9 +125,9 @@ internal class ItemsBuilderTest {
|
|||
fun `GIVEN combined block addressBook item WHEN clicked THEN router pushes AddressBook`() {
|
||||
// Arrange
|
||||
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true)
|
||||
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
|
||||
val block = result.first() as DetailsItemUM.WalletActionBlock
|
||||
val addressBook = block.items
|
||||
.filterIsInstance<DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook>()
|
||||
.filterIsInstance<DetailsItemUM.WalletActionBlock.Item.AddressBook>()
|
||||
.single()
|
||||
|
||||
// Act
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.features.home.api
|
||||
|
||||
interface HomeFeatureToggles {
|
||||
|
||||
val isStoriesContainerEnabled: Boolean
|
||||
}
|
||||
|
|
@ -13,59 +13,45 @@ android {
|
|||
dependencies {
|
||||
/** Api */
|
||||
implementation(projects.features.home.api)
|
||||
implementation(projects.features.hotWallet.api)
|
||||
|
||||
/** Core modules */
|
||||
implementation(projects.core.decompose)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.res)
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.analytics.models)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.configToggles)
|
||||
|
||||
/** Common */
|
||||
implementation(projects.common.routing)
|
||||
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.settings)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.feedback)
|
||||
implementation(projects.domain.feedback.models)
|
||||
implementation(projects.domain.referral)
|
||||
|
||||
/** Referral */
|
||||
implementation(projects.features.referral.domain)
|
||||
|
||||
/** AndroidX libraries */
|
||||
implementation(deps.androidx.activity.compose)
|
||||
implementation(deps.lifecycle.runtime.ktx)
|
||||
|
||||
/** Compose libraries */
|
||||
implementation(deps.compose.ui)
|
||||
implementation(deps.compose.ui.tooling)
|
||||
implementation(deps.compose.foundation)
|
||||
implementation(deps.compose.material3)
|
||||
implementation(deps.compose.animation)
|
||||
implementation(deps.compose.coil)
|
||||
implementation(deps.decompose.ext.compose)
|
||||
|
||||
|
||||
/** Tangem libraries */
|
||||
implementation(tangemDeps.card.android)
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.blockchain)
|
||||
|
||||
|
||||
/** Other libraries */
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
|
||||
/** Tests */
|
||||
testImplementation(projects.test.core)
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>BooleanPropertyNaming:HomeButtons.kt$HomeButtonsState$val btnScanStateInProgress: Boolean</ID>
|
||||
<ID>BooleanPropertyNaming:HomeUM.kt$HomeUM$val scanInProgress: Boolean</ID>
|
||||
<ID>MultilineLambdaItParameter:StoriesProgressBar.kt${ when (index) { currentStep -> it.fillMaxWidth(progress.value) in 0 until currentStep -> it.fillMaxWidth(fraction = 1f) else -> it } }</ID>
|
||||
<ID>ReusedModifierInstance:HomeButtonsV2.kt$StoriesButton( modifier = modifier, text = stringResourceSafe(id = R.string.common_get_started), useDarkerColors = false, onClick = onGetStartedClick, )</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.features.home.impl
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.home.api.HomeFeatureToggles
|
||||
|
||||
internal class DefaultHomeFeatureToggles(
|
||||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
) : HomeFeatureToggles {
|
||||
|
||||
override val isStoriesContainerEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15901_STORIES_CONTAINER_ENABLED)
|
||||
}
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
package com.tangem.features.home.impl.di
|
||||
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.core.decompose.di.ModelComponent
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.home.api.HomeComponent
|
||||
import com.tangem.features.home.api.HomeFeatureToggles
|
||||
import com.tangem.features.home.impl.DefaultHomeComponent
|
||||
import com.tangem.features.home.impl.DefaultHomeFeatureToggles
|
||||
import com.tangem.features.home.impl.model.HomeModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ClassKey
|
||||
|
|
@ -22,6 +26,17 @@ internal interface ComponentModule {
|
|||
fun bindComponent(factory: DefaultHomeComponent.Factory): HomeComponent.Factory
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object HomeFeatureTogglesModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideHomeFeatureToggles(featureTogglesManager: FeatureTogglesManager): HomeFeatureToggles {
|
||||
return DefaultHomeFeatureToggles(featureTogglesManager)
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(ModelComponent::class)
|
||||
internal interface ModelModule {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ package com.tangem.features.home.impl.model
|
|||
import com.tangem.common.core.TangemError
|
||||
import com.tangem.common.core.TangemSdkError
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRoute.ManageTokens.Source
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.routing.entity.InitScreenLaunchMode
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
|
|
@ -16,11 +14,9 @@ import com.tangem.core.decompose.model.Model
|
|||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.core.ui.message.dialog.Dialogs
|
||||
import com.tangem.domain.card.ScanCardProcessor
|
||||
import com.tangem.domain.card.analytics.IntroductionProcess
|
||||
import com.tangem.domain.card.analytics.Shop
|
||||
import com.tangem.domain.card.repository.CardSdkConfigRepository
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.common.wallets.error.SaveWalletError
|
||||
|
|
@ -30,10 +26,11 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
|
|||
import com.tangem.domain.settings.usercountry.models.UserCountry
|
||||
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
|
||||
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
|
||||
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
|
||||
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
|
||||
import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase
|
||||
import com.tangem.features.home.api.HomeComponent
|
||||
import com.tangem.features.home.api.HomeFeatureToggles
|
||||
import com.tangem.features.home.impl.ui.state.HomeStoriesConfig
|
||||
import com.tangem.features.home.impl.ui.state.HomeUM
|
||||
import com.tangem.features.home.impl.ui.state.Stories
|
||||
import com.tangem.features.home.impl.ui.state.getRestrictedStories
|
||||
|
|
@ -59,14 +56,12 @@ internal class HomeModel @Inject constructor(
|
|||
private val settingsRepository: SettingsRepository,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val router: Router,
|
||||
private val appRouter: AppRouter,
|
||||
private val getUserCountryUseCase: GetUserCountryUseCase,
|
||||
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory,
|
||||
private val saveWalletUseCase: SaveWalletUseCase,
|
||||
private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase,
|
||||
private val urlOpener: UrlOpener,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase,
|
||||
private val homeFeatureToggles: HomeFeatureToggles,
|
||||
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
|
||||
) : Model() {
|
||||
|
||||
|
|
@ -74,17 +69,8 @@ internal class HomeModel @Inject constructor(
|
|||
|
||||
val params = paramsContainer.require<HomeComponent.Params>()
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
HomeUM(
|
||||
scanInProgress = false,
|
||||
stories = getRestrictedStories().toImmutableList(),
|
||||
onShopClick = ::onShopClick,
|
||||
onSearchTokensClick = ::onSearchTokensClick,
|
||||
onGetStartedClick = ::onGetStartedClick,
|
||||
),
|
||||
)
|
||||
|
||||
val uiState = _uiState.asStateFlow()
|
||||
val uiState: StateFlow<HomeUM>
|
||||
field = MutableStateFlow(createInitialState())
|
||||
|
||||
init {
|
||||
analyticsEventHandler.send(IntroductionProcess.ScreenOpened())
|
||||
|
|
@ -96,6 +82,17 @@ internal class HomeModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun createInitialState(): HomeUM {
|
||||
val initialStories = getRestrictedStories().toImmutableList()
|
||||
return HomeUM(
|
||||
isScanInProgress = false,
|
||||
isStoriesContainerEnabled = homeFeatureToggles.isStoriesContainerEnabled,
|
||||
stories = initialStories,
|
||||
storiesConfig = HomeStoriesConfig(stories = initialStories),
|
||||
onGetStartedClick = ::onGetStartedClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeUserCountryChanges() {
|
||||
getUserCountryUseCase.invoke()
|
||||
.distinctUntilChanged()
|
||||
|
|
@ -114,35 +111,21 @@ internal class HomeModel @Inject constructor(
|
|||
} else {
|
||||
Stories.entries
|
||||
}
|
||||
.toImmutableList()
|
||||
|
||||
_uiState.update {
|
||||
it.copy(stories = stories.toImmutableList())
|
||||
uiState.update {
|
||||
it.copy(stories = stories, storiesConfig = HomeStoriesConfig(stories = stories))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onShopClick() {
|
||||
analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards())
|
||||
analyticsEventHandler.send(Shop.ScreenOpened())
|
||||
modelScope.launch {
|
||||
generateBuyTangemCardLinkUseCase.invoke(null).let { urlOpener.openUrl(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSearchTokensClick() {
|
||||
analyticsEventHandler.send(IntroductionProcess.ButtonTokensList())
|
||||
router.push(AppRoute.ManageTokens(Source.STORIES))
|
||||
}
|
||||
|
||||
private fun onGetStartedClick() {
|
||||
debouncer.debounce(modelScope) {
|
||||
modelScope.launch {
|
||||
val mode = if (shouldShowMobileWalletPromoUseCase()) {
|
||||
AppRoute.CreateWalletStart.Mode.HotWallet
|
||||
} else {
|
||||
AppRoute.CreateWalletStart.Mode.ColdWallet
|
||||
}
|
||||
router.push(AppRoute.CreateWalletStart(mode = mode))
|
||||
val mode = if (shouldShowMobileWalletPromoUseCase()) {
|
||||
AppRoute.CreateWalletStart.Mode.HotWallet
|
||||
} else {
|
||||
AppRoute.CreateWalletStart.Mode.ColdWallet
|
||||
}
|
||||
router.push(AppRoute.CreateWalletStart(mode = mode))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,13 +181,13 @@ internal class HomeModel @Inject constructor(
|
|||
setLoading(false)
|
||||
when (error) {
|
||||
is SaveWalletError.DataError -> TangemLogger.e("Unable to save user wallet: $error")
|
||||
is SaveWalletError.WalletAlreadySaved -> appRouter.replaceAll(AppRoute.Wallet)
|
||||
is SaveWalletError.WalletAlreadySaved -> router.replaceAll(AppRoute.Wallet)
|
||||
}
|
||||
},
|
||||
ifRight = {
|
||||
setLoading(false)
|
||||
sendSignedInCardAnalyticsEvent(scanResponse, userWallet.isImported)
|
||||
appRouter.replaceAll(AppRoute.Wallet)
|
||||
router.replaceAll(AppRoute.Wallet)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -221,7 +204,7 @@ internal class HomeModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun setLoading(isLoading: Boolean) {
|
||||
_uiState.update { it.copy(scanInProgress = isLoading) }
|
||||
uiState.update { it.copy(isScanInProgress = isLoading) }
|
||||
}
|
||||
|
||||
private fun handleScanError(error: TangemError) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier
|
|||
import com.tangem.core.ui.components.SystemBarsIconsDisposable
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.core.ui.utils.ChangeRootBackgroundColorEffect
|
||||
import com.tangem.features.home.impl.ui.compose.HomeStoriesScreen
|
||||
import com.tangem.features.home.impl.ui.compose.StoriesScreenV2
|
||||
import com.tangem.features.home.impl.ui.state.HomeUM
|
||||
|
||||
|
|
@ -12,11 +13,18 @@ import com.tangem.features.home.impl.ui.state.HomeUM
|
|||
internal fun Home(state: HomeUM, modifier: Modifier = Modifier) {
|
||||
SystemBarsIconsDisposable(darkIcons = false)
|
||||
|
||||
StoriesScreenV2(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
onGetStartedClick = state.onGetStartedClick,
|
||||
)
|
||||
if (state.isStoriesContainerEnabled) {
|
||||
HomeStoriesScreen(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
)
|
||||
} else {
|
||||
StoriesScreenV2(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
onGetStartedClick = state.onGetStartedClick,
|
||||
)
|
||||
}
|
||||
|
||||
ChangeRootBackgroundColorEffect(TangemColorPalette.Black)
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.tangem.features.home.impl.ui.compose
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.stories.StoriesContainer
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.test.StoriesScreenTestTags
|
||||
import com.tangem.features.home.impl.ui.compose.content.FirstStoriesContent
|
||||
import com.tangem.features.home.impl.ui.compose.content.StoriesCurrencies
|
||||
import com.tangem.features.home.impl.ui.compose.content.StoriesRevolutionaryWallet
|
||||
import com.tangem.features.home.impl.ui.compose.content.StoriesUltraSecureBackup
|
||||
import com.tangem.features.home.impl.ui.compose.content.StoriesWalletForEveryone
|
||||
import com.tangem.features.home.impl.ui.compose.content.StoriesWeb3
|
||||
import com.tangem.features.home.impl.ui.compose.views.HomeButtonsV2
|
||||
import com.tangem.features.home.impl.ui.state.HomeUM
|
||||
import com.tangem.features.home.impl.ui.state.Stories
|
||||
|
||||
private const val BACKGROUND_COLOR = 0xFF010101L
|
||||
|
||||
/**
|
||||
* Home stories built on the shared [StoriesContainer].
|
||||
* The container provides the progress bar, tap/hold navigation and pause; this screen supplies the
|
||||
* per-story content, the Tangem logo and the persistent "Get Started" button.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HomeStoriesScreen(state: HomeUM, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(BACKGROUND_COLOR))
|
||||
.testTag(StoriesScreenTestTags.SCREEN_CONTAINER),
|
||||
) {
|
||||
StoriesContainer(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
config = state.storiesConfig,
|
||||
isPauseStories = state.isScanInProgress,
|
||||
) { story, isPaused ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_tangem_logo),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillHeight,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = TangemTheme.dimens.spacing16,
|
||||
top = TangemTheme.dimens.spacing16,
|
||||
)
|
||||
.height(TangemTheme.dimens.size18)
|
||||
.align(Alignment.Start),
|
||||
)
|
||||
when (story) {
|
||||
Stories.TangemIntro -> FirstStoriesContent(isPaused = isPaused, duration = story.duration)
|
||||
Stories.RevolutionaryWallet -> StoriesRevolutionaryWallet()
|
||||
Stories.UltraSecureBackup -> StoriesUltraSecureBackup(
|
||||
isPaused = isPaused,
|
||||
stepDuration = story.duration,
|
||||
)
|
||||
Stories.Currencies -> StoriesCurrencies(isPaused, story.duration)
|
||||
Stories.Web3 -> StoriesWeb3(isPaused, story.duration)
|
||||
Stories.WalletForEveryone -> StoriesWalletForEveryone(story.duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = TangemTheme.dimens.spacing16)
|
||||
.padding(horizontal = TangemTheme.dimens.spacing16)
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
|
||||
) {
|
||||
HomeButtonsV2(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onGetStartedClick = state.onGetStartedClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ private const val SCALE_SWITCH_BARRIER = 1.15f
|
|||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
fun HorizontalSlidingImage(
|
||||
internal fun HorizontalSlidingImage(
|
||||
painter: Painter,
|
||||
paused: Boolean,
|
||||
duration: Int,
|
||||
|
|
@ -52,7 +52,7 @@ fun HorizontalSlidingImage(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesTextAnimation(
|
||||
internal fun StoriesTextAnimation(
|
||||
slideInDuration: Int = 500,
|
||||
slideInDelay: Int = 200,
|
||||
slideDistance: Dp = 60.dp,
|
||||
|
|
@ -94,7 +94,7 @@ fun StoriesTextAnimation(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesBottomImageAnimation(
|
||||
internal fun StoriesBottomImageAnimation(
|
||||
firstStepDuration: Int,
|
||||
totalDuration: Int,
|
||||
initialScale: Float = 2.5f,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ import com.tangem.features.home.impl.ui.state.Stories
|
|||
import kotlin.math.max
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.features.home.impl.ui.state.HomeUM
|
||||
import com.tangem.utils.annotations.RemoveWithToggle
|
||||
|
||||
@RemoveWithToggle("AND_15901_STORIES_CONTAINER_ENABLED")
|
||||
@Composable
|
||||
internal fun StoriesScreenV2(state: HomeUM, onGetStartedClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var currentStory by remember { mutableStateOf(state.firstStory) }
|
||||
|
|
@ -61,7 +63,7 @@ internal fun StoriesScreenV2(state: HomeUM, onGetStartedClick: () -> Unit, modif
|
|||
storiesSize = state.stories.lastIndex,
|
||||
currentStoryIndex = currentStoryIndex,
|
||||
currentStory = currentStory,
|
||||
isScanInProgress = state.scanInProgress,
|
||||
isScanInProgress = state.isScanInProgress,
|
||||
onGoToPreviousStory = goToPreviousStory,
|
||||
onGoToNextStory = goToNextStory,
|
||||
onGetStartedClick = onGetStartedClick,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.tangem.features.home.impl.ui.compose.content
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
|
@ -22,33 +21,44 @@ import com.tangem.core.ui.R
|
|||
import com.tangem.core.ui.utils.dpSize
|
||||
import com.tangem.core.ui.utils.toPx
|
||||
|
||||
@Composable
|
||||
fun StoriesCurrenciesContent(paused: Boolean, duration: Int) {
|
||||
val currencyDrawableList = remember {
|
||||
listOf(
|
||||
R.drawable.currency0,
|
||||
R.drawable.currency1,
|
||||
R.drawable.currency2,
|
||||
R.drawable.currency3,
|
||||
R.drawable.currency4,
|
||||
)
|
||||
}
|
||||
private val currencyDrawables = listOf(
|
||||
R.drawable.currency0,
|
||||
R.drawable.currency1,
|
||||
R.drawable.currency2,
|
||||
R.drawable.currency3,
|
||||
R.drawable.currency4,
|
||||
)
|
||||
|
||||
private val web3DappDrawables = listOf(
|
||||
R.drawable.dapps1,
|
||||
R.drawable.dapps1,
|
||||
R.drawable.dapps2,
|
||||
R.drawable.dapps3,
|
||||
R.drawable.dapps4,
|
||||
R.drawable.dapps5,
|
||||
)
|
||||
|
||||
private val currencyDesignItemHeight = 82.dp
|
||||
private val web3DesignItemHeight = 75.dp
|
||||
private val currencyDecreaseRate = 1f / currencyDrawables.size
|
||||
private val web3DecreaseRate = 1f / web3DappDrawables.size
|
||||
private const val WEB3_CHESS_OFFSET_DIVIDER = 3
|
||||
|
||||
@Composable
|
||||
internal fun StoriesCurrenciesContent(paused: Boolean, duration: Int) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val decreaseRate = remember { 1f / currencyDrawableList.size }
|
||||
val designItemHeight = remember { 82.dp }
|
||||
|
||||
BoxWithGradient {
|
||||
Column(modifier = Modifier.graphicsLayer(clip = false)) {
|
||||
currencyDrawableList.forEachIndexed { index, drawableResId ->
|
||||
currencyDrawables.forEachIndexed { index, drawableResId ->
|
||||
val painter = painterResource(id = drawableResId)
|
||||
val scaledItemSize = scaleToDesignSize(painter.dpSize(), designItemHeight = designItemHeight)
|
||||
val scaledItemSize = scaleToDesignSize(painter.dpSize(), designItemHeight = currencyDesignItemHeight)
|
||||
val itemOversizedScreenWidthBy = scaledItemSize.width - screenWidth
|
||||
val moveItemToStartOfScreen = itemOversizedScreenWidthBy / 2
|
||||
|
||||
val chessOffset = if (index.isEven()) 0.dp else scaledItemSize.halfHeight()
|
||||
val animateFrom = chessOffset - moveItemToStartOfScreen
|
||||
val animateTo = 50.dp - 50.dp * index * decreaseRate
|
||||
val animateTo = 50.dp - 50.dp * index * currencyDecreaseRate
|
||||
|
||||
HorizontalSlidingImage(
|
||||
paused = paused,
|
||||
|
|
@ -65,34 +75,21 @@ fun StoriesCurrenciesContent(paused: Boolean, duration: Int) {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
fun StoriesWeb3Content(paused: Boolean, duration: Int) {
|
||||
val dappsItemList = remember {
|
||||
listOf(
|
||||
R.drawable.dapps1,
|
||||
R.drawable.dapps1,
|
||||
R.drawable.dapps2,
|
||||
R.drawable.dapps3,
|
||||
R.drawable.dapps4,
|
||||
R.drawable.dapps5,
|
||||
)
|
||||
}
|
||||
internal fun StoriesWeb3Content(paused: Boolean, duration: Int) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val decreaseRate = remember { 1f / dappsItemList.size }
|
||||
val designItemHeight = 75.dp
|
||||
|
||||
BoxWithGradient {
|
||||
Column(modifier = Modifier.graphicsLayer(clip = false)) {
|
||||
dappsItemList.forEachIndexed { index, drawableResId ->
|
||||
web3DappDrawables.forEachIndexed { index, drawableResId ->
|
||||
val painter = painterResource(id = drawableResId)
|
||||
val scaledItemSize = scaleToDesignSize(painter.dpSize(), designItemHeight = designItemHeight)
|
||||
val scaledItemSize = scaleToDesignSize(painter.dpSize(), designItemHeight = web3DesignItemHeight)
|
||||
val itemOversizedScreenWidthBy = scaledItemSize.width - screenWidth
|
||||
val moveItemToStartOfScreen = itemOversizedScreenWidthBy / 2
|
||||
|
||||
val chessOffset = if (index.isEven()) 0.dp else scaledItemSize.width / 3
|
||||
val chessOffset = if (index.isEven()) 0.dp else scaledItemSize.width / WEB3_CHESS_OFFSET_DIVIDER
|
||||
val animateFrom = chessOffset - moveItemToStartOfScreen
|
||||
val animateTo = 70.dp - 70.dp * index * decreaseRate
|
||||
val animateTo = 70.dp - 70.dp * index * web3DecreaseRate
|
||||
|
||||
HorizontalSlidingImage(
|
||||
paused = paused,
|
||||
|
|
@ -138,6 +135,6 @@ private val BottomGradient: Brush = Brush.verticalGradient(
|
|||
),
|
||||
)
|
||||
|
||||
fun DpSize.halfHeight(): Dp = this.height / 2
|
||||
private fun DpSize.halfHeight(): Dp = this.height / 2
|
||||
|
||||
fun Int.isEven() = this and 1 == 0
|
||||
private fun Int.isEven() = this and 1 == 0
|
||||
|
|
@ -13,7 +13,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
|
@ -21,16 +20,21 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.home.impl.ui.compose.StoriesTextAnimation
|
||||
import com.tangem.core.ui.R
|
||||
|
||||
@Suppress("LongMethod", "ComplexMethod", "MagicNumber")
|
||||
private val firstStoryTitleStyle = TextStyle(
|
||||
fontSize = 46.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun FirstStoriesContent(isPaused: Boolean, duration: Int) {
|
||||
internal fun FirstStoriesContent(isPaused: Boolean, duration: Int) {
|
||||
val progress = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(isPaused) {
|
||||
|
|
@ -47,16 +51,8 @@ fun FirstStoriesContent(isPaused: Boolean, duration: Int) {
|
|||
}
|
||||
}
|
||||
|
||||
val style = TextStyle(
|
||||
fontSize = 46.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
SpacerH(TangemTheme.dimens.spacing94)
|
||||
|
|
@ -67,15 +63,14 @@ fun FirstStoriesContent(isPaused: Boolean, duration: Int) {
|
|||
Text(
|
||||
modifier = modifier,
|
||||
text = stringResourceSafe(R.string.story_meet_title),
|
||||
style = style,
|
||||
style = firstStoryTitleStyle,
|
||||
color = TangemColorPalette.White,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
SpacerH(TangemTheme.dimens.spacing46)
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
painter = painterResource(R.drawable.img_meet_tangem),
|
||||
contentScale = ContentScale.Inside,
|
||||
contentDescription = "Tangem Wallet card",
|
||||
|
|
@ -86,8 +81,5 @@ fun FirstStoriesContent(isPaused: Boolean, duration: Int) {
|
|||
@Preview
|
||||
@Composable
|
||||
private fun FirstStoriesPreview() {
|
||||
FirstStoriesContent(
|
||||
false,
|
||||
8000,
|
||||
)
|
||||
FirstStoriesContent(isPaused = false, duration = 8000)
|
||||
}
|
||||
|
|
@ -1,40 +1,56 @@
|
|||
package com.tangem.features.home.impl.ui.compose.content
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.utils.AnimatedValue
|
||||
import com.tangem.core.ui.utils.asImageBitmap
|
||||
import com.tangem.core.ui.utils.toAnimatable
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Composable
|
||||
fun FloatingCardsContent(isPaused: Boolean, stepDuration: Int) {
|
||||
val imageBitmap = asImageBitmap(R.drawable.img_card_placeholder_wallet_2)
|
||||
val cards = listOf(
|
||||
FloatingCard.first(),
|
||||
FloatingCard.second(),
|
||||
FloatingCard.third(),
|
||||
)
|
||||
|
||||
internal fun FloatingCardsContent(isPaused: Boolean, stepDuration: Int) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
cards.forEach { floatingCard ->
|
||||
FloatingCard.Item(
|
||||
floatingCards.forEach { cardValues ->
|
||||
FloatingCardItem(
|
||||
isPaused = isPaused,
|
||||
imageBitmap = imageBitmap,
|
||||
cardValues = floatingCard,
|
||||
imageRes = R.drawable.img_card_placeholder_wallet_2,
|
||||
cardValues = cardValues,
|
||||
stepDuration = stepDuration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FloatingCardItem(
|
||||
isPaused: Boolean,
|
||||
stepDuration: Int,
|
||||
@DrawableRes imageRes: Int,
|
||||
cardValues: CardValues,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(imageRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.graphicsLayer(
|
||||
translationX = cardValues.translateX.toAnimatable(isPaused, stepDuration).value,
|
||||
translationY = cardValues.translateY.toAnimatable(isPaused, stepDuration).value,
|
||||
rotationX = cardValues.rotationX.toAnimatable(isPaused, stepDuration).value,
|
||||
rotationY = cardValues.rotationY.toAnimatable(isPaused, stepDuration).value,
|
||||
rotationZ = cardValues.rotationZ.toAnimatable(isPaused, stepDuration).value,
|
||||
scaleX = cardValues.scale.toAnimatable(isPaused, stepDuration).value,
|
||||
scaleY = cardValues.scale.toAnimatable(isPaused, stepDuration).value,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private data class CardValues(
|
||||
val translateX: AnimatedValue = AnimatedValue(0f, 0f),
|
||||
val translateY: AnimatedValue = AnimatedValue(0f, 0f),
|
||||
|
|
@ -44,54 +60,30 @@ private data class CardValues(
|
|||
val scale: AnimatedValue = AnimatedValue(1f, 1f),
|
||||
)
|
||||
|
||||
private object FloatingCard {
|
||||
|
||||
@Suppress("TopLevelComposableFunctions")
|
||||
@Composable
|
||||
fun Item(isPaused: Boolean, stepDuration: Int, imageBitmap: ImageBitmap, cardValues: CardValues) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = "Floating Tangem card",
|
||||
modifier = Modifier
|
||||
.graphicsLayer(
|
||||
translationX = cardValues.translateX.toAnimatable(isPaused, stepDuration).value,
|
||||
translationY = cardValues.translateY.toAnimatable(isPaused, stepDuration).value,
|
||||
rotationX = cardValues.rotationX.toAnimatable(isPaused, stepDuration).value,
|
||||
rotationY = cardValues.rotationY.toAnimatable(isPaused, stepDuration).value,
|
||||
rotationZ = cardValues.rotationZ.toAnimatable(isPaused, stepDuration).value,
|
||||
scaleX = cardValues.scale.toAnimatable(isPaused, stepDuration).value,
|
||||
scaleY = cardValues.scale.toAnimatable(isPaused, stepDuration).value,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun first(): CardValues = CardValues(
|
||||
@Suppress("MagicNumber")
|
||||
private val floatingCards = listOf(
|
||||
CardValues(
|
||||
translateX = -400f to -350f,
|
||||
translateY = 30f to 32f,
|
||||
rotationX = 10f to 15f,
|
||||
rotationY = 15f to 15f,
|
||||
rotationZ = 40f to 27f,
|
||||
scale = 0.6f to 0.6f,
|
||||
)
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun second(): CardValues = CardValues(
|
||||
),
|
||||
CardValues(
|
||||
translateX = 350f to 300f,
|
||||
translateY = -70f to 0f,
|
||||
rotationX = 30f to 48f,
|
||||
rotationY = 0f to 5f,
|
||||
rotationZ = -34f to -42f,
|
||||
scale = 0.47f to 0.35f,
|
||||
)
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun third(): CardValues = CardValues(
|
||||
),
|
||||
CardValues(
|
||||
translateX = 320f to 250f,
|
||||
translateY = 500f to 500f,
|
||||
rotationX = 0f to 3f,
|
||||
rotationY = 10f to 10f,
|
||||
rotationZ = -45f to -30f,
|
||||
scale = 0.6f to 0.75f,
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
@ -26,7 +26,7 @@ import com.tangem.features.home.impl.ui.compose.StoriesTextAnimation
|
|||
import com.tangem.core.ui.R
|
||||
|
||||
@Composable
|
||||
fun StoriesRevolutionaryWallet() {
|
||||
internal fun StoriesRevolutionaryWallet() {
|
||||
SplitContent(
|
||||
topContent = {
|
||||
TopContent(
|
||||
|
|
@ -45,7 +45,7 @@ fun StoriesRevolutionaryWallet() {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesUltraSecureBackup(isPaused: Boolean, stepDuration: Int) {
|
||||
internal fun StoriesUltraSecureBackup(isPaused: Boolean, stepDuration: Int) {
|
||||
SplitContent(
|
||||
topContent = {
|
||||
TopContent(
|
||||
|
|
@ -64,7 +64,7 @@ fun StoriesUltraSecureBackup(isPaused: Boolean, stepDuration: Int) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesCurrencies(isPaused: Boolean, stepDuration: Int) {
|
||||
internal fun StoriesCurrencies(isPaused: Boolean, stepDuration: Int) {
|
||||
SplitContent(
|
||||
topContent = {
|
||||
TopContent(
|
||||
|
|
@ -80,7 +80,7 @@ fun StoriesCurrencies(isPaused: Boolean, stepDuration: Int) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesWeb3(isPaused: Boolean, stepDuration: Int) {
|
||||
internal fun StoriesWeb3(isPaused: Boolean, stepDuration: Int) {
|
||||
SplitContent(
|
||||
topContent = {
|
||||
TopContent(
|
||||
|
|
@ -96,7 +96,7 @@ fun StoriesWeb3(isPaused: Boolean, stepDuration: Int) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesWalletForEveryone(stepDuration: Int) {
|
||||
internal fun StoriesWalletForEveryone(stepDuration: Int) {
|
||||
SplitContent(
|
||||
topContent = {
|
||||
TopContent(
|
||||
|
|
@ -127,8 +127,7 @@ fun StoriesWalletForEveryone(stepDuration: Int) {
|
|||
@Composable
|
||||
private fun SplitContent(topContent: @Composable () -> Unit, bottomContent: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
) {
|
||||
|
|
@ -140,16 +139,11 @@ private fun SplitContent(topContent: @Composable () -> Unit, bottomContent: @Com
|
|||
@Composable
|
||||
private fun TopContent(titleText: String, subtitleText: String) {
|
||||
SpacerH(TangemTheme.dimens.spacing36)
|
||||
StoriesTitleText(
|
||||
text = titleText,
|
||||
)
|
||||
StoriesTitleText(text = titleText)
|
||||
SpacerH16()
|
||||
StoriesSubtitleText(
|
||||
subtitleText = subtitleText,
|
||||
)
|
||||
StoriesSubtitleText(subtitleText = subtitleText)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun StoriesTitleText(text: String) {
|
||||
StoriesTextAnimation(
|
||||
|
|
@ -157,8 +151,7 @@ private fun StoriesTitleText(text: String) {
|
|||
slideInDelay = 150,
|
||||
) { modifier ->
|
||||
Text(
|
||||
modifier = modifier
|
||||
.padding(start = 40.dp, end = 40.dp),
|
||||
modifier = modifier.padding(horizontal = 40.dp),
|
||||
text = text,
|
||||
style = TangemTheme.typography.head,
|
||||
color = TangemColorPalette.White,
|
||||
|
|
@ -167,7 +160,6 @@ private fun StoriesTitleText(text: String) {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun StoriesSubtitleText(subtitleText: String) {
|
||||
StoriesTextAnimation(
|
||||
|
|
@ -175,8 +167,7 @@ private fun StoriesSubtitleText(subtitleText: String) {
|
|||
slideInDelay = 400,
|
||||
) { modifier ->
|
||||
Text(
|
||||
modifier = modifier
|
||||
.padding(start = 40.dp, end = 40.dp),
|
||||
modifier = modifier.padding(horizontal = 40.dp),
|
||||
text = subtitleText,
|
||||
style = TangemTheme.typography.body1,
|
||||
color = TangemColorPalette.Dark1,
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
package com.tangem.features.home.impl.ui.compose.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import com.tangem.core.ui.components.SpacerW12
|
||||
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.test.StoriesScreenTestTags
|
||||
import com.tangem.core.ui.R
|
||||
|
||||
@Composable
|
||||
internal fun HomeButtons(
|
||||
btnScanStateInProgress: Boolean,
|
||||
onScanButtonClick: () -> Unit,
|
||||
onShopButtonClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = modifier,
|
||||
) {
|
||||
ScanCardButton(
|
||||
modifier = Modifier
|
||||
.weight(weight = 1f)
|
||||
.testTag(StoriesScreenTestTags.SCAN_BUTTON),
|
||||
showProgress = btnScanStateInProgress,
|
||||
onClick = onScanButtonClick,
|
||||
)
|
||||
SpacerW12()
|
||||
OrderCardButton(
|
||||
modifier = Modifier
|
||||
.weight(weight = 1f)
|
||||
.testTag(StoriesScreenTestTags.ORDER_BUTTON),
|
||||
onClick = onShopButtonClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScanCardButton(showProgress: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
StoriesButton(
|
||||
modifier = modifier,
|
||||
text = stringResourceSafe(id = R.string.home_button_scan),
|
||||
useDarkerColors = false,
|
||||
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24),
|
||||
onClick = onClick,
|
||||
showProgress = showProgress,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OrderCardButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
StoriesButton(
|
||||
modifier = modifier,
|
||||
text = stringResourceSafe(id = R.string.home_button_order),
|
||||
useDarkerColors = true,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun HomeButtonsPreview(@PreviewParameter(HomeButtonsParameterProvider::class) state: HomeButtonsState) {
|
||||
TangemThemePreview {
|
||||
Box(
|
||||
modifier = Modifier.background(Color.Black),
|
||||
) {
|
||||
HomeButtons(
|
||||
btnScanStateInProgress = state.btnScanStateInProgress,
|
||||
onScanButtonClick = {},
|
||||
onShopButtonClick = {},
|
||||
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class HomeButtonsParameterProvider : CollectionPreviewParameterProvider<HomeButtonsState>(
|
||||
collection = listOf(
|
||||
HomeButtonsState(
|
||||
btnScanStateInProgress = false,
|
||||
),
|
||||
HomeButtonsState(
|
||||
btnScanStateInProgress = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private data class HomeButtonsState(
|
||||
val btnScanStateInProgress: Boolean,
|
||||
)
|
||||
// endregion Preview
|
||||
|
|
@ -24,7 +24,7 @@ internal fun HomeButtonsV2(onGetStartedClick: () -> Unit, modifier: Modifier = M
|
|||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StoriesButton(
|
||||
modifier = modifier,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResourceSafe(id = R.string.common_get_started),
|
||||
useDarkerColors = false,
|
||||
onClick = onGetStartedClick,
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
package com.tangem.features.home.impl.ui.compose.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.R
|
||||
|
||||
@Composable
|
||||
internal fun SearchCurrenciesButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
StoriesButton(
|
||||
modifier = modifier,
|
||||
text = stringResourceSafe(id = R.string.common_search_tokens),
|
||||
icon = TangemButtonIconPosition.Start(R.drawable.ic_search_24),
|
||||
showProgress = false,
|
||||
useDarkerColors = true,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun SearchCurrenciesButtonPreview() {
|
||||
TangemThemePreview {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(color = Color.Black)
|
||||
.padding(all = TangemTheme.dimens.spacing16),
|
||||
) {
|
||||
SearchCurrenciesButton(modifier = Modifier.fillMaxWidth(), onClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
// endregion Preview
|
||||
|
|
@ -23,7 +23,7 @@ import kotlinx.coroutines.delay
|
|||
private const val STORIES_ANIMATION_SPEED_ZERO_DURATION = 3000L
|
||||
|
||||
@Composable
|
||||
fun StoriesProgressBar(
|
||||
internal fun StoriesProgressBar(
|
||||
steps: Int,
|
||||
currentStep: Int,
|
||||
paused: Boolean = false,
|
||||
|
|
@ -82,11 +82,11 @@ fun StoriesProgressBar(
|
|||
.clip(RoundedCornerShape(TangemTheme.dimens.radius2))
|
||||
.background(TangemColorPalette.White)
|
||||
.fillMaxHeight()
|
||||
.let {
|
||||
.let { progressModifier ->
|
||||
when (index) {
|
||||
currentStep -> it.fillMaxWidth(progress.value)
|
||||
in 0 until currentStep -> it.fillMaxWidth(fraction = 1f)
|
||||
else -> it
|
||||
currentStep -> progressModifier.fillMaxWidth(progress.value)
|
||||
in 0 until currentStep -> progressModifier.fillMaxWidth(fraction = 1f)
|
||||
else -> progressModifier
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
package com.tangem.features.home.impl.ui.state
|
||||
|
||||
import com.tangem.core.ui.components.stories.model.StoriesContentConfig
|
||||
import com.tangem.core.ui.components.stories.model.StoryConfig
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class HomeUM(
|
||||
val scanInProgress: Boolean,
|
||||
internal data class HomeUM(
|
||||
val isScanInProgress: Boolean,
|
||||
val isStoriesContainerEnabled: Boolean,
|
||||
val stories: ImmutableList<Stories>,
|
||||
val onShopClick: () -> Unit,
|
||||
val onSearchTokensClick: () -> Unit,
|
||||
val storiesConfig: HomeStoriesConfig,
|
||||
val onGetStartedClick: () -> Unit,
|
||||
) {
|
||||
val firstStory: Stories get() = stories[0]
|
||||
|
|
@ -14,7 +16,17 @@ data class HomeUM(
|
|||
fun stepOf(story: Stories): Int = stories.indexOf(story)
|
||||
}
|
||||
|
||||
enum class Stories(val duration: Int = 6000) {
|
||||
/**
|
||||
* Config for the redesigned Home stories ([StoriesContainer]). The Home intro loops forever and is
|
||||
* not closable, so [isCloseButtonVisible] is `false` and [onClose] keeps its no-op default.
|
||||
*/
|
||||
internal data class HomeStoriesConfig(
|
||||
override val stories: ImmutableList<Stories>,
|
||||
override val isRestartable: Boolean = true,
|
||||
override val isCloseButtonVisible: Boolean = false,
|
||||
) : StoriesContentConfig<Stories>
|
||||
|
||||
internal enum class Stories(override val duration: Int = 6000) : StoryConfig {
|
||||
TangemIntro,
|
||||
RevolutionaryWallet,
|
||||
UltraSecureBackup,
|
||||
|
|
@ -26,6 +38,6 @@ enum class Stories(val duration: Int = 6000) {
|
|||
/**
|
||||
* For FCA restriction stories
|
||||
*/
|
||||
fun getRestrictedStories(): List<Stories> {
|
||||
internal fun getRestrictedStories(): List<Stories> {
|
||||
return Stories.entries.filterNot { it == Stories.Currencies }
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
package com.tangem.features.home.impl.model
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.routing.entity.InitScreenLaunchMode
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.domain.card.ScanCardProcessor
|
||||
import com.tangem.domain.card.repository.CardSdkConfigRepository
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
|
||||
import com.tangem.domain.settings.usercountry.models.UserCountry
|
||||
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
|
||||
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
|
||||
import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase
|
||||
import com.tangem.features.home.api.HomeComponent
|
||||
import com.tangem.features.home.api.HomeFeatureToggles
|
||||
import com.tangem.features.home.impl.ui.state.Stories
|
||||
import com.tangem.features.home.impl.ui.state.getRestrictedStories
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class HomeModelTest {
|
||||
|
||||
private val scanCardProcessor: ScanCardProcessor = mockk()
|
||||
private val cardSdkConfigRepository: CardSdkConfigRepository = mockk(relaxed = true)
|
||||
private val settingsRepository: SettingsRepository = mockk()
|
||||
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
private val router: Router = mockk(relaxed = true)
|
||||
private val getUserCountryUseCase: GetUserCountryUseCase = mockk()
|
||||
private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk(relaxed = true)
|
||||
private val saveWalletUseCase: SaveWalletUseCase = mockk(relaxed = true)
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true)
|
||||
private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase = mockk(relaxed = true)
|
||||
private val homeFeatureToggles: HomeFeatureToggles = mockk()
|
||||
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
|
||||
|
||||
private val progressSlot = slot<suspend (Boolean) -> Unit>()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
every { homeFeatureToggles.isStoriesContainerEnabled } returns false
|
||||
every { getUserCountryUseCase.invoke() } returns emptyFlow()
|
||||
coEvery { settingsRepository.shouldSaveAccessCodes() } returns false
|
||||
coEvery {
|
||||
scanCardProcessor.scan(
|
||||
analyticsSource = any(),
|
||||
shouldCheckIsAlreadyActivated = any(),
|
||||
cardId = any(),
|
||||
onProgressStateChange = capture(progressSlot),
|
||||
onWalletNotCreated = any(),
|
||||
onCancel = any(),
|
||||
onFailure = any(),
|
||||
onSuccess = any(),
|
||||
)
|
||||
} just Runs
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle enabled WHEN model created THEN isStoriesContainerEnabled is true`() = runTest {
|
||||
// Arrange
|
||||
every { homeFeatureToggles.isStoriesContainerEnabled } returns true
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isStoriesContainerEnabled).isTrue()
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle disabled WHEN model created THEN isStoriesContainerEnabled is false`() = runTest {
|
||||
// Arrange
|
||||
every { homeFeatureToggles.isStoriesContainerEnabled } returns false
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isStoriesContainerEnabled).isFalse()
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN model created WHEN no country emitted THEN storiesConfig is non-closable looping and in sync`() =
|
||||
runTest {
|
||||
// Act
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val state = model.uiState.value
|
||||
assertThat(state.storiesConfig.isRestartable).isTrue()
|
||||
assertThat(state.storiesConfig.isCloseButtonVisible).isFalse()
|
||||
assertThat(state.storiesConfig.stories).isEqualTo(state.stories)
|
||||
assertThat(state.stories).containsExactlyElementsIn(getRestrictedStories()).inOrder()
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA-restricted country WHEN model created THEN Currencies excluded and config in sync`() = runTest {
|
||||
// Arrange
|
||||
every { getUserCountryUseCase.invoke() } returns flowOf(UserCountry.Other(code = "GB").right())
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val state = model.uiState.value
|
||||
assertThat(state.stories).containsExactlyElementsIn(getRestrictedStories()).inOrder()
|
||||
assertThat(state.stories).doesNotContain(Stories.Currencies)
|
||||
assertThat(state.storiesConfig.stories).isEqualTo(state.stories)
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-restricted country WHEN model created THEN all stories shown and config in sync`() = runTest {
|
||||
// Arrange
|
||||
every { getUserCountryUseCase.invoke() } returns flowOf(UserCountry.Russia.right())
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val state = model.uiState.value
|
||||
assertThat(state.stories).containsExactlyElementsIn(Stories.entries).inOrder()
|
||||
assertThat(state.storiesConfig.stories).isEqualTo(state.stories)
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN scan in progress WHEN loading toggles THEN storiesConfig instance is not replaced`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(testScope = this, launchMode = InitScreenLaunchMode.WithCardScan)
|
||||
advanceUntilIdle()
|
||||
val initialConfig = model.uiState.value.storiesConfig
|
||||
|
||||
// Act + Assert — loading on
|
||||
progressSlot.captured.invoke(true)
|
||||
advanceUntilIdle()
|
||||
assertThat(model.uiState.value.isScanInProgress).isTrue()
|
||||
assertThat(model.uiState.value.storiesConfig).isSameInstanceAs(initialConfig)
|
||||
|
||||
// Act + Assert — loading off
|
||||
progressSlot.captured.invoke(false)
|
||||
advanceUntilIdle()
|
||||
assertThat(model.uiState.value.isScanInProgress).isFalse()
|
||||
assertThat(model.uiState.value.storiesConfig).isSameInstanceAs(initialConfig)
|
||||
|
||||
model.onDestroy()
|
||||
}
|
||||
|
||||
private fun createModel(
|
||||
testScope: TestScope,
|
||||
launchMode: InitScreenLaunchMode = InitScreenLaunchMode.Standard,
|
||||
paramsContainer: ParamsContainer = MutableParamsContainer(
|
||||
value = HomeComponent.Params(launchMode = launchMode),
|
||||
),
|
||||
): HomeModel {
|
||||
return HomeModel(
|
||||
paramsContainer = paramsContainer,
|
||||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||
scanCardProcessor = scanCardProcessor,
|
||||
cardSdkConfigRepository = cardSdkConfigRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
router = router,
|
||||
getUserCountryUseCase = getUserCountryUseCase,
|
||||
coldUserWalletBuilderFactory = coldUserWalletBuilderFactory,
|
||||
saveWalletUseCase = saveWalletUseCase,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
shouldShowMobileWalletPromoUseCase = shouldShowMobileWalletPromoUseCase,
|
||||
homeFeatureToggles = homeFeatureToggles,
|
||||
uiMessageSender = uiMessageSender,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
main = testDispatcher,
|
||||
mainImmediate = testDispatcher,
|
||||
io = testDispatcher,
|
||||
default = testDispatcher,
|
||||
single = testDispatcher,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import com.tangem.core.ui.message.DialogMessage
|
|||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
|
||||
import com.tangem.domain.wallets.models.GetUserWalletError
|
||||
import com.tangem.domain.wallets.models.errors.GetUserWalletError
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase
|
||||
import com.tangem.features.hotwallet.WalletBackupComponent
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
package com.tangem.features.onramp.component
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Swap select tokens component
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface SwapSelectTokensComponent : ComposableContentComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, SwapSelectTokensComponent>
|
||||
|
||||
/**
|
||||
* Params
|
||||
*
|
||||
* @property userWalletId user wallet id
|
||||
*/
|
||||
data class Params(val userWalletId: UserWalletId)
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
package com.tangem.features.onramp.swap
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
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.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.child
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
|
||||
import com.tangem.features.onramp.component.SwapSelectTokensComponent
|
||||
import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent
|
||||
import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute
|
||||
import com.tangem.features.onramp.swap.model.SwapSelectTokensModel
|
||||
import com.tangem.features.onramp.swap.ui.SwapSelectTokens
|
||||
import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent
|
||||
import com.tangem.features.onramp.tokenlist.entity.OnrampOperation
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
@Stable
|
||||
internal class DefaultSwapSelectTokensComponent @AssistedInject constructor(
|
||||
tokenListComponentFactory: OnrampTokenListComponent.Factory,
|
||||
availableSwapPairsComponentFactory: AvailableSwapPairsComponent.Factory,
|
||||
analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
|
||||
@Assisted private val appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: SwapSelectTokensComponent.Params,
|
||||
) : AppComponentContext by appComponentContext, SwapSelectTokensComponent {
|
||||
|
||||
private val model: SwapSelectTokensModel = getOrCreateModel(params)
|
||||
|
||||
private val selectFromTokenListComponent: OnrampTokenListComponent = tokenListComponentFactory.create(
|
||||
context = child(key = "select_from_token_list"),
|
||||
params = OnrampTokenListComponent.Params(
|
||||
filterOperation = OnrampOperation.SWAP,
|
||||
userWalletId = params.userWalletId,
|
||||
onTokenClick = model::selectFromToken,
|
||||
),
|
||||
)
|
||||
|
||||
private val selectToTokenListComponent: AvailableSwapPairsComponent = availableSwapPairsComponentFactory.create(
|
||||
context = child(key = "select_to_token_list"),
|
||||
params = AvailableSwapPairsComponent.Params(
|
||||
userWalletId = params.userWalletId,
|
||||
selectedStatus = model.fromCurrencyStatus,
|
||||
onTokenClick = model::selectToToken,
|
||||
),
|
||||
)
|
||||
|
||||
private val bottomSheetSlot = childSlot(
|
||||
source = selectToTokenListComponent.bottomSheetNavigation,
|
||||
serializer = AddToPortfolioRoute.serializer(),
|
||||
key = "add_to_portfolio_bottom_sheet",
|
||||
handleBackButton = false,
|
||||
childFactory = { _, context -> bottomSheetChild(context) },
|
||||
)
|
||||
|
||||
init {
|
||||
analyticsEventHandler.send(event = MainScreenAnalyticsEvent.SwapScreenOpened())
|
||||
}
|
||||
|
||||
@Suppress("UnsafeCallOnNullableType")
|
||||
private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent {
|
||||
return addToPortfolioComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = AddToPortfolioComponent.Params(
|
||||
addToPortfolioManager = selectToTokenListComponent.addToPortfolioManager,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
val fromTokensState by selectFromTokenListComponent.uiState.collectAsStateWithLifecycle()
|
||||
val toTokensState by selectToTokenListComponent.uiState.collectAsStateWithLifecycle()
|
||||
val bottomSheet by bottomSheetSlot.subscribeAsState()
|
||||
|
||||
SwapSelectTokens(
|
||||
state = state,
|
||||
selectFromTokenListComponent = selectFromTokenListComponent,
|
||||
selectFromTokenListState = fromTokensState,
|
||||
selectToTokenListComponent = selectToTokenListComponent,
|
||||
selectToTokenListState = toTokensState,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
bottomSheet.child?.instance?.BottomSheet()
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : SwapSelectTokensComponent.Factory {
|
||||
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: SwapSelectTokensComponent.Params,
|
||||
): DefaultSwapSelectTokensComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
package com.tangem.features.onramp.swap.availablepairs
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState
|
||||
import com.tangem.core.ui.decompose.ComposableListContentComponent
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
|
||||
import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute
|
||||
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** Token list component that present list of available tokens for swap */
|
||||
@Stable
|
||||
internal interface AvailableSwapPairsComponent : ComposableListContentComponent<TokenListUM> {
|
||||
|
||||
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute>
|
||||
val addToPortfolioManager: AddToPortfolioManager
|
||||
|
||||
/** Component factory */
|
||||
interface Factory : ComponentFactory<Params, AvailableSwapPairsComponent>
|
||||
|
||||
/**
|
||||
* Params
|
||||
*
|
||||
* @property userWalletId id of multi-currency wallet
|
||||
* @property selectedStatus flow of selected status
|
||||
* @property onTokenClick callback for token click
|
||||
*/
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val selectedStatus: StateFlow<CryptoCurrencyStatus?>,
|
||||
val onTokenClick: (TokenItemState, CryptoCurrencyStatus) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
package com.tangem.features.onramp.swap.availablepairs
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
|
||||
import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute
|
||||
import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel
|
||||
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
|
||||
import com.tangem.features.onramp.tokenlist.ui.onrampSwapTokenList
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Stable
|
||||
internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted params: AvailableSwapPairsComponent.Params,
|
||||
) : AvailableSwapPairsComponent, AppComponentContext by context {
|
||||
|
||||
private val model: AvailableSwapPairsModel = getOrCreateModel(params)
|
||||
|
||||
override val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> get() = model.bottomSheetNavigation
|
||||
override val addToPortfolioManager: AddToPortfolioManager get() = model.addToPortfolioManager
|
||||
|
||||
override val uiState: StateFlow<TokenListUM>
|
||||
get() = model.state
|
||||
|
||||
override fun LazyListScope.content(uiState: TokenListUM, modifier: Modifier) {
|
||||
onrampSwapTokenList(state = uiState)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AvailableSwapPairsComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AvailableSwapPairsComponent.Params,
|
||||
): DefaultAvailableSwapPairsComponent
|
||||
}
|
||||
}
|
||||
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