Updated on 2026-08-14
This commit is contained in:
parent
52d1ed4312
commit
e29792fa97
22 changed files with 1198 additions and 5 deletions
|
|
@ -112,6 +112,7 @@ dependencies {
|
||||||
implementation(projects.libs.blockchainSdk)
|
implementation(projects.libs.blockchainSdk)
|
||||||
implementation(projects.domain.account)
|
implementation(projects.domain.account)
|
||||||
implementation(projects.domain.account.status)
|
implementation(projects.domain.account.status)
|
||||||
|
implementation(projects.domain.addressBook)
|
||||||
implementation(projects.domain.models)
|
implementation(projects.domain.models)
|
||||||
implementation(projects.domain.core)
|
implementation(projects.domain.core)
|
||||||
api(projects.domain.common)
|
api(projects.domain.common)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.tangem.tap.di.domain
|
||||||
|
|
||||||
|
import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
|
||||||
|
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
|
||||||
|
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AddressBookDomainModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideValidateContactAddressUseCase(
|
||||||
|
validateWalletAddressUseCase: ValidateWalletAddressUseCase,
|
||||||
|
getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
|
||||||
|
): ValidateContactAddressUseCase {
|
||||||
|
return ValidateContactAddressUseCase(
|
||||||
|
validateWalletAddressUseCase = validateWalletAddressUseCase,
|
||||||
|
getNetworkAddressesUseCase = getNetworkAddressesUseCase,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,9 @@ dependencies {
|
||||||
implementation(projects.features.addressBook.api)
|
implementation(projects.features.addressBook.api)
|
||||||
|
|
||||||
/** Domain */
|
/** Domain */
|
||||||
implementation(projects.domain.models)
|
implementation(projects.domain.account)
|
||||||
implementation(projects.domain.addressBook)
|
implementation(projects.domain.addressBook)
|
||||||
|
implementation(projects.domain.models)
|
||||||
|
|
||||||
/** Common */
|
/** Common */
|
||||||
implementation(projects.common.ui)
|
implementation(projects.common.ui)
|
||||||
|
|
@ -45,6 +46,12 @@ dependencies {
|
||||||
/** Other */
|
/** Other */
|
||||||
implementation(deps.kotlin.immutable.collections)
|
implementation(deps.kotlin.immutable.collections)
|
||||||
|
|
||||||
|
/** Utils */
|
||||||
|
implementation(projects.libs.blockchainSdk)
|
||||||
|
implementation(tangemDeps.blockchain)
|
||||||
|
|
||||||
/** Tests */
|
/** Tests */
|
||||||
testImplementation(projects.test.core)
|
testImplementation(projects.test.core)
|
||||||
|
testImplementation(projects.test.mock)
|
||||||
|
testImplementation(projects.common.test)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package com.tangem.features.addressbook.addaddress
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.tangem.core.decompose.context.AppComponentContext
|
||||||
|
import com.tangem.core.decompose.model.getOrCreateModel
|
||||||
|
import com.tangem.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
|
||||||
|
|
||||||
|
internal class DefaultAddAddressComponent @AssistedInject constructor(
|
||||||
|
@Assisted context: AppComponentContext,
|
||||||
|
@Assisted params: AddAddressComponent.Params,
|
||||||
|
) : AddAddressComponent, AppComponentContext by context {
|
||||||
|
|
||||||
|
private val model: AddAddressModel = getOrCreateModel(params)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content(modifier: Modifier) {
|
||||||
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
|
AddAddressContent(
|
||||||
|
state = state,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
BackHandler(onBack = state.onBackClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory : AddAddressComponent.Factory {
|
||||||
|
override fun create(
|
||||||
|
context: AppComponentContext,
|
||||||
|
params: AddAddressComponent.Params,
|
||||||
|
): DefaultAddAddressComponent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.tangem.features.addressbook.addaddress.contract
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||||
|
import com.tangem.domain.models.network.Network
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
internal data class AddAddressUM(
|
||||||
|
val addressField: AddressFieldUM,
|
||||||
|
val availableNetworks: ImmutableList<Network>,
|
||||||
|
val buttonUM: TangemButtonUM,
|
||||||
|
val chosenNetworkState: ChosenNetworkState,
|
||||||
|
val onAddressChange: (String) -> Unit,
|
||||||
|
val onAddressClear: () -> Unit,
|
||||||
|
val onPasteClick: () -> Unit,
|
||||||
|
val onQrClick: () -> Unit,
|
||||||
|
val onBackClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
sealed class ChosenNetworkState {
|
||||||
|
data object Loading : ChosenNetworkState()
|
||||||
|
data object Empty : ChosenNetworkState()
|
||||||
|
data class Result(
|
||||||
|
val networkUMList: ImmutableList<NetworkUM>,
|
||||||
|
) : ChosenNetworkState() {
|
||||||
|
|
||||||
|
data class NetworkUM(
|
||||||
|
val networkName: String,
|
||||||
|
@DrawableRes val iconResId: Int,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.tangem.features.addressbook.addaddress.contract
|
||||||
|
|
||||||
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
|
|
||||||
|
internal data class AddressFieldUM(
|
||||||
|
val value: String,
|
||||||
|
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,152 @@
|
||||||
|
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.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.addaddress.contract.AddressFieldUM
|
||||||
|
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
|
||||||
|
internal class AddAddressModel @Inject constructor(
|
||||||
|
paramsContainer: ParamsContainer,
|
||||||
|
override val dispatchers: CoroutineDispatcherProvider,
|
||||||
|
multiAccountListSupplier: MultiAccountListSupplier,
|
||||||
|
private val clipboardManager: ClipboardManager,
|
||||||
|
) : Model() {
|
||||||
|
|
||||||
|
private val params: AddAddressComponent.Params = paramsContainer.require()
|
||||||
|
|
||||||
|
val state: StateFlow<AddAddressUM>
|
||||||
|
field = MutableStateFlow(getInitialState())
|
||||||
|
|
||||||
|
private val availableCoins: StateFlow<List<CryptoCurrency.Coin>> = multiAccountListSupplier()
|
||||||
|
.map { accountLists ->
|
||||||
|
accountLists
|
||||||
|
.flatMap { it.accounts }
|
||||||
|
.filterIsInstance<Account.CryptoPortfolio>()
|
||||||
|
.flatMap { it.cryptoCurrencies }
|
||||||
|
.filterIsInstance<CryptoCurrency.Coin>()
|
||||||
|
.distinctBy { it.network.id }
|
||||||
|
}
|
||||||
|
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
private val addressInput = state
|
||||||
|
.map { it.addressField.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.debounce(ADD_ADDRESS_DEBOUNCE)
|
||||||
|
|
||||||
|
init {
|
||||||
|
subscribeToAddressInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
chosenNetworkState = AddAddressUM.ChosenNetworkState.Loading,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun subscribeToAddressInput() {
|
||||||
|
combine(addressInput, availableCoins) { input, coins ->
|
||||||
|
getUniqueNetworks(input, coins)
|
||||||
|
}
|
||||||
|
.onEach { availableNetworks ->
|
||||||
|
state.update { oldState ->
|
||||||
|
oldState.copy(
|
||||||
|
availableNetworks = availableNetworks,
|
||||||
|
chosenNetworkState = createChosenNetworkState(availableNetworks),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(modelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChosenNetworkState(availableNetworks: ImmutableList<Network>): AddAddressUM.ChosenNetworkState {
|
||||||
|
return if (availableNetworks.isEmpty()) {
|
||||||
|
AddAddressUM.ChosenNetworkState.Empty
|
||||||
|
} else {
|
||||||
|
AddAddressUM.ChosenNetworkState.Result(
|
||||||
|
networkUMList = availableNetworks
|
||||||
|
.map { network ->
|
||||||
|
AddAddressUM.ChosenNetworkState.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateAndConfirm() {
|
||||||
|
// TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
chosenNetworkState = AddAddressUM.ChosenNetworkState.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,103 @@
|
||||||
|
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.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tangem.core.ui.R
|
||||||
|
import com.tangem.core.ui.components.SpacerH12
|
||||||
|
import com.tangem.core.ui.ds.button.PrimaryTangemButton
|
||||||
|
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.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
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color = TangemTheme.colors3.bg.primary)
|
||||||
|
.systemBarsPadding(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
TangemTopBar(
|
||||||
|
modifier = Modifier.statusBarsPadding(),
|
||||||
|
title = resourceReference(R.string.address_book_add_address),
|
||||||
|
startContent = {
|
||||||
|
TangemButton(
|
||||||
|
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
|
||||||
|
onClick = state.onBackClick,
|
||||||
|
size = TangemButton.Size.X11,
|
||||||
|
variant = TangemButton.Variant.Material,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
RecipientRow(
|
||||||
|
addressField = state.addressField,
|
||||||
|
onValueChange = state.onAddressChange,
|
||||||
|
onAddressClear = state.onAddressClear,
|
||||||
|
onQrClick = state.onQrClick,
|
||||||
|
onPasteClick = state.onPasteClick,
|
||||||
|
)
|
||||||
|
SpacerH12()
|
||||||
|
NetworkBlock(state.chosenNetworkState)
|
||||||
|
PrimaryButton(state.buttonUM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
PrimaryTangemButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||||
|
buttonUM = buttonUM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true, widthDp = 360)
|
||||||
|
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
private fun Preview_AddAddressContent() {
|
||||||
|
TangemThemePreviewRedesign {
|
||||||
|
AddAddressContent(
|
||||||
|
state = AddAddressUM(
|
||||||
|
addressField = AddressFieldUM(
|
||||||
|
value = "",
|
||||||
|
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,
|
||||||
|
isEnabled = false,
|
||||||
|
onClick = { },
|
||||||
|
),
|
||||||
|
chosenNetworkState = AddAddressUM.ChosenNetworkState.Empty,
|
||||||
|
onAddressChange = {},
|
||||||
|
onAddressClear = {},
|
||||||
|
onPasteClick = {},
|
||||||
|
onQrClick = {},
|
||||||
|
onBackClick = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
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.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
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.ds2.loader.TangemLoader
|
||||||
|
import com.tangem.core.ui.ds2.row.TangemRow
|
||||||
|
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||||
|
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||||
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
|
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||||
|
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
|
||||||
|
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM.ChosenNetworkState.Result.NetworkUM
|
||||||
|
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(chosenNetworkState: AddAddressUM.ChosenNetworkState) {
|
||||||
|
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),
|
||||||
|
titleSlot = {
|
||||||
|
Text(
|
||||||
|
text = stringResourceSafe(R.string.common_network),
|
||||||
|
style = TangemTheme.typography.body2,
|
||||||
|
color = TangemTheme.colors3.text.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
endSlot = {
|
||||||
|
SelectNetworkButton(chosenNetworkState)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectNetworkButton(chosenNetworkState: AddAddressUM.ChosenNetworkState) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
when (chosenNetworkState) {
|
||||||
|
is AddAddressUM.ChosenNetworkState.Result -> NetworkIconsResolver(chosenNetworkState.networkUMList)
|
||||||
|
AddAddressUM.ChosenNetworkState.Loading -> TangemLoader()
|
||||||
|
AddAddressUM.ChosenNetworkState.Empty -> {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
text = stringResourceSafe(R.string.address_book_select_network),
|
||||||
|
style = TangemTheme.typography.body2,
|
||||||
|
color = TangemTheme.colors3.text.secondary,
|
||||||
|
)
|
||||||
|
ChevronIcon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
|
||||||
|
when (networks.size) {
|
||||||
|
0 -> Unit
|
||||||
|
1 -> {
|
||||||
|
val network = networks.first()
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = network.iconResId),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
text = network.networkName,
|
||||||
|
style = TangemTheme.typography.body2,
|
||||||
|
color = TangemTheme.colors3.text.secondary,
|
||||||
|
)
|
||||||
|
ChevronIcon()
|
||||||
|
}
|
||||||
|
// 3 and any larger count share the same rendering: up to MAX_VISIBLE_NETWORKS overlapping
|
||||||
|
// icons, plus a "+N" badge that appears only when there are more than that.
|
||||||
|
else -> {
|
||||||
|
OverlappingNetworkIcons(networks)
|
||||||
|
ChevronIcon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
|
||||||
|
val visible = networks.take(MAX_VISIBLE_NETWORKS)
|
||||||
|
val remaining = networks.size - visible.size
|
||||||
|
|
||||||
|
Box(modifier = Modifier.wrapContentWidth()) {
|
||||||
|
visible.forEachIndexed { index, network ->
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = network.iconResId),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = NetworkIconStep * index)
|
||||||
|
.networkIconRing()
|
||||||
|
.size(NetworkIconSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (remaining > 0) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = NetworkIconStep * visible.size)
|
||||||
|
.networkIconRing()
|
||||||
|
.background(color = TangemTheme.colors3.bg.tertiary)
|
||||||
|
.size(NetworkIconSize),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "+$remaining",
|
||||||
|
style = TangemTheme.typography.caption1,
|
||||||
|
color = TangemTheme.colors3.text.secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bg-colored ring + clip applied to every overlapping element so the one drawn on top carves a
|
||||||
|
// crescent out of the one below it. The ring color must match the surface the icons sit on.
|
||||||
|
@Composable
|
||||||
|
private fun Modifier.networkIconRing(): Modifier = this
|
||||||
|
.border(width = 2.dp, color = TangemTheme.colors3.bg.secondary, shape = CircleShape)
|
||||||
|
.padding(2.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChevronIcon() {
|
||||||
|
Image(
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
painter = painterResource(id = R.drawable.ic_select_18_24),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
private fun Preview_NetworkBlock() {
|
||||||
|
TangemThemePreviewRedesign {
|
||||||
|
Column {
|
||||||
|
NetworkBlock(
|
||||||
|
chosenNetworkState = AddAddressUM.ChosenNetworkState.Result(
|
||||||
|
networkUMList = persistentListOf(
|
||||||
|
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SpacerH12()
|
||||||
|
NetworkBlock(
|
||||||
|
chosenNetworkState = AddAddressUM.ChosenNetworkState.Result(
|
||||||
|
networkUMList = persistentListOf(
|
||||||
|
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
||||||
|
NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22),
|
||||||
|
NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SpacerH12()
|
||||||
|
NetworkBlock(
|
||||||
|
chosenNetworkState = AddAddressUM.ChosenNetworkState.Result(
|
||||||
|
networkUMList = List(15) {
|
||||||
|
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
|
||||||
|
}.toImmutableList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SpacerH12()
|
||||||
|
NetworkBlock(chosenNetworkState = AddAddressUM.ChosenNetworkState.Loading)
|
||||||
|
SpacerH12()
|
||||||
|
NetworkBlock(chosenNetworkState = AddAddressUM.ChosenNetworkState.Empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
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.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tangem.core.ui.R
|
||||||
|
import com.tangem.core.ui.components.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
|
||||||
|
import com.tangem.core.ui.ds2.button.TangemButton
|
||||||
|
import com.tangem.core.ui.ds2.row.TangemRow
|
||||||
|
import com.tangem.core.ui.ds2.row.TangemRowContentLead
|
||||||
|
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||||
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
|
import com.tangem.core.ui.extensions.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
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RecipientRow(
|
||||||
|
addressField: AddressFieldUM,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
onAddressClear: () -> Unit,
|
||||||
|
onQrClick: () -> Unit,
|
||||||
|
onPasteClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.clip(RoundedCornerShape(16.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,
|
||||||
|
)
|
||||||
|
TangemRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||||
|
contentLead = TangemRowContentLead.Start,
|
||||||
|
startSlot = {
|
||||||
|
TangemIcon(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(TangemTheme.colors3.bg.tertiary),
|
||||||
|
tangemIconUM = TangemIconUM.Ident(text = addressField.value),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
titleSlot = {
|
||||||
|
SimpleTextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = 12.dp),
|
||||||
|
value = addressField.value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
placeholder = TextReference.Res(R.string.address_book_enter_address),
|
||||||
|
singleLine = false,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
private fun Preview_RecipientRow() {
|
||||||
|
TangemThemePreviewRedesign {
|
||||||
|
Column {
|
||||||
|
RecipientRow(
|
||||||
|
addressField = AddressFieldUM(
|
||||||
|
value = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359",
|
||||||
|
placeholder = resourceReference(R.string.address_book_enter_address),
|
||||||
|
label = resourceReference(R.string.common_address),
|
||||||
|
),
|
||||||
|
onValueChange = {},
|
||||||
|
onAddressClear = {},
|
||||||
|
onQrClick = {},
|
||||||
|
onPasteClick = {},
|
||||||
|
)
|
||||||
|
SpacerH12()
|
||||||
|
RecipientRow(
|
||||||
|
addressField = AddressFieldUM(
|
||||||
|
value = "",
|
||||||
|
placeholder = resourceReference(R.string.address_book_enter_address),
|
||||||
|
label = resourceReference(R.string.common_address),
|
||||||
|
),
|
||||||
|
onValueChange = {},
|
||||||
|
onAddressClear = {},
|
||||||
|
onQrClick = {},
|
||||||
|
onPasteClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,4 +15,7 @@ internal sealed class AddressBookRoute {
|
||||||
data class EditContact(
|
data class EditContact(
|
||||||
val contactId: String? = null,
|
val contactId: String? = null,
|
||||||
) : AddressBookRoute()
|
) : AddressBookRoute()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object AddAddress : AddressBookRoute()
|
||||||
}
|
}
|
||||||
|
|
@ -16,8 +16,10 @@ import com.tangem.core.decompose.context.childByContext
|
||||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||||
import com.tangem.domain.addressbook.model.ContactId
|
import com.tangem.domain.addressbook.model.ContactId
|
||||||
import com.tangem.features.addressbook.AddressBookComponent
|
import com.tangem.features.addressbook.AddressBookComponent
|
||||||
import com.tangem.features.addressbook.list.AddressBookListComponent
|
import com.tangem.features.addressbook.addaddress.AddAddressComponent
|
||||||
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
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.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
@ -27,10 +29,18 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||||
@Assisted private val params: AddressBookComponent.Params,
|
@Assisted private val params: AddressBookComponent.Params,
|
||||||
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
|
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
|
||||||
private val editContactComponentFactory: EditContactComponent.Factory,
|
private val editContactComponentFactory: EditContactComponent.Factory,
|
||||||
|
private val addAddressComponentFactory: AddAddressComponent.Factory,
|
||||||
) : AddressBookComponent, AppComponentContext by context {
|
) : AddressBookComponent, AppComponentContext by context {
|
||||||
|
|
||||||
private val navigation = StackNavigation<AddressBookRoute>()
|
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(
|
private val contentStack = childStack(
|
||||||
key = "address_book_stack",
|
key = "address_book_stack",
|
||||||
source = navigation,
|
source = navigation,
|
||||||
|
|
@ -66,6 +76,24 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||||
params = EditContactComponent.Params(
|
params = EditContactComponent.Params(
|
||||||
contactId = config.contactId?.let(::ContactId),
|
contactId = config.contactId?.let(::ContactId),
|
||||||
onBackClick = { navigation.pop() },
|
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()
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.tangem.features.addressbook.di
|
package com.tangem.features.addressbook.di
|
||||||
|
|
||||||
import com.tangem.features.addressbook.AddressBookComponent
|
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.component.DefaultAddressBookComponent
|
||||||
import com.tangem.features.addressbook.list.AddressBookListComponent
|
import com.tangem.features.addressbook.list.AddressBookListComponent
|
||||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||||
|
|
@ -29,4 +31,8 @@ internal interface AddressBookComponentModule {
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory
|
fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
fun bindAddAddressComponentFactory(factory: DefaultAddAddressComponent.Factory): AddAddressComponent.Factory
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package com.tangem.features.addressbook.di
|
||||||
|
|
||||||
import com.tangem.core.decompose.di.ModelComponent
|
import com.tangem.core.decompose.di.ModelComponent
|
||||||
import com.tangem.core.decompose.model.Model
|
import com.tangem.core.decompose.model.Model
|
||||||
|
import com.tangem.features.addressbook.addaddress.model.AddAddressModel
|
||||||
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
||||||
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
|
|
@ -23,4 +24,9 @@ internal interface AddressBookModelModule {
|
||||||
@IntoMap
|
@IntoMap
|
||||||
@ClassKey(EditContactModel::class)
|
@ClassKey(EditContactModel::class)
|
||||||
fun bindEditContactModel(model: EditContactModel): Model
|
fun bindEditContactModel(model: EditContactModel): Model
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoMap
|
||||||
|
@ClassKey(AddAddressModel::class)
|
||||||
|
fun bindAddAddressModel(model: AddAddressModel): Model
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package com.tangem.features.addressbook.editcontact
|
||||||
import com.tangem.core.decompose.factory.ComponentFactory
|
import com.tangem.core.decompose.factory.ComponentFactory
|
||||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||||
import com.tangem.domain.addressbook.model.ContactId
|
import com.tangem.domain.addressbook.model.ContactId
|
||||||
|
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||||
|
|
||||||
internal interface EditContactComponent : ComposableContentComponent {
|
internal interface EditContactComponent : ComposableContentComponent {
|
||||||
|
|
||||||
|
|
@ -11,5 +12,6 @@ internal interface EditContactComponent : ComposableContentComponent {
|
||||||
data class Params(
|
data class Params(
|
||||||
val contactId: ContactId?,
|
val contactId: ContactId?,
|
||||||
val onBackClick: () -> Unit,
|
val onBackClick: () -> Unit,
|
||||||
|
val onAddAddressClick: (onResult: (ValidatedAddress) -> Unit) -> Unit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,23 +1,22 @@
|
||||||
package com.tangem.features.addressbook.editcontact.contract
|
package com.tangem.features.addressbook.editcontact.contract
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import com.tangem.common.ui.account.AccountIconUM
|
import com.tangem.common.ui.account.AccountIconUM
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
|
||||||
internal data class EditContactUM(
|
internal data class EditContactUM(
|
||||||
val title: TextReference,
|
val title: TextReference,
|
||||||
val name: String,
|
val name: String,
|
||||||
val namePlaceholder: TextReference,
|
val namePlaceholder: TextReference,
|
||||||
val portfolioIcon: AccountIconUM.CryptoPortfolio,
|
val portfolioIcon: AccountIconUM.CryptoPortfolio,
|
||||||
val colors: Colors,
|
val colors: Colors,
|
||||||
|
val addresses: ImmutableList<ValidatedAddress>,
|
||||||
val onNameChange: (String) -> Unit,
|
val onNameChange: (String) -> Unit,
|
||||||
val onCloseClick: () -> Unit,
|
val onCloseClick: () -> Unit,
|
||||||
|
val onAddAddressClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Immutable
|
|
||||||
data class Colors(
|
data class Colors(
|
||||||
val selected: CryptoPortfolioIcon.Color,
|
val selected: CryptoPortfolioIcon.Color,
|
||||||
val list: ImmutableList<CryptoPortfolioIcon.Color>,
|
val list: ImmutableList<CryptoPortfolioIcon.Color>,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
@ -9,7 +9,9 @@ import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||||
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
||||||
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
||||||
|
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -40,6 +42,14 @@ internal class EditContactModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requestAddAddress() {
|
||||||
|
params.onAddAddressClick(::addAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addAddress(address: ValidatedAddress) {
|
||||||
|
state.update { it.copy(addresses = (it.addresses + address).toImmutableList()) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun getInitialState(): EditContactUM {
|
private fun getInitialState(): EditContactUM {
|
||||||
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
|
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
|
||||||
val selectedColor = colors.first()
|
val selectedColor = colors.first()
|
||||||
|
|
@ -61,8 +71,10 @@ internal class EditContactModel @Inject constructor(
|
||||||
list = colors,
|
list = colors,
|
||||||
onColorSelect = ::onColorSelect,
|
onColorSelect = ::onColorSelect,
|
||||||
),
|
),
|
||||||
|
addresses = persistentListOf(),
|
||||||
onNameChange = ::onNameChange,
|
onNameChange = ::onNameChange,
|
||||||
onCloseClick = params.onBackClick,
|
onCloseClick = params.onBackClick,
|
||||||
|
onAddAddressClick = ::requestAddAddress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,11 +7,14 @@ import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.util.fastForEach
|
import androidx.compose.ui.util.fastForEach
|
||||||
|
|
@ -31,6 +34,9 @@ import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreview
|
import com.tangem.core.ui.res.TangemThemePreview
|
||||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||||
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
||||||
|
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -63,6 +69,82 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
|
||||||
) {
|
) {
|
||||||
ContactSummary(state = state)
|
ContactSummary(state = state)
|
||||||
ContactColor(colors = state.colors)
|
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),
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -177,8 +259,10 @@ private fun Preview_EditContactContent() {
|
||||||
list = colors,
|
list = colors,
|
||||||
onColorSelect = {},
|
onColorSelect = {},
|
||||||
),
|
),
|
||||||
|
addresses = persistentListOf(),
|
||||||
onNameChange = {},
|
onNameChange = {},
|
||||||
onCloseClick = {},
|
onCloseClick = {},
|
||||||
|
onAddAddressClick = {},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
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.clipboard.ClipboardManager
|
||||||
|
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.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
|
||||||
|
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
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
internal class AddAddressModelTest {
|
||||||
|
|
||||||
|
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
|
||||||
|
private val clipboardManager: ClipboardManager = mockk()
|
||||||
|
|
||||||
|
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
|
||||||
|
private val ethereum = cryptoCurrencyFactory.createCoin(Blockchain.Ethereum)
|
||||||
|
private val bitcoin = cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin)
|
||||||
|
|
||||||
|
private var model: AddAddressModel? = null
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun resetMocks() {
|
||||||
|
clearMocks(multiAccountListSupplier, clipboardManager)
|
||||||
|
// Default: no accounts, so no coins are available unless a test overrides it.
|
||||||
|
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
// Cancels modelScope, stopping the long-lived availableCoins / address-input collectors.
|
||||||
|
model?.onDestroy()
|
||||||
|
model = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
inner class AddressField {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `WHEN model created THEN field is empty AND button disabled`() = runTest {
|
||||||
|
// Act
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
val state = model.state.value
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(state.addressField.value).isEmpty()
|
||||||
|
assertThat(state.addressField.isValuePasted).isFalse()
|
||||||
|
assertThat(state.buttonUM.isEnabled).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN empty field WHEN onAddressChange THEN value updated`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
val address = "0xABC"
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.state.value.onAddressChange(address)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val field = model.state.value.addressField
|
||||||
|
assertThat(field.value).isEqualTo(address)
|
||||||
|
assertThat(field.isValuePasted).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN empty field WHEN onPasteClick THEN value marked as pasted`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
val address = "0xABC"
|
||||||
|
every { clipboardManager.getText() } returns address
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.state.value.onPasteClick()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val field = model.state.value.addressField
|
||||||
|
assertThat(field.value).isEqualTo(address)
|
||||||
|
assertThat(field.isValuePasted).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
var confirmed: ValidatedAddress? = null
|
||||||
|
val model = createModel(testScope = this, onConfirm = { confirmed = it })
|
||||||
|
model.state.value.onAddressChange("0xABC")
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.state.value.buttonUM.onClick()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(confirmed).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
inner class AddressInput {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN coins available WHEN valid address typed THEN matching network chosen`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
every { multiAccountListSupplier.invoke() } returns
|
||||||
|
flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val state = model.state.value
|
||||||
|
assertThat(state.availableNetworks).containsExactly(ethereum.network)
|
||||||
|
assertThat(state.chosenNetworkState)
|
||||||
|
.isEqualTo(resultOf(ethereum.network))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN coins available WHEN address matches no network THEN empty state`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
every { multiAccountListSupplier.invoke() } returns
|
||||||
|
flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.state.value.onAddressChange("not-an-address")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val state = model.state.value
|
||||||
|
assertThat(state.availableNetworks).isEmpty()
|
||||||
|
assertThat(state.chosenNetworkState).isEqualTo(AddAddressUM.ChosenNetworkState.Empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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())
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val state = model.state.value
|
||||||
|
assertThat(state.availableNetworks).isEmpty()
|
||||||
|
assertThat(state.chosenNetworkState).isEqualTo(AddAddressUM.ChosenNetworkState.Empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
@Test
|
||||||
|
fun `GIVEN address typed before coins load WHEN coins emitted THEN network resolved reactively`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val accountsFlow = MutableStateFlow<List<AccountList>>(emptyList())
|
||||||
|
every { multiAccountListSupplier.invoke() } returns accountsFlow
|
||||||
|
val model = createModel(testScope = this)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Act — type while coins are still empty
|
||||||
|
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
||||||
|
advanceUntilIdle()
|
||||||
|
// Assert intermediate: nothing to match yet
|
||||||
|
assertThat(model.state.value.chosenNetworkState).isEqualTo(AddAddressUM.ChosenNetworkState.Empty)
|
||||||
|
|
||||||
|
// Act — coins arrive later
|
||||||
|
accountsFlow.value = listOf(accountListWith(ethereum, bitcoin))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(model.state.value.chosenNetworkState)
|
||||||
|
.isEqualTo(resultOf(ethereum.network))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resultOf(vararg networks: Network) = AddAddressUM.ChosenNetworkState.Result(
|
||||||
|
networkUMList = networks
|
||||||
|
.map { network ->
|
||||||
|
AddAddressUM.ChosenNetworkState.Result.NetworkUM(
|
||||||
|
networkName = network.name,
|
||||||
|
iconResId = network.iconResId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toImmutableList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun accountListWith(vararg currencies: CryptoCurrency): AccountList {
|
||||||
|
val walletId = MockAccounts.userWalletId
|
||||||
|
val accounts = listOf(
|
||||||
|
Account.CryptoPortfolio.createMainAccount(
|
||||||
|
userWalletId = walletId,
|
||||||
|
cryptoCurrencies = currencies.toList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return AccountList(
|
||||||
|
userWalletId = walletId,
|
||||||
|
accounts = accounts,
|
||||||
|
totalAccounts = accounts.size,
|
||||||
|
totalArchivedAccounts = 0,
|
||||||
|
).getOrNull()!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createModel(
|
||||||
|
testScope: TestScope,
|
||||||
|
onConfirm: (ValidatedAddress) -> Unit = {},
|
||||||
|
params: AddAddressComponent.Params = AddAddressComponent.Params(
|
||||||
|
onBackClick = {},
|
||||||
|
onConfirm = onConfirm,
|
||||||
|
),
|
||||||
|
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
|
||||||
|
): AddAddressModel {
|
||||||
|
return AddAddressModel(
|
||||||
|
paramsContainer = paramsContainer,
|
||||||
|
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||||
|
multiAccountListSupplier = multiAccountListSupplier,
|
||||||
|
clipboardManager = clipboardManager,
|
||||||
|
).also { model = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||||
|
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||||
|
return TestingCoroutineDispatcherProvider(
|
||||||
|
main = testDispatcher,
|
||||||
|
mainImmediate = testDispatcher,
|
||||||
|
io = testDispatcher,
|
||||||
|
default = testDispatcher,
|
||||||
|
single = testDispatcher,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// EIP-55 checksummed address from the spec — guaranteed to pass Ethereum validation.
|
||||||
|
const val VALID_ETH_ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,9 +8,13 @@ import com.tangem.core.ui.R
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.domain.addressbook.model.ContactId
|
import com.tangem.domain.addressbook.model.ContactId
|
||||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
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.EditContactComponent
|
||||||
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
||||||
|
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
|
@ -42,8 +46,10 @@ internal class EditContactModelTest {
|
||||||
list = expectedColors,
|
list = expectedColors,
|
||||||
onColorSelect = state.colors.onColorSelect,
|
onColorSelect = state.colors.onColorSelect,
|
||||||
),
|
),
|
||||||
|
addresses = persistentListOf(),
|
||||||
onNameChange = state.onNameChange,
|
onNameChange = state.onNameChange,
|
||||||
onCloseClick = state.onCloseClick,
|
onCloseClick = state.onCloseClick,
|
||||||
|
onAddAddressClick = state.onAddAddressClick,
|
||||||
)
|
)
|
||||||
assertThat(state).isEqualTo(expected)
|
assertThat(state).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +60,7 @@ internal class EditContactModelTest {
|
||||||
val params = EditContactComponent.Params(
|
val params = EditContactComponent.Params(
|
||||||
contactId = ContactId(value = "contact-id"),
|
contactId = ContactId(value = "contact-id"),
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
|
onAddAddressClick = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -86,11 +93,32 @@ internal class EditContactModelTest {
|
||||||
assertThat(state.portfolioIcon.color).isEqualTo(newColor)
|
assertThat(state.portfolioIcon.color).isEqualTo(newColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN add address requested WHEN result delivered 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())
|
||||||
|
|
||||||
|
// Act
|
||||||
|
model.state.value.onAddAddressClick()
|
||||||
|
capturedSink?.invoke(validatedAddress)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(model.state.value.addresses).containsExactly(validatedAddress)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createModel(
|
private fun createModel(
|
||||||
testScope: TestScope,
|
testScope: TestScope,
|
||||||
params: EditContactComponent.Params = EditContactComponent.Params(
|
params: EditContactComponent.Params = EditContactComponent.Params(
|
||||||
contactId = null,
|
contactId = null,
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
|
onAddAddressClick = {},
|
||||||
),
|
),
|
||||||
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
|
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
|
||||||
): EditContactModel {
|
): EditContactModel {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue