From 09db4a7d03b4e630e25652ed91c82fda86f56879 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 11:18:58 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 6 +- .../com/tangem/common/routing/AppRoute.kt | 11 +- .../routing/entity/AddressBookOpenMode.kt | 17 ++ .../core/ui/components/account/AccountIcon.kt | 9 +- .../main/res/drawable/ic_address_book_24.xml | 25 ++ .../src/main/res/drawable/ic_contact_20.xml | 9 - features/address-book/api/build.gradle.kts | 3 + .../addressbook/AddressBookComponent.kt | 3 +- .../addaddress/AddAddressComponent.kt | 15 -- .../addaddress/DefaultAddAddressComponent.kt | 24 +- .../addaddress/model/AddAddressModel.kt | 124 +++------- .../state/AddAddressStateController.kt | 50 ++++ ...UpdateAddAddressInitialStateTransformer.kt | 29 +++ .../UpdateAddressInputTransformer.kt | 25 ++ .../UpdateAddressValidationTransformer.kt | 36 +++ .../addaddress/ui/AddAddressContent.kt | 38 +-- .../addressbook/addaddress/ui/NetworkBlock.kt | 89 ++++--- .../addressbook/addaddress/ui/RecipientRow.kt | 98 ++++---- .../{contract => ui/state}/AddAddressUM.kt | 16 +- .../{contract => ui/state}/AddressFieldUM.kt | 5 +- .../common/AddressBookChildFactory.kt | 56 +++++ .../common/AddressBookClickIntents.kt | 26 +++ .../common/AddressBookResultHolder.kt | 29 +++ .../common/DefaultAddressBookComponent.kt | 117 ++++++++++ .../DefaultAddressBookFeatureToggles.kt | 3 +- .../addressbook/component/AddressBookRoute.kt | 21 -- .../component/DefaultAddressBookComponent.kt | 108 --------- .../di/AddressBookComponentModule.kt | 22 +- .../addressbook/di/AddressBookModule.kt | 2 +- .../DefaultEditContactComponent.kt | 27 ++- .../editcontact/EditContactComponent.kt | 17 -- .../editcontact/contract/ValidatedAddress.kt | 14 -- .../editcontact/model/EditContactModel.kt | 105 +++++---- .../state/EditContactStateController.kt | 52 +++++ .../AddValidatedAddressTransformer.kt | 19 ++ .../SelectContactColorTransformer.kt | 17 ++ .../UpdateContactNameTransformer.kt | 13 ++ ...pdateEditContactInitialStateTransformer.kt | 35 +++ .../editcontact/ui/EditContactContent.kt | 221 ++++++++++-------- .../{contract => ui/state}/EditContactUM.kt | 4 +- .../editcontact/ui/state/ValidatedAddress.kt | 17 ++ .../list/AddressBookListComponent.kt | 14 -- .../list/DefaultAddressBookListComponent.kt | 31 ++- .../list/contract/AddressBookListUM.kt | 15 -- .../list/model/AddressBookListModel.kt | 39 ++-- .../state/AddressBookListStateController.kt | 25 ++ .../state/converter/ContactUMConverter.kt | 21 ++ ...eAddressBookListInitialStateTransformer.kt | 19 ++ .../list/ui/AddressBookEmptyScreen.kt | 61 +++-- .../list/ui/state/AddressBookListUM.kt | 12 + .../addressbook/list/ui/state/ContactUM.kt | 11 + .../addressbook/route/AddressBookRoute.kt | 27 +++ .../addaddress/model/AddAddressModelTest.kt | 90 +++---- .../editcontact/model/EditContactModelTest.kt | 98 +++++--- .../features/details/entity/DetailsItemUM.kt | 3 +- .../features/details/ui/DetailsScreen.kt | 118 ++++++++-- .../features/details/utils/ItemsBuilder.kt | 20 +- .../details/utils/ItemsBuilderTest.kt | 18 +- 58 files changed, 1353 insertions(+), 826 deletions(-) create mode 100644 common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt create mode 100644 core/ui/src/main/res/drawable/ic_address_book_24.xml delete mode 100644 core/ui/src/main/res/drawable/ic_contact_20.xml delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt rename features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/{contract => ui/state}/AddAddressUM.kt (62%) rename features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/{contract => ui/state}/AddressFieldUM.kt (54%) create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt rename features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/{ => common}/DefaultAddressBookFeatureToggles.kt (78%) delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SelectContactColorTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateContactNameTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt rename features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/{contract => ui/state}/EditContactUM.kt (87%) create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/AddressBookListComponent.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 1e1942f867..8f1f852eb9 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -21,7 +21,6 @@ import com.tangem.features.feed.entry.components.FeedEntryRoute import com.tangem.features.home.api.HomeComponent import com.tangem.features.hotwallet.* import com.tangem.features.kyc.KycComponent -import com.tangem.features.survey.SurveyComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensMode @@ -37,9 +36,10 @@ import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.SendEntryPointComponent import com.tangem.features.staking.api.StakingComponent +import com.tangem.features.survey.SurveyComponent import com.tangem.features.swap.SwapComponent -import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComponent import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.* import com.tangem.features.tokendetails.TokenDetailsComponent @@ -758,7 +758,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.AddressBook -> { createComponentChild( context = context, - params = AddressBookComponent.Params(route.predefinedAddress), + params = AddressBookComponent.Params(addressBookOpenMode = route.addressBookOpenMode), componentFactory = addressBookComponentFactory, ) } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 12df52d117..1f04371c14 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -6,6 +6,7 @@ import android.annotation.SuppressLint import android.os.Bundle import com.tangem.common.routing.bundle.RouteBundleParams import com.tangem.common.routing.bundle.bundle +import com.tangem.common.routing.entity.AddressBookOpenMode import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.navigation.Route @@ -174,8 +175,14 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class AddressBook( - val predefinedAddress: String? = null, - ) : AppRoute(path = "/address_book/predefinedAddress/$predefinedAddress") + val addressBookOpenMode: AddressBookOpenMode = AddressBookOpenMode.Default, + ) : AppRoute( + path = when (addressBookOpenMode) { + is AddressBookOpenMode.WithContactCreation -> + "/address_book/${addressBookOpenMode.address}-${addressBookOpenMode.networkId}" + AddressBookOpenMode.Default -> "/address_book" + }, + ) @Serializable data class QrScanning(val source: Source) : AppRoute(path = "/$source/qr_scanning${source.path}") { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt new file mode 100644 index 0000000000..7c6b6206c4 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt @@ -0,0 +1,17 @@ +package com.tangem.common.routing.entity + +import kotlinx.serialization.Serializable + +/** How the address book is opened — part of the navigation contract, carried by [com.tangem.common.routing.AppRoute.AddressBook]. */ +@Serializable +sealed interface AddressBookOpenMode { + + @Serializable + data object Default : AddressBookOpenMode + + @Serializable + data class WithContactCreation( + val address: String, + val networkId: String, + ) : AddressBookOpenMode +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt index 35901d7a10..b9ffbb60e7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt @@ -32,7 +32,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreviewRedesign enum class AccountIconSize { - Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall + Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall, RedesignLarge } /** @@ -132,6 +132,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1 AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28 AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11 + AccountIconSize.RedesignLarge -> TangemTheme.typography3.heading.medium } val textSize by animateFloatAsState( @@ -166,6 +167,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) { AccountIconSize.ExtraSmall -> 8.dp AccountIconSize.RedesignedDefault -> 20.dp AccountIconSize.RedesignExtraSmall -> 8.dp + AccountIconSize.RedesignLarge -> 32.dp } fun AccountIconSize.toBoxSize(): Dp = when (this) { @@ -176,6 +178,7 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) { AccountIconSize.ExtraSmall -> 14.dp AccountIconSize.RedesignedDefault -> 40.dp AccountIconSize.RedesignExtraSmall -> 16.dp + AccountIconSize.RedesignLarge -> 80.dp } private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { @@ -186,6 +189,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { AccountIconSize.ExtraSmall -> 4.dp AccountIconSize.RedesignedDefault -> 12.dp AccountIconSize.RedesignExtraSmall -> 6.dp + AccountIconSize.RedesignLarge -> 80.dp } @Preview(showBackground = true) @@ -228,7 +232,8 @@ private fun Sample() { AccountIconSize.Small -> AccountIconSize.ExtraSmall AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall - AccountIconSize.RedesignExtraSmall -> AccountIconSize.Default + AccountIconSize.RedesignExtraSmall -> AccountIconSize.RedesignLarge + AccountIconSize.RedesignLarge -> AccountIconSize.Default } }) { Text("Change") } diff --git a/core/ui/src/main/res/drawable/ic_address_book_24.xml b/core/ui/src/main/res/drawable/ic_address_book_24.xml new file mode 100644 index 0000000000..ce54ca1a63 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_address_book_24.xml @@ -0,0 +1,25 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_contact_20.xml b/core/ui/src/main/res/drawable/ic_contact_20.xml deleted file mode 100644 index b6d762b21b..0000000000 --- a/core/ui/src/main/res/drawable/ic_contact_20.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/features/address-book/api/build.gradle.kts b/features/address-book/api/build.gradle.kts index 425593dae2..50ede12ea4 100644 --- a/features/address-book/api/build.gradle.kts +++ b/features/address-book/api/build.gradle.kts @@ -10,6 +10,9 @@ android { dependencies { + /* Project - Common */ + api(projects.common.routing) + /* Project - Domain */ implementation(projects.domain.models) diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookComponent.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookComponent.kt index e9fe9ddb22..ff7f36e511 100644 --- a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookComponent.kt +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookComponent.kt @@ -1,5 +1,6 @@ package com.tangem.features.addressbook +import com.tangem.common.routing.entity.AddressBookOpenMode import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent @@ -7,5 +8,5 @@ interface AddressBookComponent : ComposableContentComponent { interface Factory : ComponentFactory - data class Params(val predefinedAddress: String?) + data class Params(val addressBookOpenMode: AddressBookOpenMode) } \ 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 deleted file mode 100644 index 445dfa6c4a..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.features.addressbook.addaddress - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress - -internal interface AddAddressComponent : ComposableContentComponent { - - interface Factory : ComponentFactory - - 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 index 374e522918..62d3e586a8 100644 --- 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 @@ -7,16 +7,15 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.addressbook.addaddress.model.AddAddressModel import com.tangem.features.addressbook.addaddress.ui.AddAddressContent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress -internal class DefaultAddAddressComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted params: AddAddressComponent.Params, -) : AddAddressComponent, AppComponentContext by context { +internal class DefaultAddAddressComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: AddAddressModel = getOrCreateModel(params) @@ -30,11 +29,8 @@ internal class DefaultAddAddressComponent @AssistedInject constructor( BackHandler(onBack = state.onBackClick) } - @AssistedFactory - interface Factory : AddAddressComponent.Factory { - override fun create( - context: AppComponentContext, - params: AddAddressComponent.Params, - ): DefaultAddAddressComponent - } + data class Params( + val onBackClick: () -> Unit, + val onConfirm: (ValidatedAddress) -> Unit, + ) } \ 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 index c5ca0ebc21..925354b0d5 100644 --- 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 @@ -1,30 +1,21 @@ package com.tangem.features.addressbook.addaddress.model -import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.common.ui.extensions.iconResId import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.R import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.features.addressbook.addaddress.AddAddressComponent -import com.tangem.features.addressbook.addaddress.contract.AddAddressUM -import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM +import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent +import com.tangem.features.addressbook.addaddress.state.AddAddressStateController +import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddAddressInitialStateTransformer +import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressInputTransformer +import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* import javax.inject.Inject -import kotlin.collections.map @OptIn(FlowPreview::class) @ModelScoped @@ -33,12 +24,12 @@ internal class AddAddressModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, multiAccountListSupplier: MultiAccountListSupplier, private val clipboardManager: ClipboardManager, + private val stateController: AddAddressStateController, ) : Model() { - private val params: AddAddressComponent.Params = paramsContainer.require() + private val params: DefaultAddAddressComponent.Params = paramsContainer.require() - val state: StateFlow - field = MutableStateFlow(getInitialState()) + val state: StateFlow get() = stateController.uiState private val availableCoins: StateFlow> = multiAccountListSupplier() .map { accountLists -> @@ -47,6 +38,7 @@ internal class AddAddressModel @Inject constructor( .filterIsInstance() .distinctBy { it.network.id } } + .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.Eagerly, emptyList()) private val addressInput = state @@ -55,94 +47,44 @@ internal class AddAddressModel @Inject constructor( .debounce(ADD_ADDRESS_DEBOUNCE) init { - subscribeToAddressInput() + updateInitialState() + subscribeToAddressValidation() } - private fun onAddressChange(value: String, isPasted: Boolean = false) { - state.update { oldState -> - oldState.copy( - addressField = oldState.addressField.copy( - value = value, - isValuePasted = isPasted, - isError = false, - error = null, - ), - chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, - ) - } + private fun updateInitialState() { + stateController.update( + UpdateAddAddressInitialStateTransformer( + onAddressChange = { onAddressChange(value = it) }, + onAddressClear = { onAddressChange("") }, + onPasteClick = ::onPaste, + onQrClick = { /* [REDACTED_TODO_COMMENT] */ }, + onBackClick = params.onBackClick, + onConfirmClick = ::validateAndConfirm, + ), + ) } - private fun subscribeToAddressInput() { + private fun onAddressChange(value: String) { + stateController.update(UpdateAddressInputTransformer(value = value)) + } + + private fun subscribeToAddressValidation() { combine(addressInput, availableCoins) { input, coins -> - getUniqueNetworks(input, coins) + UpdateAddressValidationTransformer(address = input, coins = coins) } - .onEach { availableNetworks -> - state.update { oldState -> - oldState.copy( - availableNetworks = availableNetworks, - chosenNetworkStateUM = createChosenNetworkState(availableNetworks), - ) - } - } + .onEach(stateController::update) + .flowOn(dispatchers.default) .launchIn(modelScope) } - private fun createChosenNetworkState(availableNetworks: ImmutableList): 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) + onAddressChange(value = clipboardManager.getText().orEmpty()) } private fun validateAndConfirm() { - // TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm + // TODO Address book ([REDACTED_TASK_KEY]): navigate to the network-selection with the address and its matching networks. } - private fun getInitialState(): AddAddressUM = AddAddressUM( - addressField = AddressFieldUM( - value = "", - placeholder = resourceReference(R.string.common_address), - label = resourceReference(R.string.address_book_enter_address), - isError = false, - error = null, - isValuePasted = false, - ), - availableNetworks = persistentListOf(), - buttonUM = TangemButtonUM( - text = TextReference.Res(R.string.address_book_add_address), - type = TangemButtonType.Primary, - isEnabled = false, - onClick = ::validateAndConfirm, - ), - chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, - onAddressChange = { onAddressChange(value = it) }, - onAddressClear = { onAddressChange("") }, - onPasteClick = ::onPaste, - onQrClick = { /* [REDACTED_TODO_COMMENT] */ }, - onBackClick = params.onBackClick, - ) - companion object { private const val ADD_ADDRESS_DEBOUNCE = 500L } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt new file mode 100644 index 0000000000..d5ecedc0de --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt @@ -0,0 +1,50 @@ +package com.tangem.features.addressbook.addaddress.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM +import com.tangem.utils.transformer.Transformer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class AddAddressStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) + + val uiState: StateFlow get() = mutableUiState.asStateFlow() + + fun update(transformer: Transformer) { + mutableUiState.update(function = transformer::transform) + } + + private fun getInitialState(): AddAddressUM = AddAddressUM( + addressField = AddressFieldUM( + value = "", + placeholder = resourceReference(R.string.address_book_enter_address), + label = resourceReference(R.string.common_address), + isError = false, + ), + buttonUM = TangemButtonUM( + text = TextReference.Res(R.string.address_book_add_address), + type = TangemButtonType.Primary, + isEnabled = false, + onClick = {}, + ), + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, + onAddressChange = {}, + onAddressClear = {}, + onPasteClick = {}, + onQrClick = {}, + onBackClick = {}, + onNetworkClick = {}, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt new file mode 100644 index 0000000000..15007b6655 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt @@ -0,0 +1,29 @@ +package com.tangem.features.addressbook.addaddress.state.transformers + +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.utils.transformer.Transformer + +/** + * Wires the callbacks owned by [com.tangem.features.addressbook.addaddress.model.AddAddressModel] into the initial + * state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController]. + */ +internal class UpdateAddAddressInitialStateTransformer( + private val onAddressChange: (String) -> Unit, + private val onAddressClear: () -> Unit, + private val onPasteClick: () -> Unit, + private val onQrClick: () -> Unit, + private val onBackClick: () -> Unit, + private val onConfirmClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: AddAddressUM): AddAddressUM { + return prevState.copy( + onAddressChange = onAddressChange, + onAddressClear = onAddressClear, + onPasteClick = onPasteClick, + onQrClick = onQrClick, + onBackClick = onBackClick, + buttonUM = prevState.buttonUM.copy(onClick = onConfirmClick), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt new file mode 100644 index 0000000000..f9b84f065e --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt @@ -0,0 +1,25 @@ +package com.tangem.features.addressbook.addaddress.state.transformers + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.utils.transformer.Transformer + +/** + * Updates the address field with a freshly entered/pasted [value] and clears any previous error, restoring the default + * label. The actual (re)validation runs after a debounce — see [UpdateAddressValidationTransformer]. + */ +internal class UpdateAddressInputTransformer( + private val value: String, +) : Transformer { + + override fun transform(prevState: AddAddressUM): AddAddressUM { + return prevState.copy( + addressField = prevState.addressField.copy( + value = value, + isError = false, + label = resourceReference(R.string.common_address), + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt new file mode 100644 index 0000000000..6d7c469965 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt @@ -0,0 +1,36 @@ +package com.tangem.features.addressbook.addaddress.state.transformers + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.utils.transformer.Transformer + +/** + * Validates [address] against the wallet's [coins] and reflects the result in the UI. + * + * The network is not chosen on this screen (it is selected on the next screen), so the address is valid when it matches + * at least one of the available networks — the same blockchain check the Send flow uses. An invalid (non-empty, + * matching nothing) address surfaces the error in the field label and disables the confirm button. + */ +internal class UpdateAddressValidationTransformer( + private val address: String, + private val coins: List, +) : Transformer { + + override fun transform(prevState: AddAddressUM): AddAddressUM { + val hasMatchedAnyNetwork = address.isNotBlank() && + coins.any { it.network.toBlockchain().validateAddress(address) } + val isError = address.isNotBlank() && !hasMatchedAnyNetwork + val label = if (isError) { + resourceReference(R.string.address_book_invalid_address_error) + } else { + resourceReference(R.string.common_address) + } + return prevState.copy( + addressField = prevState.addressField.copy(isError = isError, label = label), + buttonUM = prevState.buttonUM.copy(isEnabled = hasMatchedAnyNetwork), + ) + } +} \ 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 index 9cf8321120..6a8d9f5cfe 100644 --- 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 @@ -3,14 +3,15 @@ package com.tangem.features.addressbook.addaddress.ui import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.ds.button.PrimaryTangemButton +import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.ds.button.TangemButtonType import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.image.TangemIconUM @@ -20,9 +21,8 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import com.tangem.features.addressbook.addaddress.contract.AddAddressUM -import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM -import kotlinx.collections.immutable.persistentListOf +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM @Composable internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifier) { @@ -34,7 +34,6 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie horizontalAlignment = Alignment.CenterHorizontally, ) { TangemTopBar( - modifier = Modifier.statusBarsPadding(), title = resourceReference(R.string.address_book_add_address), startContent = { TangemButton( @@ -47,14 +46,23 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie ) RecipientRow( + modifier = Modifier.padding(horizontal = 16.dp), addressField = state.addressField, onValueChange = state.onAddressChange, onAddressClear = state.onAddressClear, onQrClick = state.onQrClick, onPasteClick = state.onPasteClick, ) - SpacerH12() - NetworkBlock(state.chosenNetworkStateUM) + SpacerH(20.dp) + NetworkBlock( + modifier = Modifier + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .fillMaxWidth() + .background(color = TangemTheme.colors3.bg.secondary), + chosenNetworkStateUM = state.chosenNetworkStateUM, + onNetworkSelectClick = state.onNetworkClick, + ) PrimaryButton(state.buttonUM) } } @@ -62,13 +70,15 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie @Composable private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) { Spacer(modifier = Modifier.weight(1f)) - - PrimaryTangemButton( + TangemButton( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), - buttonUM = buttonUM, + .padding(horizontal = 16.dp, vertical = 12.dp), + onClick = buttonUM.onClick, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, + size = TangemButton.Size.X12, + text = buttonUM.text, ) } @@ -84,7 +94,6 @@ private fun Preview_AddAddressContent() { placeholder = resourceReference(R.string.address_book_enter_address), label = resourceReference(R.string.common_address), ), - availableNetworks = persistentListOf(), buttonUM = TangemButtonUM( text = TextReference.Res(R.string.address_book_add_address), type = TangemButtonType.Primary, @@ -97,6 +106,7 @@ private fun Preview_AddAddressContent() { onPasteClick = {}, onQrClick = {}, onBackClick = {}, + onNetworkClick = {}, ), ) } 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 index dc91b1d8b7..e2b3e1b6a5 100644 --- 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 @@ -1,86 +1,95 @@ package com.tangem.features.addressbook.addaddress.ui -import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.ds2.loader.TangemLoader +import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.row.TangemRow import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import com.tangem.features.addressbook.addaddress.contract.AddAddressUM -import com.tangem.features.addressbook.addaddress.contract.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM +import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList private const val MAX_VISIBLE_NETWORKS = 3 -private val NetworkIconSize = 24.dp // Horizontal advance per icon. Smaller than the icon size so icons overlap; the bg-colored ring on // the icon drawn on top carves the crescent cut-out from the icon below. private val NetworkIconStep = 18.dp @Composable -internal fun NetworkBlock(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) { +internal fun NetworkBlock( + onNetworkSelectClick: () -> Unit, + chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, + modifier: Modifier = Modifier, +) { TangemRow( verticalAlignment = TangemRowVerticalAlignment.Center, - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(16.dp)) - .fillMaxWidth() - .background(color = TangemTheme.colors3.bg.secondary) - .padding(horizontal = 4.dp), + modifier = modifier, titleSlot = { Text( text = stringResourceSafe(R.string.common_network), - style = TangemTheme.typography.body2, + style = TangemTheme.typography3.body.medium, color = TangemTheme.colors3.text.primary, ) }, endSlot = { - SelectNetworkButton(chosenNetworkStateUM) + SelectNetworkButton( + onNetworkSelectClick = onNetworkSelectClick, + chosenNetworkStateUM = chosenNetworkStateUM, + ) }, ) } @Composable -private fun SelectNetworkButton(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) { +private fun SelectNetworkButton( + onNetworkSelectClick: () -> Unit, + chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, +) { Row( + modifier = Modifier.clickableSingle( + onClick = onNetworkSelectClick, + enabled = chosenNetworkStateUM !is AddAddressUM.ChosenNetworkStateUM.Loading, + ), verticalAlignment = Alignment.CenterVertically, ) { when (chosenNetworkStateUM) { is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList) - AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader() + AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20) AddAddressUM.ChosenNetworkStateUM.Empty -> { Text( modifier = Modifier.padding(start = 8.dp), text = stringResourceSafe(R.string.address_book_select_network), - style = TangemTheme.typography.body2, + style = TangemTheme.typography3.body.medium, color = TangemTheme.colors3.text.secondary, ) + SpacerW(4.dp) ChevronIcon() } } @@ -100,7 +109,7 @@ private fun NetworkIconsResolver(networks: ImmutableList) { Text( modifier = Modifier.padding(start = 8.dp), text = network.networkName, - style = TangemTheme.typography.body2, + style = TangemTheme.typography3.body.medium, color = TangemTheme.colors3.text.secondary, ) ChevronIcon() @@ -120,7 +129,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList) { val remaining = networks.size - visible.size Box(modifier = Modifier.wrapContentWidth()) { - visible.forEachIndexed { index, network -> + visible.fastForEachIndexed { index, network -> Image( painter = painterResource(id = network.iconResId), contentDescription = null, @@ -128,7 +137,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList) { modifier = Modifier .padding(start = NetworkIconStep * index) .networkIconRing() - .size(NetworkIconSize), + .size(24.dp), ) } if (remaining > 0) { @@ -137,12 +146,13 @@ private fun OverlappingNetworkIcons(networks: ImmutableList) { .padding(start = NetworkIconStep * visible.size) .networkIconRing() .background(color = TangemTheme.colors3.bg.tertiary) - .size(NetworkIconSize), + .heightIn(min = 24.dp) + .padding(vertical = 2.dp, horizontal = 4.dp), contentAlignment = Alignment.Center, ) { Text( - text = "+$remaining", - style = TangemTheme.typography.caption1, + text = "${StringsSigns.PLUS}$remaining", + style = TangemTheme.typography3.caption.medium, color = TangemTheme.colors3.text.secondary, ) } @@ -160,20 +170,23 @@ private fun Modifier.networkIconRing(): Modifier = this @Composable private fun ChevronIcon() { - Image( - modifier = Modifier.padding(start = 8.dp), - painter = painterResource(id = R.drawable.ic_select_18_24), + Icon( + modifier = Modifier + .padding(start = 8.dp) + .size(20.dp), + tint = TangemTheme.colors3.icon.secondary, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_select_18_24), contentDescription = null, ) } -@Preview -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true) @Composable private fun Preview_NetworkBlock() { TangemThemePreviewRedesign { Column { NetworkBlock( + onNetworkSelectClick = {}, chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result( networkUMList = persistentListOf( NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22), @@ -182,6 +195,7 @@ private fun Preview_NetworkBlock() { ) SpacerH12() NetworkBlock( + onNetworkSelectClick = {}, chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result( networkUMList = persistentListOf( NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22), @@ -192,6 +206,7 @@ private fun Preview_NetworkBlock() { ) SpacerH12() NetworkBlock( + onNetworkSelectClick = {}, chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result( networkUMList = List(15) { NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22) @@ -199,9 +214,9 @@ private fun Preview_NetworkBlock() { ), ) SpacerH12() - NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading) + NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, onNetworkSelectClick = {}) SpacerH12() - NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty) + NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, onNetworkSelectClick = {}) } } } \ 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 index 8ebe82cc0f..aadf4f91ca 100644 --- 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 @@ -3,11 +3,7 @@ package com.tangem.features.addressbook.addaddress.ui import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -19,7 +15,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.SpacerW8 import com.tangem.core.ui.components.fields.SimpleTextField import com.tangem.core.ui.ds.image.TangemIcon import com.tangem.core.ui.ds.image.TangemIconUM @@ -28,13 +23,14 @@ import com.tangem.core.ui.ds2.row.TangemRow import com.tangem.core.ui.ds2.row.TangemRowContentLead import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled -import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM +import com.tangem.core.ui.res.generated.icons.ic_scan_20 +import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM @Composable internal fun RecipientRow( @@ -43,19 +39,23 @@ internal fun RecipientRow( onAddressClear: () -> Unit, onQrClick: () -> Unit, onPasteClick: () -> Unit, + modifier: Modifier = Modifier, ) { Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(16.dp)) + modifier = modifier + .clip(RoundedCornerShape(24.dp)) .fillMaxWidth() .background(TangemTheme.colors3.bg.secondary), ) { Text( - modifier = Modifier.padding(start = 16.dp, top = 16.dp), - text = stringResourceSafe(R.string.common_address), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors3.text.secondary, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + text = addressField.label.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = if (addressField.isError) { + TangemTheme.colors3.text.status.error + } else { + TangemTheme.colors3.text.secondary + }, ) TangemRow( modifier = Modifier.fillMaxWidth(), @@ -64,7 +64,7 @@ internal fun RecipientRow( startSlot = { TangemIcon( modifier = Modifier - .size(36.dp) + .size(40.dp) .clip(CircleShape) .background(TangemTheme.colors3.bg.tertiary), tangemIconUM = TangemIconUM.Ident(text = addressField.value), @@ -72,43 +72,55 @@ internal fun RecipientRow( }, titleSlot = { SimpleTextField( - modifier = Modifier - .weight(1f) - .padding(start = 12.dp), + modifier = Modifier.weight(1f), value = addressField.value, onValueChange = onValueChange, - placeholder = TextReference.Res(R.string.address_book_enter_address), - singleLine = false, + placeholder = addressField.placeholder, ) }, endSlot = { - if (addressField.value.isNotEmpty()) { - Icon( - modifier = Modifier.clickable(onClick = onAddressClear), - imageVector = Icons.ic_cross_circle_20_filled, - tint = TangemTheme.colors3.icon.tertiary, - contentDescription = null, - ) - } else { - Row { - TangemButton( - variant = TangemButton.Variant.Secondary, - iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_qrcode_scaner_24), - onClick = onQrClick, - ) - SpacerW8() - TangemButton( - variant = TangemButton.Variant.Primary, - text = TextReference.Res(id = R.string.common_paste), - onClick = onPasteClick, - ) - } - } + RecipientEndSlot( + hasValue = addressField.value.isNotEmpty(), + onAddressClear = onAddressClear, + onQrClick = onQrClick, + onPasteClick = onPasteClick, + ) }, ) } } +@Composable +private fun RecipientEndSlot( + hasValue: Boolean, + onAddressClear: () -> Unit, + onQrClick: () -> Unit, + onPasteClick: () -> Unit, +) { + if (hasValue) { + Icon( + modifier = Modifier + .clip(CircleShape) + .clickable(onClick = onAddressClear), + imageVector = Icons.ic_cross_circle_20_filled, + tint = TangemTheme.colors3.icon.tertiary, + contentDescription = null, + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TangemButton( + variant = TangemButton.Variant.Secondary, + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_scan_20), + onClick = onQrClick, + ) + TangemButton( + text = TextReference.Res(id = R.string.common_paste), + onClick = onPasteClick, + ) + } + } +} + @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable 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/ui/state/AddAddressUM.kt similarity index 62% rename from features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddAddressUM.kt rename to features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt index d54c5e1716..45704c2355 100644 --- 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/ui/state/AddAddressUM.kt @@ -1,14 +1,13 @@ -package com.tangem.features.addressbook.addaddress.contract +package com.tangem.features.addressbook.addaddress.ui.state import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import com.tangem.core.ui.ds.button.TangemButtonUM -import com.tangem.domain.models.network.Network import kotlinx.collections.immutable.ImmutableList +@Immutable internal data class AddAddressUM( val addressField: AddressFieldUM, - val availableNetworks: ImmutableList, val buttonUM: TangemButtonUM, val chosenNetworkStateUM: ChosenNetworkStateUM, val onAddressChange: (String) -> Unit, @@ -16,15 +15,14 @@ internal data class AddAddressUM( val onPasteClick: () -> Unit, val onQrClick: () -> Unit, val onBackClick: () -> Unit, + val onNetworkClick: () -> Unit, ) { @Immutable - sealed class ChosenNetworkStateUM { - data object Loading : ChosenNetworkStateUM() - data object Empty : ChosenNetworkStateUM() - data class Result( - val networkUMList: ImmutableList, - ) : ChosenNetworkStateUM() { + sealed interface 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, 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/ui/state/AddressFieldUM.kt similarity index 54% rename from features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddressFieldUM.kt rename to features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddressFieldUM.kt index ea8e324bd7..2e65e1b381 100644 --- 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/ui/state/AddressFieldUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.addressbook.addaddress.contract +package com.tangem.features.addressbook.addaddress.ui.state import com.tangem.core.ui.extensions.TextReference @@ -7,7 +7,4 @@ internal data class AddressFieldUM( val placeholder: TextReference, val label: TextReference, val isError: Boolean = false, - val error: TextReference? = null, - val isValuePasted: Boolean = false, - val blockchainAddress: String? = null, ) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt new file mode 100644 index 0000000000..3283d11e76 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt @@ -0,0 +1,56 @@ +package com.tangem.features.addressbook.common + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent +import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import com.tangem.features.addressbook.list.DefaultAddressBookListComponent +import com.tangem.features.addressbook.route.AddressBookRoute +import kotlinx.collections.immutable.persistentListOf +import javax.inject.Inject + +/** + * Builds the child screens of the address book feature for a given [AddressBookRoute], wiring their callbacks to the + * container's [AddressBookClickIntents]. Mirrors the `FeedEntryChildFactory` pattern used by the feed feature. + */ +internal class AddressBookChildFactory @Inject constructor() { + + fun createChild( + route: AddressBookRoute, + context: AppComponentContext, + clickIntents: AddressBookClickIntents, + ): ComposableContentComponent = when (route) { + AddressBookRoute.List -> DefaultAddressBookListComponent( + appComponentContext = context, + params = DefaultAddressBookListComponent.Params( + onContactClick = { clickIntents.onContactClick(ContactId(it)) }, + onAddContactClick = clickIntents::onAddContactClick, + ), + ) + is AddressBookRoute.EditContact -> DefaultEditContactComponent( + appComponentContext = context, + params = DefaultEditContactComponent.Params( + contactId = route.contactId?.let(::ContactId), + predefinedAddress = buildPredefinedAddress(route), + onBackClick = clickIntents::onEditContactBack, + onAddAddressClick = clickIntents::onAddAddressClick, + ), + ) + AddressBookRoute.AddAddress -> DefaultAddAddressComponent( + appComponentContext = context, + params = DefaultAddAddressComponent.Params( + onBackClick = clickIntents::onAddAddressBack, + onConfirm = clickIntents::onAddressConfirmed, + ), + ) + } + + /** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */ + private fun buildPredefinedAddress(route: AddressBookRoute.EditContact): ValidatedAddress? { + val address = route.predefinedAddress ?: return null + val networkId = route.predefinedNetworkId ?: return null + return ValidatedAddress(address = address, networkIds = persistentListOf(networkId)) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt new file mode 100644 index 0000000000..5b13a5204f --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt @@ -0,0 +1,26 @@ +package com.tangem.features.addressbook.common + +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress + +/** + * Navigation/click contract that the container ([DefaultAddressBookComponent]) implements and passes down to its + * children through [AddressBookChildFactory]. Keeping all cross-screen intents in one place removes the need for the + * children to know about each other or about navigation. + * + * Result delivery (the confirmed address) is handled out-of-band by [AddressBookResultHolder], not by this contract. + */ +internal interface AddressBookClickIntents { + + fun onContactClick(contactId: ContactId) + + fun onAddContactClick() + + fun onEditContactBack() + + fun onAddAddressClick() + + fun onAddAddressBack() + + fun onAddressConfirmed(address: ValidatedAddress) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt new file mode 100644 index 0000000000..1ba1423a22 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt @@ -0,0 +1,29 @@ +package com.tangem.features.addressbook.common + +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Carries a [ValidatedAddress] confirmed on the AddAddress screen over to the EditContact screen. + * + * The two screens live in independent model scopes, so a shared singleton holder is used to hand the result over + * instead of routing it through navigation/click intents. The producer calls [setConfirmedAddress]; the consumer + * observes [confirmedAddress] and calls [clear] after applying the value so it is not re-applied on resubscription. + */ +@Singleton +internal class AddressBookResultHolder @Inject constructor() { + + val confirmedAddress: StateFlow + field = MutableStateFlow(null) + + fun setConfirmedAddress(address: ValidatedAddress) { + confirmedAddress.value = address + } + + fun clear() { + confirmedAddress.value = null + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt new file mode 100644 index 0000000000..462aec3631 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt @@ -0,0 +1,117 @@ +package com.tangem.features.addressbook.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.slide +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.pushNew +import com.tangem.common.routing.entity.AddressBookOpenMode +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.features.addressbook.AddressBookComponent +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import com.tangem.features.addressbook.route.AddressBookRoute +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddressBookComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: AddressBookComponent.Params, + private val childFactory: AddressBookChildFactory, + private val resultHolder: AddressBookResultHolder, +) : AddressBookComponent, AppComponentContext by context { + + private val navigation = StackNavigation() + + init { + // Drop any address left over from a previous session before the (possibly preloaded) stack starts collecting. + resultHolder.clear() + } + + private val clickIntents = object : AddressBookClickIntents { + + override fun onContactClick(contactId: ContactId) { + navigation.pushNew(AddressBookRoute.EditContact(contactId = contactId.value)) + } + + override fun onAddContactClick() { + navigation.pushNew(AddressBookRoute.EditContact()) + } + + override fun onEditContactBack() { + navigation.pop() + } + + override fun onAddAddressClick() { + navigation.pushNew(AddressBookRoute.AddAddress) + } + + override fun onAddAddressBack() { + navigation.pop() + } + + override fun onAddressConfirmed(address: ValidatedAddress) { + resultHolder.setConfirmedAddress(address) + navigation.pop() + } + } + + private val contentStack = childStack( + key = "address_book_stack", + source = navigation, + serializer = AddressBookRoute.serializer(), + initialStack = ::initialStack, + handleBackButton = false, + childFactory = ::screenChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val childStack by contentStack.subscribeAsState() + Children( + modifier = modifier, + stack = childStack, + animation = stackAnimation(slide()), + ) { child -> + child.instance.Content(Modifier) + } + } + + private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent { + return childFactory.createChild( + route = config, + context = childByContext(componentContext), + clickIntents = clickIntents, + ) + } + + private fun initialStack(): List = when (val mode = params.addressBookOpenMode) { + AddressBookOpenMode.Default -> listOf(AddressBookRoute.List) + is AddressBookOpenMode.WithContactCreation -> listOf( + AddressBookRoute.List, + // Address + network are already known, so open the new contact with that address attached — no AddAddress. + AddressBookRoute.EditContact( + predefinedAddress = mode.address, + predefinedNetworkId = mode.networkId, + ), + ) + } + + @AssistedFactory + interface Factory : AddressBookComponent.Factory { + override fun create( + context: AppComponentContext, + params: AddressBookComponent.Params, + ): DefaultAddressBookComponent + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/DefaultAddressBookFeatureToggles.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookFeatureToggles.kt similarity index 78% rename from features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/DefaultAddressBookFeatureToggles.kt rename to features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookFeatureToggles.kt index 08247d086c..c3ddb2f1b9 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/DefaultAddressBookFeatureToggles.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookFeatureToggles.kt @@ -1,7 +1,8 @@ -package com.tangem.features.addressbook +package com.tangem.features.addressbook.common import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.addressbook.AddressBookFeatureToggles internal class DefaultAddressBookFeatureToggles( private val featureTogglesManager: FeatureTogglesManager, 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 deleted file mode 100644 index 815ebd2498..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.features.addressbook.component - -import kotlinx.serialization.Serializable - -@Serializable -internal sealed class AddressBookRoute { - - @Serializable - data object List : AddressBookRoute() - - /** - * if [contactId] is not null we should fetch existing contact - */ - @Serializable - data class EditContact( - val contactId: String? = null, - ) : AddressBookRoute() - - @Serializable - data object AddAddress : AddressBookRoute() -} \ 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 deleted file mode 100644 index e843ffd769..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.tangem.features.addressbook.component - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.extensions.compose.stack.Children -import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.stack.StackNavigation -import com.arkivanov.decompose.router.stack.childStack -import com.arkivanov.decompose.router.stack.pop -import com.arkivanov.decompose.router.stack.pushNew -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.childByContext -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.addressbook.model.ContactId -import com.tangem.features.addressbook.AddressBookComponent -import com.tangem.features.addressbook.addaddress.AddAddressComponent -import com.tangem.features.addressbook.editcontact.EditContactComponent -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress -import com.tangem.features.addressbook.list.AddressBookListComponent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultAddressBookComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted private val params: AddressBookComponent.Params, - private val addressBookListComponentFactory: AddressBookListComponent.Factory, - private val editContactComponentFactory: EditContactComponent.Factory, - private val addAddressComponentFactory: AddAddressComponent.Factory, -) : AddressBookComponent, AppComponentContext by context { - - private val navigation = StackNavigation() - - /** - * Consumer for the address entered on the [AddressBookRoute.AddAddress] screen, registered by the EditContact - * screen when it requests adding an address and invoked when AddAddress confirms. Transient by design — the - * entered addresses live only in EditContact's in-memory state until the contact is saved. - */ - private var pendingAddressSink: ((ValidatedAddress) -> Unit)? = null - - private val contentStack = childStack( - key = "address_book_stack", - source = navigation, - serializer = AddressBookRoute.serializer(), - initialConfiguration = AddressBookRoute.List, - handleBackButton = false, - childFactory = ::screenChild, - ) - - @Suppress("ReusedModifierInstance") - @Composable - override fun Content(modifier: Modifier) { - val childStack by contentStack.subscribeAsState() - - Children(stack = childStack, animation = stackAnimation()) { child -> - child.instance.Content(modifier = modifier) - } - } - - private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent = - when (config) { - AddressBookRoute.List -> addressBookListComponentFactory.create( - context = childByContext(componentContext), - params = AddressBookListComponent.Params( - onContactClick = { contactId -> - navigation.pushNew(AddressBookRoute.EditContact(contactId)) - }, - onAddContactClick = { navigation.pushNew(AddressBookRoute.EditContact()) }, - ), - ) - is AddressBookRoute.EditContact -> editContactComponentFactory.create( - context = childByContext(componentContext), - params = EditContactComponent.Params( - contactId = config.contactId?.let(::ContactId), - onBackClick = { navigation.pop() }, - onAddAddressClick = { onResult -> - pendingAddressSink = onResult - navigation.pushNew(AddressBookRoute.AddAddress) - }, - ), - ) - AddressBookRoute.AddAddress -> addAddressComponentFactory.create( - context = childByContext(componentContext), - params = AddAddressComponent.Params( - onBackClick = { - pendingAddressSink = null - navigation.pop() - }, - onConfirm = { address -> - pendingAddressSink?.invoke(address) - pendingAddressSink = null - navigation.pop() - }, - ), - ) - } - - @AssistedFactory - interface Factory : AddressBookComponent.Factory { - override fun create( - context: AppComponentContext, - params: AddressBookComponent.Params, - ): DefaultAddressBookComponent - } -} \ No newline at end of file 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 493422e7c2..868ed604e7 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,13 +1,7 @@ package com.tangem.features.addressbook.di import com.tangem.features.addressbook.AddressBookComponent -import com.tangem.features.addressbook.addaddress.AddAddressComponent -import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent -import com.tangem.features.addressbook.component.DefaultAddressBookComponent -import com.tangem.features.addressbook.list.AddressBookListComponent -import com.tangem.features.addressbook.list.DefaultAddressBookListComponent -import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent -import com.tangem.features.addressbook.editcontact.EditContactComponent +import com.tangem.features.addressbook.common.DefaultAddressBookComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -21,18 +15,4 @@ internal interface AddressBookComponentModule { @Binds @Singleton fun bindAddressBookComponentFactory(factory: DefaultAddressBookComponent.Factory): AddressBookComponent.Factory - - @Binds - @Singleton - fun bindAddressBookListComponentFactory( - factory: DefaultAddressBookListComponent.Factory, - ): AddressBookListComponent.Factory - - @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/AddressBookModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt index 4594968b09..3ca882b9ec 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt @@ -2,7 +2,7 @@ package com.tangem.features.addressbook.di import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.addressbook.AddressBookFeatureToggles -import com.tangem.features.addressbook.DefaultAddressBookFeatureToggles +import com.tangem.features.addressbook.common.DefaultAddressBookFeatureToggles import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt index 8f83105d52..7fa44a861e 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt @@ -7,16 +7,16 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.addressbook.model.ContactId import com.tangem.features.addressbook.editcontact.model.EditContactModel import com.tangem.features.addressbook.editcontact.ui.EditContactContent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress -internal class DefaultEditContactComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted params: EditContactComponent.Params, -) : EditContactComponent, AppComponentContext by context { +internal class DefaultEditContactComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: EditContactModel = getOrCreateModel(params) @@ -30,11 +30,10 @@ internal class DefaultEditContactComponent @AssistedInject constructor( BackHandler(onBack = state.onCloseClick) } - @AssistedFactory - interface Factory : EditContactComponent.Factory { - override fun create( - context: AppComponentContext, - params: EditContactComponent.Params, - ): DefaultEditContactComponent - } + data class Params( + val contactId: ContactId?, + val predefinedAddress: ValidatedAddress? = null, + val onBackClick: () -> Unit, + val onAddAddressClick: () -> Unit, + ) } \ 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 deleted file mode 100644 index 2e93f5ff24..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.features.addressbook.editcontact - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.addressbook.model.ContactId -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress - -internal interface EditContactComponent : ComposableContentComponent { - - interface Factory : ComponentFactory - - 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/ValidatedAddress.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt deleted file mode 100644 index 87a094ee60..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.features.addressbook.editcontact.contract - -import com.tangem.domain.models.network.Network - -/** - * A recipient address that has been validated and resolved to a [Network] on the AddAddress screen. - * - * This is the in-progress (pre-save) representation accumulated in [EditContactUM]. It is converted to a domain - * `AddressEntry` only when the contact is persisted, since the entry's id and signature are produced at save time. - */ -data class ValidatedAddress( - val address: String, - val network: Network, -) \ 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 d4bcdf27ab..90f414fabf 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 @@ -1,80 +1,79 @@ package com.tangem.features.addressbook.editcontact.model -import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.R -import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.features.addressbook.editcontact.EditContactComponent -import com.tangem.features.addressbook.editcontact.contract.EditContactUM -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress +import com.tangem.features.addressbook.common.AddressBookResultHolder +import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent +import com.tangem.features.addressbook.editcontact.state.EditContactStateController +import com.tangem.features.addressbook.editcontact.state.transformers.AddValidatedAddressTransformer +import com.tangem.features.addressbook.editcontact.state.transformers.SelectContactColorTransformer +import com.tangem.features.addressbook.editcontact.state.transformers.UpdateContactNameTransformer +import com.tangem.features.addressbook.editcontact.state.transformers.UpdateEditContactInitialStateTransformer +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject @ModelScoped internal class EditContactModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + private val stateController: EditContactStateController, + private val resultHolder: AddressBookResultHolder, ) : Model() { - private val params: EditContactComponent.Params = paramsContainer.require() + private val params: DefaultEditContactComponent.Params = paramsContainer.require() - val state: StateFlow - field = MutableStateFlow(getInitialState()) + val state: StateFlow get() = stateController.uiState + + init { + updateInitialState() + prefillPredefinedAddress() + subscribeToConfirmedAddresses() + } + + /** In WithContactCreation mode the contact opens with the already-known address attached. */ + private fun prefillPredefinedAddress() { + params.predefinedAddress?.let(::addAddress) + } + + private fun updateInitialState() { + stateController.update( + UpdateEditContactInitialStateTransformer( + isExistingContact = params.contactId != null, + onNameChange = ::onNameChange, + onColorSelect = ::onColorSelect, + onCloseClick = params.onBackClick, + onAddAddressClick = params.onAddAddressClick, + ), + ) + } + + private fun subscribeToConfirmedAddresses() { + resultHolder.confirmedAddress + .filterNotNull() + .onEach { address -> + addAddress(address) + resultHolder.clear() + } + .launchIn(modelScope) + } private fun onNameChange(name: String) { - state.update { it.copy(name = name) } + stateController.update(UpdateContactNameTransformer(name = name)) } private fun onColorSelect(color: CryptoPortfolioIcon.Color) { - state.update { oldState -> - oldState.copy( - colors = oldState.colors.copy(selected = color), - portfolioIcon = oldState.portfolioIcon.copy(color = color), - ) - } - } - - private fun requestAddAddress() { - params.onAddAddressClick(::addAddress) + stateController.update(SelectContactColorTransformer(color = color)) } private fun addAddress(address: ValidatedAddress) { - state.update { it.copy(addresses = (it.addresses + address).toImmutableList()) } - } - - private fun getInitialState(): EditContactUM { - val colors = CryptoPortfolioIcon.Color.entries.toImmutableList() - val selectedColor = colors.first() - val titleResId = if (params.contactId == null) { - R.string.address_book_new_contact - } else { - R.string.address_book_contact - } - return EditContactUM( - title = resourceReference(titleResId), - name = "", - namePlaceholder = resourceReference(R.string.address_book_new_contact), - portfolioIcon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = selectedColor, - ), - colors = EditContactUM.Colors( - selected = selectedColor, - list = colors, - onColorSelect = ::onColorSelect, - ), - addresses = persistentListOf(), - onNameChange = ::onNameChange, - onCloseClick = params.onBackClick, - onAddAddressClick = ::requestAddAddress, - ) + stateController.update(AddValidatedAddressTransformer(address = address)) } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt new file mode 100644 index 0000000000..566148a41d --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt @@ -0,0 +1,52 @@ +package com.tangem.features.addressbook.editcontact.state + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class EditContactStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) + + val uiState: StateFlow get() = mutableUiState.asStateFlow() + + fun update(transformer: Transformer) { + mutableUiState.update(function = transformer::transform) + } + + private fun getInitialState(): EditContactUM { + val colors = CryptoPortfolioIcon.Color.entries.toImmutableList() + val selectedColor = colors.first() + return EditContactUM( + title = TextReference.EMPTY, + name = "", + namePlaceholder = resourceReference(R.string.address_book_new_contact), + portfolioIcon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = selectedColor, + ), + colors = EditContactUM.Colors( + selected = selectedColor, + list = colors, + onColorSelect = {}, + ), + addresses = persistentListOf(), + onNameChange = {}, + onCloseClick = {}, + onAddAddressClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt new file mode 100644 index 0000000000..232b07210f --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.addressbook.editcontact.state.transformers + +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toImmutableList + +internal class AddValidatedAddressTransformer( + private val address: ValidatedAddress, +) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + // Skip duplicates: an address is identified by its string value (it already carries all its networks). + if (prevState.addresses.any { it.address == address.address }) return prevState + return prevState.copy( + addresses = (prevState.addresses + address).toImmutableList(), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SelectContactColorTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SelectContactColorTransformer.kt new file mode 100644 index 0000000000..fa2e4a0572 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SelectContactColorTransformer.kt @@ -0,0 +1,17 @@ +package com.tangem.features.addressbook.editcontact.state.transformers + +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer + +internal class SelectContactColorTransformer( + private val color: CryptoPortfolioIcon.Color, +) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + return prevState.copy( + colors = prevState.colors.copy(selected = color), + portfolioIcon = prevState.portfolioIcon.copy(color = color), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateContactNameTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateContactNameTransformer.kt new file mode 100644 index 0000000000..8b61afce92 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateContactNameTransformer.kt @@ -0,0 +1,13 @@ +package com.tangem.features.addressbook.editcontact.state.transformers + +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateContactNameTransformer( + private val name: String, +) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + return prevState.copy(name = name) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt new file mode 100644 index 0000000000..26d8bdd3c8 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt @@ -0,0 +1,35 @@ +package com.tangem.features.addressbook.editcontact.state.transformers + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer + +/** + * Wires the title (derived from whether an existing contact is being edited) and the callbacks owned by + * [com.tangem.features.addressbook.editcontact.model.EditContactModel] into the initial state. + */ +internal class UpdateEditContactInitialStateTransformer( + private val isExistingContact: Boolean, + private val onNameChange: (String) -> Unit, + private val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit, + private val onCloseClick: () -> Unit, + private val onAddAddressClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + val titleResId = if (isExistingContact) { + R.string.address_book_contact + } else { + R.string.address_book_new_contact + } + return prevState.copy( + title = resourceReference(titleResId), + colors = prevState.colors.copy(onColorSelect = onColorSelect), + onNameChange = onNameChange, + onCloseClick = onCloseClick, + onAddAddressClick = onAddAddressClick, + ) + } +} \ 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 e6d2f4a0ab..c45223210c 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 @@ -1,20 +1,16 @@ package com.tangem.features.addressbook.editcontact.ui import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach @@ -22,19 +18,27 @@ import com.tangem.common.ui.account.AccountIcon import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.getUiColor import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.fields.AutoSizeTextField +import com.tangem.core.ui.ds.image.TangemIcon import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowText +import com.tangem.core.ui.ds2.row.TangemRowTextRole +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20 import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.features.addressbook.editcontact.contract.EditContactUM -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -63,118 +67,126 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif Column( modifier = Modifier - .padding(horizontal = 16.dp) - .weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp), + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { ContactSummary(state = state) ContactColor(colors = state.colors) - ContactAddresses(addresses = state.addresses) - AddAddressRow(onClick = state.onAddAddressClick) - } - } -} - -@Composable -private fun ContactAddresses(addresses: ImmutableList) { - if (addresses.isEmpty()) return - Column( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .fillMaxWidth() - .background(TangemTheme.colors3.bg.secondary), - ) { - addresses.fastForEach { entry -> - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + BlockCard( + shape = RoundedCornerShape(24.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary), ) { - Text( - text = entry.network.name, - style = TangemTheme.typography3.caption.medium, - color = TangemTheme.colors3.text.tertiary, - ) - Text( - text = entry.address, - style = TangemTheme.typography3.body.medium, - color = TangemTheme.colors3.text.primary, - maxLines = 1, - ) + ContactAddresses(addresses = state.addresses) + AddAddressRow(onClick = state.onAddAddressClick) } } } } @Composable -private fun AddAddressRow(onClick: () -> Unit) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .fillMaxWidth() - .background(TangemTheme.colors3.bg.secondary) - .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 15.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(TangemTheme.colors3.bg.status.infoSubtle), - ) { - Icon( - modifier = Modifier.size(18.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_plus_24), - tint = TangemTheme.colors3.text.status.info, - contentDescription = null, - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = stringResourceSafe(R.string.address_book_add_address), - style = TangemTheme.typography3.body.medium, - color = TangemTheme.colors3.text.primary, - ) - Text( - text = stringResourceSafe(R.string.address_book_add_address_description), - style = TangemTheme.typography3.caption.medium, - color = TangemTheme.colors3.text.tertiary, - ) - } +private fun ContactAddresses(addresses: ImmutableList) { + addresses.fastForEach { entry -> + AddressRow(entry = entry) } } +@Composable +private fun AddressRow(entry: ValidatedAddress) { + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + startSlot = { + TangemIcon( + tangemIconUM = TangemIconUM.Ident(text = entry.address), + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + ) + }, + titleSlot = { + TangemRowText( + text = stringReference(entry.address), + role = TangemRowTextRole.Title, + overflow = TextOverflow.MiddleEllipsis, + ) + }, + subtitleSlot = { + TangemRowText( + text = pluralReference( + id = R.plurals.common_networks_count, + count = entry.networkIds.size, + formatArgs = wrappedList(entry.networkIds.size), + ), + role = TangemRowTextRole.Subtitle, + ) + }, + ) +} + +@Composable +private fun AddAddressRow(onClick: () -> Unit) { + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + onClick = onClick, + startSlot = { + TangemIcon( + tangemIconUM = TangemIconUM.Icon( + imageVector = Icons.ic_sign_plus_20, + tintReference = { TangemTheme.colors3.icon.brand }, + ), + modifier = Modifier + .size(40.dp) + .background( + color = TangemTheme.colors3.bg.status.infoSubtle, + shape = RoundedCornerShape(10.dp), + ) + .padding(8.dp), + ) + }, + titleSlot = { + TangemRowText( + text = TextReference.Res(R.string.address_book_add_address), + role = TangemRowTextRole.Title, + ) + }, + subtitleSlot = { + TangemRowText( + text = TextReference.Res(R.string.address_book_add_address_description), + role = TangemRowTextRole.Subtitle, + ) + }, + ) +} + @Composable private fun ContactSummary(state: EditContactUM) { val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() } Column( modifier = Modifier - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(24.dp)) .fillMaxWidth() - .background(TangemTheme.colors3.bg.secondary), + .background(TangemTheme.colors3.bg.secondary) + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(modifier = Modifier.height(24.dp)) + SpacerH(20.dp) AccountIcon( name = stringReference(avatarName), icon = state.portfolioIcon, - size = AccountIconSize.Large, + size = AccountIconSize.RedesignLarge, ) - Spacer(modifier = Modifier.height(24.dp)) + + SpacerH(28.dp) Text( text = stringResourceSafe(R.string.address_book_contact_name), style = TangemTheme.typography3.caption.medium, - color = TangemTheme.colors3.text.tertiary, + color = TangemTheme.colors3.text.secondary, ) - Spacer(modifier = Modifier.height(2.dp)) + + SpacerH(4.dp) AutoSizeTextField( value = state.name, @@ -186,7 +198,7 @@ private fun ContactSummary(state: EditContactUM) { color = TangemTheme.colors3.text.primary, placeholderColor = TangemTheme.colors3.text.tertiary, ) - Spacer(modifier = Modifier.height(20.dp)) + SpacerH(8.dp) } } @@ -196,16 +208,16 @@ private fun ContactSummary(state: EditContactUM) { private fun ContactColor(colors: EditContactUM.Colors) { Box( modifier = Modifier - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(24.dp)) .fillMaxWidth() - .background(TangemTheme.colors3.bg.secondary), + .background(TangemTheme.colors3.bg.secondary) + .padding(16.dp), ) { FlowRow( maxItemsInEachRow = 6, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(18.dp), ) { colors.list.fastForEach { color -> val isSelected = color == colors.selected @@ -219,12 +231,12 @@ private fun ContactColor(colors: EditContactUM.Colors) { if (isSelected) { Box( modifier = Modifier - .size(47.dp) + .size(48.dp) .border(2.dp, color.getUiColor(), shape = CircleShape), ) Box( modifier = Modifier - .size(36.dp) + .size(38.dp) .background(color = color.getUiColor(), shape = CircleShape), ) } else { @@ -260,7 +272,12 @@ private fun Preview_EditContactContent() { list = colors, onColorSelect = {}, ), - addresses = persistentListOf(), + addresses = persistentListOf( + ValidatedAddress( + address = "0x1234567890abcdef1234567890abcdef12345678", + networkIds = persistentListOf("ethereum", "bsc", "polygon"), + ), + ), onNameChange = {}, onCloseClick = {}, onAddAddressClick = {}, 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/ui/state/EditContactUM.kt similarity index 87% rename from features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt rename to features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/EditContactUM.kt index 9efd8db0e2..55793600ad 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/ui/state/EditContactUM.kt @@ -1,10 +1,12 @@ -package com.tangem.features.addressbook.editcontact.contract +package com.tangem.features.addressbook.editcontact.ui.state +import androidx.compose.runtime.Immutable import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.account.CryptoPortfolioIcon import kotlinx.collections.immutable.ImmutableList +@Immutable internal data class EditContactUM( val title: TextReference, val name: String, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt new file mode 100644 index 0000000000..8d36e4c6a8 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt @@ -0,0 +1,17 @@ +package com.tangem.features.addressbook.editcontact.ui.state + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +/** + * A recipient address validated on the AddAddress screen, together with the networks it resolves to. + * + * A single address can belong to several networks (e.g. the same address across EVM chains), so it carries a list of + * [networkIds]. This is the in-progress (pre-save) representation accumulated in [EditContactUM]; the [networkIds] are + * used to rebuild the domain `AddressEntry`s when the contact is persisted. + */ +@Immutable +data class ValidatedAddress( + val address: String, + val networkIds: ImmutableList, +) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/AddressBookListComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/AddressBookListComponent.kt deleted file mode 100644 index 0072a79d9f..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/AddressBookListComponent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.features.addressbook.list - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent - -internal interface AddressBookListComponent : ComposableContentComponent { - - interface Factory : ComponentFactory - - data class Params( - val onContactClick: (String) -> Unit, - val onAddContactClick: () -> Unit, - ) -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt index 63733b90f9..6214fdbc75 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt @@ -1,22 +1,22 @@ package com.tangem.features.addressbook.list +import androidx.compose.foundation.background import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.addressbook.list.contract.AddressBookListUM +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.TangemTheme import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM -internal class DefaultAddressBookListComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted val params: AddressBookListComponent.Params, -) : AddressBookListComponent, AppComponentContext by context { +internal class DefaultAddressBookListComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: AddressBookListModel = getOrCreateModel(params) @@ -25,19 +25,16 @@ internal class DefaultAddressBookListComponent @AssistedInject constructor( val state by model.state.collectAsStateWithLifecycle() when (val addressBookListUM = state) { is AddressBookListUM.Empty -> AddressBookEmptyScreen( - tangemButtonUM = addressBookListUM.tangemButtonUM, + onAddContactClick = addressBookListUM.onAddClick, onBackClick = router::pop, - modifier = modifier, + modifier = modifier.background(TangemTheme.colors3.bg.primary), ) is AddressBookListUM.AddressList -> TODO("[REDACTED_TASK_KEY]") } } - @AssistedFactory - interface Factory : AddressBookListComponent.Factory { - override fun create( - context: AppComponentContext, - params: AddressBookListComponent.Params, - ): DefaultAddressBookListComponent - } + data class Params( + val onContactClick: (String) -> Unit, + val onAddContactClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt deleted file mode 100644 index 4c0c74bab4..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.features.addressbook.list.contract - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.ds.button.TangemButtonUM -import com.tangem.domain.addressbook.model.Contact -import kotlinx.collections.immutable.ImmutableList - -@Immutable -internal sealed class AddressBookListUM { - - data class Empty( - val tangemButtonUM: TangemButtonUM, - ) : AddressBookListUM() - data class AddressList(val contacts: ImmutableList) : AddressBookListUM() -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index 27039d82aa..6d269a7616 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -3,18 +3,11 @@ package com.tangem.features.addressbook.list.model import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.R -import com.tangem.core.ui.R.drawable.ic_plus_24 -import com.tangem.core.ui.ds.button.TangemButtonIconPosition -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM -import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.addressbook.list.AddressBookListComponent -import com.tangem.features.addressbook.list.contract.AddressBookListUM +import com.tangem.features.addressbook.list.DefaultAddressBookListComponent +import com.tangem.features.addressbook.list.state.AddressBookListStateController +import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListInitialStateTransformer +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject @@ -22,22 +15,16 @@ import javax.inject.Inject internal class AddressBookListModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + private val stateController: AddressBookListStateController, ) : Model() { - private val params = paramsContainer.require() + private val params = paramsContainer.require() - val state: StateFlow = MutableStateFlow( - AddressBookListUM.Empty( - tangemButtonUM = TangemButtonUM( - text = TextReference.Res(R.string.address_book_new_contact), - tangemIconUM = TangemIconUM.Icon( - iconRes = ic_plus_24, - tintReference = { TangemTheme.colors3.text.inverse.primary }, - ), - iconPosition = TangemButtonIconPosition.End, - type = TangemButtonType.Primary, - onClick = params.onAddContactClick, - ), - ), - ) + val state: StateFlow get() = stateController.uiState + + init { + stateController.update( + UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick), + ) + } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt new file mode 100644 index 0000000000..f42ef0cd71 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt @@ -0,0 +1,25 @@ +package com.tangem.features.addressbook.list.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.utils.transformer.Transformer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class AddressBookListStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = + MutableStateFlow(value = getInitialState()) + + val uiState: StateFlow get() = mutableUiState.asStateFlow() + + fun update(transformer: Transformer) { + mutableUiState.update(function = transformer::transform) + } + + private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {}) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt new file mode 100644 index 0000000000..e50783cb34 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt @@ -0,0 +1,21 @@ +package com.tangem.features.addressbook.list.state.converter + +import com.tangem.domain.addressbook.model.Contact +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.utils.converter.Converter + +/** + * Maps a domain [Contact] to its UI representation [ContactUM]. + * + * TODO AddressBook ([REDACTED_TASK_KEY]): wire into [com.tangem.features.addressbook.list.model.AddressBookListModel] when the contacts list + * is loaded from the repository and the [com.tangem.features.addressbook.list.ui.state.AddressBookListUM.AddressList] + * screen is implemented. + */ +internal class ContactUMConverter : Converter { + + override fun convert(value: Contact): ContactUM = ContactUM( + id = value.id.value, + name = value.name.value, + addressCount = value.addressEntries.size, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt new file mode 100644 index 0000000000..8191d0f1b5 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.addressbook.list.state.transformers + +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.utils.transformer.Transformer + +/** + * Wires the "add contact" callback owned by the container into the initial (empty) list state. + */ +internal class UpdateAddressBookListInitialStateTransformer( + private val onAddContactClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: AddressBookListUM): AddressBookListUM { + return when (prevState) { + is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick) + is AddressBookListUM.AddressList -> prevState + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt index b1045b5cc2..50a6d9a30d 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt @@ -1,6 +1,5 @@ package com.tangem.features.addressbook.list.ui -import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -9,26 +8,26 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R -import com.tangem.core.ui.ds.button.PrimaryTangemButton -import com.tangem.core.ui.ds.button.TangemButtonIconPosition -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20 +import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20 @Composable internal fun AddressBookEmptyScreen( - tangemButtonUM: TangemButtonUM, + onAddContactClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -41,26 +40,19 @@ internal fun AddressBookEmptyScreen( title = resourceReference(R.string.address_book_title), startContent = { TangemButton( - iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24), + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_20), onClick = onBackClick, size = TangemButton.Size.X11, variant = TangemButton.Variant.Material, ) }, ) - NoContactInfo() - PrimaryTangemButton( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), - buttonUM = tangemButtonUM, - ) + NoContactInfo(onAddClick = onAddContactClick) } } @Composable -private fun ColumnScope.NoContactInfo() { +private fun ColumnScope.NoContactInfo(onAddClick: () -> Unit) { Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center, @@ -68,18 +60,24 @@ private fun ColumnScope.NoContactInfo() { ) { ContactImage() Text( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing24), + modifier = Modifier.padding(top = 32.dp), text = stringResourceSafe(R.string.address_book_no_contacts), color = TangemTheme.colors3.text.primary, - style = TangemTheme.typography3.heading.medium, + style = TangemTheme.typography3.heading.small, ) Text( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + modifier = Modifier.padding(top = 8.dp), text = stringResourceSafe(R.string.address_book_no_contacts_description), color = TangemTheme.colors3.text.secondary, - style = TangemTheme.typography3.body.medium, + style = TangemTheme.typography3.subheading.medium, textAlign = TextAlign.Center, ) + TangemButton( + modifier = Modifier.padding(top = 40.dp), + text = resourceReference(R.string.address_book_add_address), + onClick = onAddClick, + iconEnd = TangemIconUM.Icon(imageVector = Icons.ic_sign_plus_20), + ) } } @@ -95,7 +93,7 @@ private fun ContactImage() { contentAlignment = Alignment.Center, ) { Image( - painter = painterResource(R.drawable.ic_contact_20), + imageVector = ImageVector.vectorResource(R.drawable.ic_address_book_24), contentDescription = stringResourceSafe(R.string.address_book_no_contacts), modifier = Modifier.size(28.dp), ) @@ -104,16 +102,11 @@ private fun ContactImage() { @Composable @Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun Preview_AddressBookEmptyScreen() { - AddressBookEmptyScreen( - tangemButtonUM = TangemButtonUM( - text = TextReference.Res(R.string.address_book_new_contact), - tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_plus_24), - iconPosition = TangemButtonIconPosition.End, - type = TangemButtonType.Secondary, - onClick = {}, - ), - onBackClick = {}, - ) + TangemThemePreviewRedesign { + AddressBookEmptyScreen( + onAddContactClick = {}, + onBackClick = {}, + ) + } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt new file mode 100644 index 0000000000..e1b9061014 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt @@ -0,0 +1,12 @@ +package com.tangem.features.addressbook.list.ui.state + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed interface AddressBookListUM { + + data class Empty(val onAddClick: () -> Unit) : AddressBookListUM + + data class AddressList(val contacts: ImmutableList) : AddressBookListUM +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt new file mode 100644 index 0000000000..c5f526a6bc --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt @@ -0,0 +1,11 @@ +package com.tangem.features.addressbook.list.ui.state + +import androidx.compose.runtime.Immutable + +/** UI model of a single address-book contact row. Holds only what the list needs to render — no domain types. */ +@Immutable +internal data class ContactUM( + val id: String, + val name: String, + val addressCount: Int, +) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt new file mode 100644 index 0000000000..c59c929b70 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt @@ -0,0 +1,27 @@ +package com.tangem.features.addressbook.route + +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class AddressBookRoute { + + @Serializable + data object List : AddressBookRoute() + + /** + * if [contactId] is not null we should fetch existing contact. + * + * [predefinedAddress] and [predefinedNetworkId] are set only when the feature is opened in + * [com.tangem.features.addressbook.entity.AddressBookOpenMode.WithContactCreation] mode — the address and its + * network are already known, so the new contact is opened with that address already attached. + */ + @Serializable + data class EditContact( + val contactId: String? = null, + val predefinedAddress: String? = null, + val predefinedNetworkId: String? = null, + ) : AddressBookRoute() + + @Serializable + data object AddAddress : AddressBookRoute() +} \ No newline at end of file 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 index c58bb25f8d..cce48d3bbe 100644 --- 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 @@ -3,24 +3,23 @@ package com.tangem.features.addressbook.addaddress.model import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Blockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.ui.extensions.iconResId import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.R import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.features.addressbook.addaddress.AddAddressComponent -import com.tangem.features.addressbook.addaddress.contract.AddAddressUM -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress +import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent +import com.tangem.features.addressbook.addaddress.state.AddAddressStateController +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.test.mock.MockAccounts import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -28,11 +27,7 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.* @OptIn(ExperimentalCoroutinesApi::class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -73,7 +68,6 @@ internal class AddAddressModelTest { // Assert assertThat(state.addressField.value).isEmpty() - assertThat(state.addressField.isValuePasted).isFalse() assertThat(state.buttonUM.isEnabled).isFalse() } @@ -87,13 +81,11 @@ internal class AddAddressModelTest { model.state.value.onAddressChange(address) // Assert - val field = model.state.value.addressField - assertThat(field.value).isEqualTo(address) - assertThat(field.isValuePasted).isFalse() + assertThat(model.state.value.addressField.value).isEqualTo(address) } @Test - fun `GIVEN empty field WHEN onPasteClick THEN value marked as pasted`() = runTest { + fun `GIVEN empty field WHEN onPasteClick THEN value taken from clipboard`() = runTest { // Arrange val model = createModel(testScope = this) val address = "0xABC" @@ -103,13 +95,10 @@ internal class AddAddressModelTest { model.state.value.onPasteClick() // Assert - val field = model.state.value.addressField - assertThat(field.value).isEqualTo(address) - assertThat(field.isValuePasted).isTrue() + assertThat(model.state.value.addressField.value).isEqualTo(address) } // validateAndConfirm() is an unimplemented seam — the button click must NOT emit a result yet. - // This guards the foundation and will fail (prompting an update) once validation is wired in. @Test fun `GIVEN typed address WHEN button clicked THEN onConfirm not called yet`() = runTest { // Arrange @@ -127,13 +116,12 @@ internal class AddAddressModelTest { @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) - inner class AddressInput { + inner class Validation { @Test - fun `GIVEN coins available WHEN valid address typed THEN matching network chosen`() = runTest { + fun `GIVEN coins available WHEN valid address typed THEN no error AND button enabled`() = runTest { // Arrange - every { multiAccountListSupplier.invoke() } returns - flowOf(listOf(accountListWith(ethereum, bitcoin))) + every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin))) val model = createModel(testScope = this) advanceUntilIdle() @@ -143,16 +131,14 @@ internal class AddAddressModelTest { // Assert val state = model.state.value - assertThat(state.availableNetworks).containsExactly(ethereum.network) - assertThat(state.chosenNetworkStateUM) - .isEqualTo(resultOf(ethereum.network)) + assertThat(state.addressField.isError).isFalse() + assertThat(state.buttonUM.isEnabled).isTrue() } @Test - fun `GIVEN coins available WHEN address matches no network THEN empty state`() = runTest { + fun `GIVEN coins available WHEN address matches no network THEN error AND button disabled`() = runTest { // Arrange - every { multiAccountListSupplier.invoke() } returns - flowOf(listOf(accountListWith(ethereum, bitcoin))) + every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin))) val model = createModel(testScope = this) advanceUntilIdle() @@ -162,31 +148,32 @@ internal class AddAddressModelTest { // Assert val state = model.state.value - assertThat(state.availableNetworks).isEmpty() - assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty) + assertThat(state.addressField.isError).isTrue() + assertThat(state.addressField.label) + .isEqualTo(resourceReference(R.string.address_book_invalid_address_error)) + assertThat(state.buttonUM.isEnabled).isFalse() } @Test - fun `GIVEN no coins available WHEN valid address typed THEN empty state`() = runTest { - // Arrange — supplier emits no accounts. - every { multiAccountListSupplier.invoke() } returns flowOf(emptyList()) + fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest { + // Arrange + every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum))) val model = createModel(testScope = this) advanceUntilIdle() // Act - model.state.value.onAddressChange(VALID_ETH_ADDRESS) + model.state.value.onAddressChange("") advanceUntilIdle() // Assert val state = model.state.value - assertThat(state.availableNetworks).isEmpty() - assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty) + assertThat(state.addressField.isError).isFalse() + assertThat(state.buttonUM.isEnabled).isFalse() } - // Covers the "not initialized yet" case: the address is typed before coins load, and the - // chosen network must resolve reactively once the supplier emits them. + // The address is typed before coins load; validity must resolve reactively once the supplier emits them. @Test - fun `GIVEN address typed before coins load WHEN coins emitted THEN network resolved reactively`() = runTest { + fun `GIVEN address typed before coins load WHEN coins emitted THEN validated reactively`() = runTest { // Arrange val accountsFlow = MutableStateFlow>(emptyList()) every { multiAccountListSupplier.invoke() } returns accountsFlow @@ -197,29 +184,19 @@ internal class AddAddressModelTest { model.state.value.onAddressChange(VALID_ETH_ADDRESS) advanceUntilIdle() // Assert intermediate: nothing to match yet - assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty) + assertThat(model.state.value.buttonUM.isEnabled).isFalse() // Act — coins arrive later accountsFlow.value = listOf(accountListWith(ethereum, bitcoin)) advanceUntilIdle() // Assert - assertThat(model.state.value.chosenNetworkStateUM) - .isEqualTo(resultOf(ethereum.network)) + val state = model.state.value + assertThat(state.buttonUM.isEnabled).isTrue() + assertThat(state.addressField.isError).isFalse() } } - private fun resultOf(vararg networks: Network) = AddAddressUM.ChosenNetworkStateUM.Result( - networkUMList = networks - .map { network -> - AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM( - networkName = network.name, - iconResId = network.iconResId, - ) - } - .toImmutableList(), - ) - private fun accountListWith(vararg currencies: CryptoCurrency): AccountList { val walletId = MockAccounts.userWalletId val accounts = listOf( @@ -239,7 +216,7 @@ internal class AddAddressModelTest { private fun createModel( testScope: TestScope, onConfirm: (ValidatedAddress) -> Unit = {}, - params: AddAddressComponent.Params = AddAddressComponent.Params( + params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params( onBackClick = {}, onConfirm = onConfirm, ), @@ -250,6 +227,7 @@ internal class AddAddressModelTest { dispatchers = testScope.createTestingCoroutineDispatcherProvider(), multiAccountListSupplier = multiAccountListSupplier, clipboardManager = clipboardManager, + stateController = AddAddressStateController(), ).also { model = it } } 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 ebf9acafd1..cdf098ec82 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,23 +8,36 @@ import com.tangem.core.ui.R import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.domain.models.network.Network -import com.tangem.features.addressbook.editcontact.EditContactComponent -import com.tangem.features.addressbook.editcontact.contract.EditContactUM -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress +import com.tangem.features.addressbook.common.AddressBookResultHolder +import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent +import com.tangem.features.addressbook.editcontact.state.EditContactStateController +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @OptIn(ExperimentalCoroutinesApi::class) internal class EditContactModelTest { + private val resultHolder = AddressBookResultHolder() + + private var model: EditContactModel? = null + + @AfterEach + fun tearDown() { + // Cancels modelScope, stopping the confirmed-addresses collector. + model?.onDestroy() + model = null + } + @Test fun `WHEN model created THEN initial state is correct`() = runTest { val expectedColors = CryptoPortfolioIcon.Color.entries.toImmutableList() @@ -57,11 +70,7 @@ internal class EditContactModelTest { @Test fun `GIVEN existing contactId WHEN model created THEN title is contact`() = runTest { // Arrange - val params = EditContactComponent.Params( - contactId = ContactId(value = "contact-id"), - onBackClick = {}, - onAddAddressClick = {}, - ) + val params = createParams(contactId = ContactId(value = "contact-id")) // Act val model = createModel(testScope = this, params = params) @@ -94,38 +103,73 @@ internal class EditContactModelTest { } @Test - fun `GIVEN add address requested WHEN result delivered THEN address appended to state`() = runTest { + fun `GIVEN confirmed address set on holder WHEN collected THEN address appended to state`() = runTest { // Arrange - var capturedSink: ((ValidatedAddress) -> Unit)? = null - val params = EditContactComponent.Params( - contactId = null, - onBackClick = {}, - onAddAddressClick = { onResult -> capturedSink = onResult }, - ) - val model = createModel(testScope = this, params = params) - val validatedAddress = ValidatedAddress(address = "0xABC", network = mockk()) + val model = createModel(testScope = this) + advanceUntilIdle() + val validatedAddress = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")) // Act - model.state.value.onAddAddressClick() - capturedSink?.invoke(validatedAddress) + resultHolder.setConfirmedAddress(validatedAddress) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.addresses).containsExactly(validatedAddress) + // The value must be consumed so it is not re-applied on resubscription. + assertThat(resultHolder.confirmedAddress.value).isNull() + } + + @Test + fun `GIVEN same address confirmed twice WHEN collected THEN added only once`() = runTest { + // Arrange + val model = createModel(testScope = this) + advanceUntilIdle() + val validatedAddress = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")) + + // Act + resultHolder.setConfirmedAddress(validatedAddress) + advanceUntilIdle() + resultHolder.setConfirmedAddress(validatedAddress) + advanceUntilIdle() // Assert assertThat(model.state.value.addresses).containsExactly(validatedAddress) } + @Test + fun `GIVEN predefined address WHEN model created THEN address attached`() = runTest { + // Arrange + val predefined = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")) + + // Act + val model = createModel(testScope = this, params = createParams(predefinedAddress = predefined)) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.addresses).containsExactly(predefined) + } + + private fun createParams( + contactId: ContactId? = null, + predefinedAddress: ValidatedAddress? = null, + ): DefaultEditContactComponent.Params = DefaultEditContactComponent.Params( + contactId = contactId, + predefinedAddress = predefinedAddress, + onBackClick = {}, + onAddAddressClick = {}, + ) + private fun createModel( testScope: TestScope, - params: EditContactComponent.Params = EditContactComponent.Params( - contactId = null, - onBackClick = {}, - onAddAddressClick = {}, - ), + params: DefaultEditContactComponent.Params = createParams(), paramsContainer: ParamsContainer = MutableParamsContainer(value = params), ): EditContactModel { return EditContactModel( paramsContainer = paramsContainer, dispatchers = testScope.createTestingCoroutineDispatcherProvider(), - ) + stateController = EditContactStateController(), + resultHolder = resultHolder, + ).also { model = it } } private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt index e7849eebed..7082a0c24e 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt @@ -25,10 +25,11 @@ internal sealed class DetailsItemUM { override val id: String = "wallet_connect" } - data class WalletConnectAddressBookBlock(val items: List) : DetailsItemUM() { + data class WalletActionBlock(val items: ImmutableList) : DetailsItemUM() { override val id: String = "wallet_connect_address_book" sealed class Item(open val onClick: () -> Unit) { + data class WalletConnect(override val onClick: () -> Unit) : Item(onClick) data class AddressBook(override val onClick: () -> Unit) : Item(onClick) } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt index 427c80adce..48d51c0835 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt @@ -2,13 +2,13 @@ package com.tangem.features.details.ui import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview @@ -27,19 +28,27 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.BlockItem -import com.tangem.core.ui.components.inputrow.InputRowImageBase import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowText +import com.tangem.core.ui.ds2.row.TangemRowTextRole +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_chevron_right_20 import com.tangem.core.ui.test.DetailsScreenTestTags import com.tangem.features.details.component.preview.PreviewDetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.entity.DetailsUM import com.tangem.features.details.impl.R +import kotlinx.collections.immutable.ImmutableList @Composable internal fun DetailsScreen( @@ -159,9 +168,9 @@ private fun Block( onClick = model.onClick, ) } - is DetailsItemUM.WalletConnectAddressBookBlock -> { + is DetailsItemUM.WalletActionBlock -> { BlockCard { - WalletConnectAddressBookBlockItems( + WalletActionsBlock( items = model.items, modifier = itemModifier, ) @@ -170,37 +179,106 @@ private fun Block( is DetailsItemUM.UserWalletList -> { userWalletListBlockContent.Content(modifier = itemModifier) } - is DetailsItemUM.UnderSectionText -> { /* Handled above */ - } + is DetailsItemUM.UnderSectionText -> Unit } } } @Composable -private fun WalletConnectAddressBookBlockItems( - items: List, +private fun WalletActionsBlock( + items: ImmutableList, modifier: Modifier = Modifier, ) { items.fastForEach { item -> when (item) { - is DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect -> InputRowImageBase( - modifier = modifier.clickable(onClick = item.onClick).padding(12.dp), - iconResVector = R.drawable.ic_wallet_connect_24, - iconTint = TangemTheme.colors.icon.primary1, - subtitle = TextReference.Res(R.string.wallet_connect_title), - caption = TextReference.Res(R.string.wallet_connect_subtitle), + is DetailsItemUM.WalletActionBlock.Item.WalletConnect -> WalletConnectActionRow( + onClick = item.onClick, + modifier = modifier, ) - is DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook -> InputRowImageBase( - modifier = modifier.clickable(onClick = item.onClick).padding(12.dp), - iconResVector = R.drawable.ic_contact_20, - iconTint = TangemTheme.colors.icon.accent, - subtitle = TextReference.Res(R.string.address_book_title), - caption = TextReference.Res(R.string.address_book_description), + is DetailsItemUM.WalletActionBlock.Item.AddressBook -> AddressBookActionRow( + onClick = item.onClick, + modifier = modifier, ) } } } +@Composable +private fun WalletConnectActionRow(onClick: () -> Unit, modifier: Modifier = Modifier) { + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + modifier = modifier, + onClick = onClick, + startSlot = { + TangemIcon( + tangemIconUM = TangemIconUM.Image(R.drawable.img_wallet_connect_76), + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)), + ) + }, + titleSlot = { + TangemRowText( + text = TextReference.Res(R.string.wallet_connect_title), + role = TangemRowTextRole.Title, + ) + }, + subtitleSlot = { + TangemRowText( + text = TextReference.Res(R.string.wallet_connect_subtitle), + role = TangemRowTextRole.Subtitle, + ) + }, + endSlot = { ActionRowChevron() }, + ) +} + +@Composable +private fun AddressBookActionRow(onClick: () -> Unit, modifier: Modifier = Modifier) { + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + modifier = modifier, + onClick = onClick, + startSlot = { + TangemIcon( + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_address_book_24, + tintReference = { TangemTheme.colors3.icon.brand }, + ), + modifier = Modifier + .size(40.dp) + .background( + color = TangemTheme.colors3.bg.status.infoSubtle, + shape = RoundedCornerShape(12.dp), + ) + .padding(8.dp), + ) + }, + titleSlot = { + TangemRowText( + text = TextReference.Res(R.string.address_book_title), + role = TangemRowTextRole.Title, + ) + }, + subtitleSlot = { + TangemRowText( + text = TextReference.Res(R.string.address_book_description), + role = TangemRowTextRole.Subtitle, + ) + }, + endSlot = { ActionRowChevron() }, + ) +} + +@Composable +private fun ActionRowChevron() { + Icon( + imageVector = Icons.ic_chevron_right_20, + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + ) +} + @Composable private fun UnderSectionTextBlock(text: TextReference, modifier: Modifier = Modifier) { Text( diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt index df2e653821..99d34d298d 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt @@ -35,7 +35,7 @@ internal class ItemsBuilder @Inject constructor( onBuyClick: () -> Unit, ): ImmutableList = buildList { if (isAddressBookAvailable) { - buildWalletConnectAddressBookBlock(isWalletConnectAvailable, userWalletId) + buildWalletActionBlock(isWalletConnectAvailable, userWalletId) } else { buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add) } @@ -91,29 +91,29 @@ internal class ItemsBuilder @Inject constructor( } } - private fun MutableList.buildWalletConnectAddressBookBlock( + private fun MutableList.buildWalletActionBlock( isWalletConnectAvailable: Boolean, userWalletId: UserWalletId, ) { - val walletConnectAddressBookItems = buildList { + val walletActionItems = buildList { if (isWalletConnectAvailable) add(buildWalletConnectButton(userWalletId)) add(buildAddressBookButton()) - } - if (walletConnectAddressBookItems.isNotEmpty()) { - add(DetailsItemUM.WalletConnectAddressBookBlock(walletConnectAddressBookItems)) + }.toImmutableList() + if (walletActionItems.isNotEmpty()) { + add(DetailsItemUM.WalletActionBlock(walletActionItems)) } } private fun buildWalletConnectButton( userWalletId: UserWalletId, - ): DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect { - return DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect( + ): DetailsItemUM.WalletActionBlock.Item.WalletConnect { + return DetailsItemUM.WalletActionBlock.Item.WalletConnect( onClick = { router.push(AppRoute.WalletConnectSessions(userWalletId)) }, ) } - private fun buildAddressBookButton(): DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook { - return DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook( + private fun buildAddressBookButton(): DetailsItemUM.WalletActionBlock.Item.AddressBook { + return DetailsItemUM.WalletActionBlock.Item.AddressBook( onClick = { router.push(AppRoute.AddressBook()) }, ) } diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt index d6962201eb..6f4c22d644 100644 --- a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt @@ -65,10 +65,10 @@ internal class ItemsBuilderTest { "support", ).inOrder() - val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock + val block = result.first() as DetailsItemUM.WalletActionBlock assertThat(block.items.map { it::class.java }).containsExactly( - DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect::class.java, - DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java, + DetailsItemUM.WalletActionBlock.Item.WalletConnect::class.java, + DetailsItemUM.WalletActionBlock.Item.AddressBook::class.java, ).inOrder() } @@ -86,9 +86,9 @@ internal class ItemsBuilderTest { "support", ).inOrder() - val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock + val block = result.first() as DetailsItemUM.WalletActionBlock assertThat(block.items.map { it::class.java }).containsExactly( - DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java, + DetailsItemUM.WalletActionBlock.Item.AddressBook::class.java, ) } @@ -109,9 +109,9 @@ internal class ItemsBuilderTest { fun `GIVEN combined block walletConnect item WHEN clicked THEN router pushes WalletConnectSessions`() { // Arrange val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true) - val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock + val block = result.first() as DetailsItemUM.WalletActionBlock val walletConnect = block.items - .filterIsInstance() + .filterIsInstance() .single() // Act @@ -125,9 +125,9 @@ internal class ItemsBuilderTest { fun `GIVEN combined block addressBook item WHEN clicked THEN router pushes AddressBook`() { // Arrange val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true) - val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock + val block = result.first() as DetailsItemUM.WalletActionBlock val addressBook = block.items - .filterIsInstance() + .filterIsInstance() .single() // Act