diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c9f4634459..c307bb3f9b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt new file mode 100644 index 0000000000..a06a3e002a --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 3c128bf724..aefe8cba59 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -105,22 +105,23 @@ Contact Contact name Copy address - We couldn’t create contact. Please try again later. - This contact will be deleted from all your address book - We couldn’t delete contact. Please try again later. + Couldn\'t create contact. Please try again later. + This contact will be deleted from all your address books + Couldn\'t delete contact. Please try again later. Manage contacts & addresses Discard Edit address Enter address + Invalid address Keep editing New contact No contacts yet - Contacts you add will appear here + Contacts added will appear here Remove address This contact will be linked to this wallet’s address book. Select network Address book - Unsaved Changes + Unsaved changes Are you sure you want to discard edits? Send only %1$s (%2$s) from %3$s network to this address. Using other tokens and networks may result in loss of funds. Default diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts index 4711602801..2869945728 100644 --- a/features/address-book/impl/build.gradle.kts +++ b/features/address-book/impl/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt new file mode 100644 index 0000000000..445dfa6c4a --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt @@ -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 + + data class Params( + val onBackClick: () -> Unit, + val onConfirm: (ValidatedAddress) -> Unit, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt new file mode 100644 index 0000000000..374e522918 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddAddressUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddAddressUM.kt new file mode 100644 index 0000000000..d54c5e1716 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddAddressUM.kt @@ -0,0 +1,34 @@ +package com.tangem.features.addressbook.addaddress.contract + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.domain.models.network.Network +import kotlinx.collections.immutable.ImmutableList + +internal data class AddAddressUM( + val addressField: AddressFieldUM, + val availableNetworks: ImmutableList, + val buttonUM: TangemButtonUM, + val chosenNetworkStateUM: ChosenNetworkStateUM, + val onAddressChange: (String) -> Unit, + val onAddressClear: () -> Unit, + val onPasteClick: () -> Unit, + val onQrClick: () -> Unit, + val onBackClick: () -> Unit, +) { + @Immutable + sealed class ChosenNetworkStateUM { + data object Loading : ChosenNetworkStateUM() + data object Empty : ChosenNetworkStateUM() + data class Result( + val networkUMList: ImmutableList, + ) : ChosenNetworkStateUM() { + + data class NetworkUM( + val networkName: String, + @DrawableRes val iconResId: Int, + ) + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddressFieldUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddressFieldUM.kt new file mode 100644 index 0000000000..ea8e324bd7 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddressFieldUM.kt @@ -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, +) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt new file mode 100644 index 0000000000..c5ca0ebc21 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt @@ -0,0 +1,149 @@ +package com.tangem.features.addressbook.addaddress.model + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.common.ui.extensions.iconResId +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.R +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.features.addressbook.addaddress.AddAddressComponent +import com.tangem.features.addressbook.addaddress.contract.AddAddressUM +import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM +import com.tangem.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 + field = MutableStateFlow(getInitialState()) + + private val availableCoins: StateFlow> = multiAccountListSupplier() + .map { accountLists -> + accountLists + .flatMap { it.flattenCurrencies() } + .filterIsInstance() + .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, + ), + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, + ) + } + } + + private fun subscribeToAddressInput() { + combine(addressInput, availableCoins) { input, coins -> + getUniqueNetworks(input, coins) + } + .onEach { availableNetworks -> + state.update { oldState -> + oldState.copy( + availableNetworks = availableNetworks, + chosenNetworkStateUM = createChosenNetworkState(availableNetworks), + ) + } + } + .launchIn(modelScope) + } + + private fun createChosenNetworkState(availableNetworks: ImmutableList): AddAddressUM.ChosenNetworkStateUM { + return if (availableNetworks.isEmpty()) { + AddAddressUM.ChosenNetworkStateUM.Empty + } else { + AddAddressUM.ChosenNetworkStateUM.Result( + networkUMList = availableNetworks + .map { network -> + AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM( + networkName = network.name, + iconResId = network.iconResId, + ) + } + .toImmutableList(), + ) + } + } + + private fun getUniqueNetworks(input: String, coins: List): ImmutableList { + 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, + ), + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, + onAddressChange = { onAddressChange(value = it) }, + onAddressClear = { onAddressChange("") }, + onPasteClick = ::onPaste, + onQrClick = { /* [REDACTED_TODO_COMMENT] */ }, + onBackClick = params.onBackClick, + ) + + companion object { + private const val ADD_ADDRESS_DEBOUNCE = 500L + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt new file mode 100644 index 0000000000..9cf8321120 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt @@ -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.chosenNetworkStateUM) + 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 = { }, + ), + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, + onAddressChange = {}, + onAddressClear = {}, + onPasteClick = {}, + onQrClick = {}, + onBackClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt new file mode 100644 index 0000000000..dc91b1d8b7 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt @@ -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.ChosenNetworkStateUM.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(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) { + 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(chosenNetworkStateUM) + }, + ) +} + +@Composable +private fun SelectNetworkButton(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + when (chosenNetworkStateUM) { + is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList) + AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader() + AddAddressUM.ChosenNetworkStateUM.Empty -> { + Text( + modifier = Modifier.padding(start = 8.dp), + text = stringResourceSafe(R.string.address_book_select_network), + style = TangemTheme.typography.body2, + color = TangemTheme.colors3.text.secondary, + ) + ChevronIcon() + } + } + } +} + +@Composable +private fun NetworkIconsResolver(networks: ImmutableList) { + 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) { + 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( + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result( + networkUMList = persistentListOf( + NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22), + ), + ), + ) + SpacerH12() + NetworkBlock( + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result( + networkUMList = persistentListOf( + NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22), + NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22), + NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22), + ), + ), + ) + SpacerH12() + NetworkBlock( + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result( + networkUMList = List(15) { + NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22) + }.toImmutableList(), + ), + ) + SpacerH12() + NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading) + SpacerH12() + NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty) + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/RecipientRow.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/RecipientRow.kt new file mode 100644 index 0000000000..8ebe82cc0f --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/RecipientRow.kt @@ -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 = {}, + ) + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt index 49e12ab00f..815ebd2498 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt @@ -15,4 +15,7 @@ internal sealed class AddressBookRoute { data class EditContact( val contactId: String? = null, ) : AddressBookRoute() + + @Serializable + data object AddAddress : AddressBookRoute() } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt index 4b4ee9140a..e843ffd769 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt @@ -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() + /** + * 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() + }, ), ) } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt index 188884bab3..493422e7c2 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt @@ -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 } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt index 0fb085f06d..8d81fd327a 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt @@ -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 } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt index ede28263b7..2e93f5ff24 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt @@ -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, ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt index 2a54242a81..9efd8db0e2 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt @@ -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, val onNameChange: (String) -> Unit, val onCloseClick: () -> Unit, + val onAddAddressClick: () -> Unit, ) { - @Immutable data class Colors( val selected: CryptoPortfolioIcon.Color, val list: ImmutableList, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt new file mode 100644 index 0000000000..87a094ee60 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt @@ -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, +) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt index 1f1035618b..d4bcdf27ab 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt @@ -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, ) } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt index 2183d33c43..e6d2f4a0ab 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt @@ -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 @@ -28,9 +31,12 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign 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,83 @@ 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) { + 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, + maxLines = 1, + ) + } + } + } +} + +@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, + ) } } } @@ -162,7 +245,7 @@ private fun ContactColor(colors: EditContactUM.Colors) { @Composable private fun Preview_EditContactContent() { val colors = CryptoPortfolioIcon.Color.entries.toImmutableList() - TangemThemePreview { + TangemThemePreviewRedesign { EditContactContent( state = EditContactUM( title = stringReference("New contact"), @@ -177,8 +260,10 @@ private fun Preview_EditContactContent() { list = colors, onColorSelect = {}, ), + addresses = persistentListOf(), onNameChange = {}, onCloseClick = {}, + onAddAddressClick = {}, ), ) } diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt new file mode 100644 index 0000000000..c58bb25f8d --- /dev/null +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt @@ -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.chosenNetworkStateUM) + .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.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.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.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.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>(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.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty) + + // Act — coins arrive later + accountsFlow.value = listOf(accountListWith(ethereum, bitcoin)) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.chosenNetworkStateUM) + .isEqualTo(resultOf(ethereum.network)) + } + } + + private fun resultOf(vararg networks: Network) = AddAddressUM.ChosenNetworkStateUM.Result( + networkUMList = networks + .map { network -> + AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM( + networkName = network.name, + iconResId = network.iconResId, + ) + } + .toImmutableList(), + ) + + private fun accountListWith(vararg currencies: CryptoCurrency): AccountList { + val walletId = MockAccounts.userWalletId + val accounts = listOf( + 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" + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt index d44b437435..ebf9acafd1 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt @@ -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 {