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.domain.account)
|
||||
implementation(projects.domain.account.status)
|
||||
implementation(projects.domain.addressBook)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.core)
|
||||
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)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.addressBook)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Common */
|
||||
implementation(projects.common.ui)
|
||||
|
|
@ -45,6 +46,12 @@ dependencies {
|
|||
/** Other */
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
|
||||
/** Utils */
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(tangemDeps.blockchain)
|
||||
|
||||
/** Tests */
|
||||
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(
|
||||
val contactId: String? = null,
|
||||
) : 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.domain.addressbook.model.ContactId
|
||||
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.contract.ValidatedAddress
|
||||
import com.tangem.features.addressbook.list.AddressBookListComponent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -27,10 +29,18 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
|||
@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,
|
||||
|
|
@ -66,6 +76,24 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
|||
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()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
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
|
||||
|
|
@ -29,4 +31,8 @@ internal interface AddressBookComponentModule {
|
|||
@Binds
|
||||
@Singleton
|
||||
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.model.Model
|
||||
import com.tangem.features.addressbook.addaddress.model.AddAddressModel
|
||||
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
||||
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
||||
import dagger.Binds
|
||||
|
|
@ -23,4 +24,9 @@ internal interface AddressBookModelModule {
|
|||
@IntoMap
|
||||
@ClassKey(EditContactModel::class)
|
||||
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.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
|
||||
internal interface EditContactComponent : ComposableContentComponent {
|
||||
|
||||
|
|
@ -11,5 +12,6 @@ internal interface EditContactComponent : ComposableContentComponent {
|
|||
data class Params(
|
||||
val contactId: ContactId?,
|
||||
val onBackClick: () -> Unit,
|
||||
val onAddAddressClick: (onResult: (ValidatedAddress) -> Unit) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +1,22 @@
|
|||
package com.tangem.features.addressbook.editcontact.contract
|
||||
|
||||
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,
|
||||
val namePlaceholder: TextReference,
|
||||
val portfolioIcon: AccountIconUM.CryptoPortfolio,
|
||||
val colors: Colors,
|
||||
val addresses: ImmutableList<ValidatedAddress>,
|
||||
val onNameChange: (String) -> Unit,
|
||||
val onCloseClick: () -> Unit,
|
||||
val onAddAddressClick: () -> Unit,
|
||||
) {
|
||||
|
||||
@Immutable
|
||||
data class Colors(
|
||||
val selected: 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.features.addressbook.editcontact.EditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.contract.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
|
||||
|
|
@ -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 {
|
||||
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
|
||||
val selectedColor = colors.first()
|
||||
|
|
@ -61,8 +71,10 @@ internal class EditContactModel @Inject constructor(
|
|||
list = colors,
|
||||
onColorSelect = ::onColorSelect,
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
onNameChange = ::onNameChange,
|
||||
onCloseClick = params.onBackClick,
|
||||
onAddAddressClick = ::requestAddAddress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,11 +7,14 @@ import androidx.compose.foundation.clickable
|
|||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.domain.models.account.CryptoPortfolioIcon
|
||||
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
|
||||
|
||||
@Composable
|
||||
|
|
@ -63,6 +69,82 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
|
|||
) {
|
||||
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),
|
||||
) {
|
||||
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,
|
||||
onColorSelect = {},
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
onNameChange = {},
|
||||
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.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.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
|
||||
|
|
@ -42,8 +46,10 @@ internal class EditContactModelTest {
|
|||
list = expectedColors,
|
||||
onColorSelect = state.colors.onColorSelect,
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
onNameChange = state.onNameChange,
|
||||
onCloseClick = state.onCloseClick,
|
||||
onAddAddressClick = state.onAddAddressClick,
|
||||
)
|
||||
assertThat(state).isEqualTo(expected)
|
||||
}
|
||||
|
|
@ -54,6 +60,7 @@ internal class EditContactModelTest {
|
|||
val params = EditContactComponent.Params(
|
||||
contactId = ContactId(value = "contact-id"),
|
||||
onBackClick = {},
|
||||
onAddAddressClick = {},
|
||||
)
|
||||
|
||||
// Act
|
||||
|
|
@ -86,11 +93,32 @@ internal class EditContactModelTest {
|
|||
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(
|
||||
testScope: TestScope,
|
||||
params: EditContactComponent.Params = EditContactComponent.Params(
|
||||
contactId = null,
|
||||
onBackClick = {},
|
||||
onAddAddressClick = {},
|
||||
),
|
||||
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
|
||||
): EditContactModel {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue