Updated on 2026-08-14
This commit is contained in:
parent
99b517455a
commit
09db4a7d03
58 changed files with 1353 additions and 826 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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") }
|
||||
|
||||
|
|
|
|||
25
core/ui/src/main/res/drawable/ic_address_book_24.xml
Normal file
25
core/ui/src/main/res/drawable/ic_address_book_24.xml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!--
|
||||
~ Copyright (C) 2026 The Android Open Source Project
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M17.213,3.014C18.508,3.147 19.5,4.257 19.5,5.583V18.417C19.5,19.831 18.372,21 16.95,21H7.05C5.629,21 4.5,19.831 4.5,18.417V17.895H3C2.448,17.895 2,17.447 2,16.895C2,16.342 2.448,15.894 3,15.894H4.5V12.984H3C2.448,12.984 2,12.536 2,11.984C2,11.432 2.448,10.984 3,10.984H4.5V8.075H3C2.448,8.075 2,7.627 2,7.075C2,6.523 2.448,6.075 3,6.075H4.5V5.583C4.5,4.169 5.628,2.999 7.05,2.999H16.95L17.213,3.014ZM10.61,12.887C9.492,12.89 8.573,13.739 8.46,14.828L8.448,15.049V15.325C8.448,15.614 8.563,15.891 8.767,16.095C8.971,16.299 9.248,16.413 9.536,16.413H14.465C15.065,16.413 15.552,15.926 15.552,15.325V15.049C15.549,13.931 14.7,13.012 13.611,12.898L13.39,12.887H10.61ZM12,7.588C10.84,7.588 9.899,8.528 9.899,9.688C9.9,10.849 10.84,11.789 12,11.789C13.16,11.789 14.1,10.849 14.101,9.688C14.101,8.528 13.16,7.588 12,7.588Z"
|
||||
android:fillColor="#0090F9"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M9.149,16.445C9.427,16.482 9.711,16.5 10,16.5V18C9.646,18 9.297,17.974 8.955,17.93L9.149,16.445ZM10.947,17.188L11.007,17.641L11.044,17.93C10.702,17.974 10.354,18 10,18V16.5C10.289,16.5 10.573,16.482 10.851,16.445L10.947,17.188ZM6.044,15.158C6.492,15.502 6.985,15.789 7.513,16.008L6.938,17.392C6.287,17.122 5.681,16.769 5.131,16.346L6.044,15.158ZM14.868,16.346C14.318,16.769 13.712,17.122 13.061,17.392L12.997,17.234L12.487,16.008C13.015,15.789 13.508,15.502 13.956,15.158L14.868,16.346ZM3.992,12.487C4.211,13.015 4.498,13.508 4.842,13.956L4.247,14.413L3.653,14.868C3.231,14.318 2.877,13.712 2.607,13.061L2.772,12.994L3.992,12.487ZM17.228,12.994L17.392,13.061C17.122,13.712 16.769,14.318 16.346,14.868L15.158,13.956C15.502,13.508 15.789,13.015 16.008,12.487L17.228,12.994ZM2,10C2,9.646 2.025,9.297 2.069,8.955L2.812,9.052L3.555,9.149C3.518,9.427 3.5,9.711 3.5,10C3.5,10.289 3.518,10.573 3.555,10.851L2.069,11.044C2.025,10.702 2,10.354 2,10ZM18,10C18,10.354 17.974,10.702 17.93,11.044L17.188,10.948L16.445,10.851C16.482,10.573 16.5,10.289 16.5,10C16.5,9.711 16.482,9.427 16.445,9.149L17.93,8.955C17.974,9.297 18,9.646 18,10ZM4.842,6.044C4.498,6.492 4.211,6.985 3.992,7.513L2.607,6.938C2.877,6.287 3.231,5.681 3.653,5.131L4.842,6.044ZM16.346,5.131C16.769,5.681 17.122,6.287 17.392,6.938L16.008,7.513C15.789,6.985 15.502,6.492 15.158,6.044L16.346,5.131ZM7.513,3.992C6.985,4.211 6.492,4.498 6.044,4.842L5.131,3.653C5.681,3.231 6.287,2.877 6.938,2.607L7.513,3.992ZM13.061,2.607C13.712,2.877 14.318,3.231 14.868,3.653L13.956,4.842C13.508,4.498 13.015,4.211 12.487,3.992L13.061,2.607ZM10,2C10.354,2 10.702,2.025 11.044,2.069L10.948,2.812L10.947,2.811L10.851,3.555C10.573,3.518 10.289,3.5 10,3.5C9.711,3.5 9.427,3.518 9.149,3.555L8.955,2.069C9.297,2.025 9.646,2 10,2Z"
|
||||
android:fillColor="#0090F9"/>
|
||||
</vector>
|
||||
|
|
@ -10,6 +10,9 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/* Project - Common */
|
||||
api(projects.common.routing)
|
||||
|
||||
/* Project - Domain */
|
||||
implementation(projects.domain.models)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
import com.tangem.common.routing.entity.AddressBookOpenMode
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
|
||||
|
|
@ -7,5 +8,5 @@ interface AddressBookComponent : ComposableContentComponent {
|
|||
|
||||
interface Factory : ComponentFactory<Params, AddressBookComponent>
|
||||
|
||||
data class Params(val predefinedAddress: String?)
|
||||
data class Params(val addressBookOpenMode: AddressBookOpenMode)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.tangem.features.addressbook.addaddress
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
|
||||
internal interface AddAddressComponent : ComposableContentComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, AddAddressComponent>
|
||||
|
||||
data class Params(
|
||||
val onBackClick: () -> Unit,
|
||||
val onConfirm: (ValidatedAddress) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -7,16 +7,15 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.features.addressbook.addaddress.model.AddAddressModel
|
||||
import com.tangem.features.addressbook.addaddress.ui.AddAddressContent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
|
||||
internal class DefaultAddAddressComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted params: AddAddressComponent.Params,
|
||||
) : AddAddressComponent, AppComponentContext by context {
|
||||
internal class DefaultAddAddressComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: AddAddressModel = getOrCreateModel(params)
|
||||
|
||||
|
|
@ -30,11 +29,8 @@ internal class DefaultAddAddressComponent @AssistedInject constructor(
|
|||
BackHandler(onBack = state.onBackClick)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddAddressComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddAddressComponent.Params,
|
||||
): DefaultAddAddressComponent
|
||||
}
|
||||
data class Params(
|
||||
val onBackClick: () -> Unit,
|
||||
val onConfirm: (ValidatedAddress) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,30 +1,21 @@
|
|||
package com.tangem.features.addressbook.addaddress.model
|
||||
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.common.ui.extensions.iconResId
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.features.addressbook.addaddress.AddAddressComponent
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
|
||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
|
||||
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddAddressInitialStateTransformer
|
||||
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressInputTransformer
|
||||
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.map
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@ModelScoped
|
||||
|
|
@ -33,12 +24,12 @@ internal class AddAddressModel @Inject constructor(
|
|||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val clipboardManager: ClipboardManager,
|
||||
private val stateController: AddAddressStateController,
|
||||
) : Model() {
|
||||
|
||||
private val params: AddAddressComponent.Params = paramsContainer.require()
|
||||
private val params: DefaultAddAddressComponent.Params = paramsContainer.require()
|
||||
|
||||
val state: StateFlow<AddAddressUM>
|
||||
field = MutableStateFlow(getInitialState())
|
||||
val state: StateFlow<AddAddressUM> get() = stateController.uiState
|
||||
|
||||
private val availableCoins: StateFlow<List<CryptoCurrency.Coin>> = multiAccountListSupplier()
|
||||
.map { accountLists ->
|
||||
|
|
@ -47,6 +38,7 @@ internal class AddAddressModel @Inject constructor(
|
|||
.filterIsInstance<CryptoCurrency.Coin>()
|
||||
.distinctBy { it.network.id }
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val addressInput = state
|
||||
|
|
@ -55,94 +47,44 @@ internal class AddAddressModel @Inject constructor(
|
|||
.debounce(ADD_ADDRESS_DEBOUNCE)
|
||||
|
||||
init {
|
||||
subscribeToAddressInput()
|
||||
updateInitialState()
|
||||
subscribeToAddressValidation()
|
||||
}
|
||||
|
||||
private fun onAddressChange(value: String, isPasted: Boolean = false) {
|
||||
state.update { oldState ->
|
||||
oldState.copy(
|
||||
addressField = oldState.addressField.copy(
|
||||
value = value,
|
||||
isValuePasted = isPasted,
|
||||
isError = false,
|
||||
error = null,
|
||||
),
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading,
|
||||
)
|
||||
}
|
||||
private fun updateInitialState() {
|
||||
stateController.update(
|
||||
UpdateAddAddressInitialStateTransformer(
|
||||
onAddressChange = { onAddressChange(value = it) },
|
||||
onAddressClear = { onAddressChange("") },
|
||||
onPasteClick = ::onPaste,
|
||||
onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
|
||||
onBackClick = params.onBackClick,
|
||||
onConfirmClick = ::validateAndConfirm,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun subscribeToAddressInput() {
|
||||
private fun onAddressChange(value: String) {
|
||||
stateController.update(UpdateAddressInputTransformer(value = value))
|
||||
}
|
||||
|
||||
private fun subscribeToAddressValidation() {
|
||||
combine(addressInput, availableCoins) { input, coins ->
|
||||
getUniqueNetworks(input, coins)
|
||||
UpdateAddressValidationTransformer(address = input, coins = coins)
|
||||
}
|
||||
.onEach { availableNetworks ->
|
||||
state.update { oldState ->
|
||||
oldState.copy(
|
||||
availableNetworks = availableNetworks,
|
||||
chosenNetworkStateUM = createChosenNetworkState(availableNetworks),
|
||||
)
|
||||
}
|
||||
}
|
||||
.onEach(stateController::update)
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun createChosenNetworkState(availableNetworks: ImmutableList<Network>): AddAddressUM.ChosenNetworkStateUM {
|
||||
return if (availableNetworks.isEmpty()) {
|
||||
AddAddressUM.ChosenNetworkStateUM.Empty
|
||||
} else {
|
||||
AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = availableNetworks
|
||||
.map { network ->
|
||||
AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM(
|
||||
networkName = network.name,
|
||||
iconResId = network.iconResId,
|
||||
)
|
||||
}
|
||||
.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUniqueNetworks(input: String, coins: List<CryptoCurrency.Coin>): ImmutableList<Network> {
|
||||
return coins
|
||||
.filter { it.network.toBlockchain().validateAddress(input) }
|
||||
.map { it.network }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
private fun onPaste() {
|
||||
onAddressChange(value = clipboardManager.getText().orEmpty(), isPasted = true)
|
||||
onAddressChange(value = clipboardManager.getText().orEmpty())
|
||||
}
|
||||
|
||||
private fun validateAndConfirm() {
|
||||
// TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm
|
||||
// TODO Address book ([REDACTED_TASK_KEY]): navigate to the network-selection with the address and its matching networks.
|
||||
}
|
||||
|
||||
private fun getInitialState(): AddAddressUM = AddAddressUM(
|
||||
addressField = AddressFieldUM(
|
||||
value = "",
|
||||
placeholder = resourceReference(R.string.common_address),
|
||||
label = resourceReference(R.string.address_book_enter_address),
|
||||
isError = false,
|
||||
error = null,
|
||||
isValuePasted = false,
|
||||
),
|
||||
availableNetworks = persistentListOf(),
|
||||
buttonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
type = TangemButtonType.Primary,
|
||||
isEnabled = false,
|
||||
onClick = ::validateAndConfirm,
|
||||
),
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
|
||||
onAddressChange = { onAddressChange(value = it) },
|
||||
onAddressClear = { onAddressChange("") },
|
||||
onPasteClick = ::onPaste,
|
||||
onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
|
||||
onBackClick = params.onBackClick,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val ADD_ADDRESS_DEBOUNCE = 500L
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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<AddAddressUM> = MutableStateFlow(value = getInitialState())
|
||||
|
||||
val uiState: StateFlow<AddAddressUM> get() = mutableUiState.asStateFlow()
|
||||
|
||||
fun update(transformer: Transformer<AddAddressUM>) {
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.tangem.features.addressbook.addaddress.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Wires the callbacks owned by [com.tangem.features.addressbook.addaddress.model.AddAddressModel] into the initial
|
||||
* state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController].
|
||||
*/
|
||||
internal class UpdateAddAddressInitialStateTransformer(
|
||||
private val onAddressChange: (String) -> Unit,
|
||||
private val onAddressClear: () -> Unit,
|
||||
private val onPasteClick: () -> Unit,
|
||||
private val onQrClick: () -> Unit,
|
||||
private val onBackClick: () -> Unit,
|
||||
private val onConfirmClick: () -> Unit,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
return prevState.copy(
|
||||
onAddressChange = onAddressChange,
|
||||
onAddressClear = onAddressClear,
|
||||
onPasteClick = onPasteClick,
|
||||
onQrClick = onQrClick,
|
||||
onBackClick = onBackClick,
|
||||
buttonUM = prevState.buttonUM.copy(onClick = onConfirmClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.tangem.features.addressbook.addaddress.state.transformers
|
||||
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Updates the address field with a freshly entered/pasted [value] and clears any previous error, restoring the default
|
||||
* label. The actual (re)validation runs after a debounce — see [UpdateAddressValidationTransformer].
|
||||
*/
|
||||
internal class UpdateAddressInputTransformer(
|
||||
private val value: String,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
return prevState.copy(
|
||||
addressField = prevState.addressField.copy(
|
||||
value = value,
|
||||
isError = false,
|
||||
label = resourceReference(R.string.common_address),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.features.addressbook.addaddress.state.transformers
|
||||
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Validates [address] against the wallet's [coins] and reflects the result in the UI.
|
||||
*
|
||||
* The network is not chosen on this screen (it is selected on the next screen), so the address is valid when it matches
|
||||
* at least one of the available networks — the same blockchain check the Send flow uses. An invalid (non-empty,
|
||||
* matching nothing) address surfaces the error in the field label and disables the confirm button.
|
||||
*/
|
||||
internal class UpdateAddressValidationTransformer(
|
||||
private val address: String,
|
||||
private val coins: List<CryptoCurrency.Coin>,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
val hasMatchedAnyNetwork = address.isNotBlank() &&
|
||||
coins.any { it.network.toBlockchain().validateAddress(address) }
|
||||
val isError = address.isNotBlank() && !hasMatchedAnyNetwork
|
||||
val label = if (isError) {
|
||||
resourceReference(R.string.address_book_invalid_address_error)
|
||||
} else {
|
||||
resourceReference(R.string.common_address)
|
||||
}
|
||||
return prevState.copy(
|
||||
addressField = prevState.addressField.copy(isError = isError, label = label),
|
||||
buttonUM = prevState.buttonUM.copy(isEnabled = hasMatchedAnyNetwork),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,14 +3,15 @@ package com.tangem.features.addressbook.addaddress.ui
|
|||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.ds.button.PrimaryTangemButton
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
|
|
@ -20,9 +21,8 @@ import com.tangem.core.ui.extensions.TextReference
|
|||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
|
||||
|
||||
@Composable
|
||||
internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifier) {
|
||||
|
|
@ -34,7 +34,6 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
|
|||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TangemTopBar(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
title = resourceReference(R.string.address_book_add_address),
|
||||
startContent = {
|
||||
TangemButton(
|
||||
|
|
@ -47,14 +46,23 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
|
|||
)
|
||||
|
||||
RecipientRow(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
addressField = state.addressField,
|
||||
onValueChange = state.onAddressChange,
|
||||
onAddressClear = state.onAddressClear,
|
||||
onQrClick = state.onQrClick,
|
||||
onPasteClick = state.onPasteClick,
|
||||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(state.chosenNetworkStateUM)
|
||||
SpacerH(20.dp)
|
||||
NetworkBlock(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.fillMaxWidth()
|
||||
.background(color = TangemTheme.colors3.bg.secondary),
|
||||
chosenNetworkStateUM = state.chosenNetworkStateUM,
|
||||
onNetworkSelectClick = state.onNetworkClick,
|
||||
)
|
||||
PrimaryButton(state.buttonUM)
|
||||
}
|
||||
}
|
||||
|
|
@ -62,13 +70,15 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
|
|||
@Composable
|
||||
private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
PrimaryTangemButton(
|
||||
TangemButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
buttonUM = buttonUM,
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
onClick = buttonUM.onClick,
|
||||
isEnabled = buttonUM.isEnabled,
|
||||
isLoading = buttonUM.isLoading,
|
||||
size = TangemButton.Size.X12,
|
||||
text = buttonUM.text,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +94,6 @@ private fun Preview_AddAddressContent() {
|
|||
placeholder = resourceReference(R.string.address_book_enter_address),
|
||||
label = resourceReference(R.string.common_address),
|
||||
),
|
||||
availableNetworks = persistentListOf(),
|
||||
buttonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
type = TangemButtonType.Primary,
|
||||
|
|
@ -97,6 +106,7 @@ private fun Preview_AddAddressContent() {
|
|||
onPasteClick = {},
|
||||
onQrClick = {},
|
||||
onBackClick = {},
|
||||
onNetworkClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,86 +1,95 @@
|
|||
package com.tangem.features.addressbook.addaddress.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEachIndexed
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.components.SpacerW
|
||||
import com.tangem.core.ui.ds2.loader.TangemLoader
|
||||
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.clickableSingle
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
|
||||
import com.tangem.utils.StringsSigns
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
private const val MAX_VISIBLE_NETWORKS = 3
|
||||
private val NetworkIconSize = 24.dp
|
||||
|
||||
// Horizontal advance per icon. Smaller than the icon size so icons overlap; the bg-colored ring on
|
||||
// the icon drawn on top carves the crescent cut-out from the icon below.
|
||||
private val NetworkIconStep = 18.dp
|
||||
|
||||
@Composable
|
||||
internal fun NetworkBlock(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
|
||||
internal fun NetworkBlock(
|
||||
onNetworkSelectClick: () -> Unit,
|
||||
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.fillMaxWidth()
|
||||
.background(color = TangemTheme.colors3.bg.secondary)
|
||||
.padding(horizontal = 4.dp),
|
||||
modifier = modifier,
|
||||
titleSlot = {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.common_network),
|
||||
style = TangemTheme.typography.body2,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
)
|
||||
},
|
||||
endSlot = {
|
||||
SelectNetworkButton(chosenNetworkStateUM)
|
||||
SelectNetworkButton(
|
||||
onNetworkSelectClick = onNetworkSelectClick,
|
||||
chosenNetworkStateUM = chosenNetworkStateUM,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectNetworkButton(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
|
||||
private fun SelectNetworkButton(
|
||||
onNetworkSelectClick: () -> Unit,
|
||||
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickableSingle(
|
||||
onClick = onNetworkSelectClick,
|
||||
enabled = chosenNetworkStateUM !is AddAddressUM.ChosenNetworkStateUM.Loading,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (chosenNetworkStateUM) {
|
||||
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList)
|
||||
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader()
|
||||
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20)
|
||||
AddAddressUM.ChosenNetworkStateUM.Empty -> {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
text = stringResourceSafe(R.string.address_book_select_network),
|
||||
style = TangemTheme.typography.body2,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
SpacerW(4.dp)
|
||||
ChevronIcon()
|
||||
}
|
||||
}
|
||||
|
|
@ -100,7 +109,7 @@ private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
|
|||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
text = network.networkName,
|
||||
style = TangemTheme.typography.body2,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
ChevronIcon()
|
||||
|
|
@ -120,7 +129,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
|
|||
val remaining = networks.size - visible.size
|
||||
|
||||
Box(modifier = Modifier.wrapContentWidth()) {
|
||||
visible.forEachIndexed { index, network ->
|
||||
visible.fastForEachIndexed { index, network ->
|
||||
Image(
|
||||
painter = painterResource(id = network.iconResId),
|
||||
contentDescription = null,
|
||||
|
|
@ -128,7 +137,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
|
|||
modifier = Modifier
|
||||
.padding(start = NetworkIconStep * index)
|
||||
.networkIconRing()
|
||||
.size(NetworkIconSize),
|
||||
.size(24.dp),
|
||||
)
|
||||
}
|
||||
if (remaining > 0) {
|
||||
|
|
@ -137,12 +146,13 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
|
|||
.padding(start = NetworkIconStep * visible.size)
|
||||
.networkIconRing()
|
||||
.background(color = TangemTheme.colors3.bg.tertiary)
|
||||
.size(NetworkIconSize),
|
||||
.heightIn(min = 24.dp)
|
||||
.padding(vertical = 2.dp, horizontal = 4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "+$remaining",
|
||||
style = TangemTheme.typography.caption1,
|
||||
text = "${StringsSigns.PLUS}$remaining",
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
}
|
||||
|
|
@ -160,20 +170,23 @@ private fun Modifier.networkIconRing(): Modifier = this
|
|||
|
||||
@Composable
|
||||
private fun ChevronIcon() {
|
||||
Image(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
painter = painterResource(id = R.drawable.ic_select_18_24),
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(20.dp),
|
||||
tint = TangemTheme.colors3.icon.secondary,
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_select_18_24),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun Preview_NetworkBlock() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column {
|
||||
NetworkBlock(
|
||||
onNetworkSelectClick = {},
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = persistentListOf(
|
||||
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
||||
|
|
@ -182,6 +195,7 @@ private fun Preview_NetworkBlock() {
|
|||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(
|
||||
onNetworkSelectClick = {},
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = persistentListOf(
|
||||
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
||||
|
|
@ -192,6 +206,7 @@ private fun Preview_NetworkBlock() {
|
|||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(
|
||||
onNetworkSelectClick = {},
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = List(15) {
|
||||
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
|
||||
|
|
@ -199,9 +214,9 @@ private fun Preview_NetworkBlock() {
|
|||
),
|
||||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading)
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, onNetworkSelectClick = {})
|
||||
SpacerH12()
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty)
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, onNetworkSelectClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,7 @@ package com.tangem.features.addressbook.addaddress.ui
|
|||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -19,7 +15,6 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH12
|
||||
import com.tangem.core.ui.components.SpacerW8
|
||||
import com.tangem.core.ui.components.fields.SimpleTextField
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
|
|
@ -28,13 +23,14 @@ import com.tangem.core.ui.ds2.row.TangemRow
|
|||
import com.tangem.core.ui.ds2.row.TangemRowContentLead
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
|
||||
import com.tangem.core.ui.res.generated.icons.ic_scan_20
|
||||
import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
|
||||
|
||||
@Composable
|
||||
internal fun RecipientRow(
|
||||
|
|
@ -43,19 +39,23 @@ internal fun RecipientRow(
|
|||
onAddressClear: () -> Unit,
|
||||
onQrClick: () -> Unit,
|
||||
onPasteClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
|
||||
text = stringResourceSafe(R.string.common_address),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
text = addressField.label.resolveReference(),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = if (addressField.isError) {
|
||||
TangemTheme.colors3.text.status.error
|
||||
} else {
|
||||
TangemTheme.colors3.text.secondary
|
||||
},
|
||||
)
|
||||
TangemRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -64,7 +64,7 @@ internal fun RecipientRow(
|
|||
startSlot = {
|
||||
TangemIcon(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(TangemTheme.colors3.bg.tertiary),
|
||||
tangemIconUM = TangemIconUM.Ident(text = addressField.value),
|
||||
|
|
@ -72,43 +72,55 @@ internal fun RecipientRow(
|
|||
},
|
||||
titleSlot = {
|
||||
SimpleTextField(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
value = addressField.value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = TextReference.Res(R.string.address_book_enter_address),
|
||||
singleLine = false,
|
||||
placeholder = addressField.placeholder,
|
||||
)
|
||||
},
|
||||
endSlot = {
|
||||
if (addressField.value.isNotEmpty()) {
|
||||
Icon(
|
||||
modifier = Modifier.clickable(onClick = onAddressClear),
|
||||
imageVector = Icons.ic_cross_circle_20_filled,
|
||||
tint = TangemTheme.colors3.icon.tertiary,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
Row {
|
||||
TangemButton(
|
||||
variant = TangemButton.Variant.Secondary,
|
||||
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_qrcode_scaner_24),
|
||||
onClick = onQrClick,
|
||||
)
|
||||
SpacerW8()
|
||||
TangemButton(
|
||||
variant = TangemButton.Variant.Primary,
|
||||
text = TextReference.Res(id = R.string.common_paste),
|
||||
onClick = onPasteClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
RecipientEndSlot(
|
||||
hasValue = addressField.value.isNotEmpty(),
|
||||
onAddressClear = onAddressClear,
|
||||
onQrClick = onQrClick,
|
||||
onPasteClick = onPasteClick,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipientEndSlot(
|
||||
hasValue: Boolean,
|
||||
onAddressClear: () -> Unit,
|
||||
onQrClick: () -> Unit,
|
||||
onPasteClick: () -> Unit,
|
||||
) {
|
||||
if (hasValue) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onAddressClear),
|
||||
imageVector = Icons.ic_cross_circle_20_filled,
|
||||
tint = TangemTheme.colors3.icon.tertiary,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TangemButton(
|
||||
variant = TangemButton.Variant.Secondary,
|
||||
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_scan_20),
|
||||
onClick = onQrClick,
|
||||
)
|
||||
TangemButton(
|
||||
text = TextReference.Res(id = R.string.common_paste),
|
||||
onClick = onPasteClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
package com.tangem.features.addressbook.addaddress.contract
|
||||
package com.tangem.features.addressbook.addaddress.ui.state
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.domain.models.network.Network
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal data class AddAddressUM(
|
||||
val addressField: AddressFieldUM,
|
||||
val availableNetworks: ImmutableList<Network>,
|
||||
val buttonUM: TangemButtonUM,
|
||||
val chosenNetworkStateUM: ChosenNetworkStateUM,
|
||||
val onAddressChange: (String) -> Unit,
|
||||
|
|
@ -16,15 +15,14 @@ internal data class AddAddressUM(
|
|||
val onPasteClick: () -> Unit,
|
||||
val onQrClick: () -> Unit,
|
||||
val onBackClick: () -> Unit,
|
||||
val onNetworkClick: () -> Unit,
|
||||
) {
|
||||
@Immutable
|
||||
sealed class ChosenNetworkStateUM {
|
||||
data object Loading : ChosenNetworkStateUM()
|
||||
data object Empty : ChosenNetworkStateUM()
|
||||
data class Result(
|
||||
val networkUMList: ImmutableList<NetworkUM>,
|
||||
) : ChosenNetworkStateUM() {
|
||||
sealed interface ChosenNetworkStateUM {
|
||||
data object Loading : ChosenNetworkStateUM
|
||||
data object Empty : ChosenNetworkStateUM
|
||||
|
||||
data class Result(val networkUMList: ImmutableList<NetworkUM>) : ChosenNetworkStateUM {
|
||||
data class NetworkUM(
|
||||
val networkName: String,
|
||||
@DrawableRes val iconResId: Int,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.addressbook.addaddress.contract
|
||||
package com.tangem.features.addressbook.addaddress.ui.state
|
||||
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
|
|
@ -7,7 +7,4 @@ internal data class AddressFieldUM(
|
|||
val placeholder: TextReference,
|
||||
val label: TextReference,
|
||||
val isError: Boolean = false,
|
||||
val error: TextReference? = null,
|
||||
val isValuePasted: Boolean = false,
|
||||
val blockchainAddress: String? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,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))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
|
||||
/**
|
||||
* Navigation/click contract that the container ([DefaultAddressBookComponent]) implements and passes down to its
|
||||
* children through [AddressBookChildFactory]. Keeping all cross-screen intents in one place removes the need for the
|
||||
* children to know about each other or about navigation.
|
||||
*
|
||||
* Result delivery (the confirmed address) is handled out-of-band by [AddressBookResultHolder], not by this contract.
|
||||
*/
|
||||
internal interface AddressBookClickIntents {
|
||||
|
||||
fun onContactClick(contactId: ContactId)
|
||||
|
||||
fun onAddContactClick()
|
||||
|
||||
fun onEditContactBack()
|
||||
|
||||
fun onAddAddressClick()
|
||||
|
||||
fun onAddAddressBack()
|
||||
|
||||
fun onAddressConfirmed(address: ValidatedAddress)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Carries a [ValidatedAddress] confirmed on the AddAddress screen over to the EditContact screen.
|
||||
*
|
||||
* The two screens live in independent model scopes, so a shared singleton holder is used to hand the result over
|
||||
* instead of routing it through navigation/click intents. The producer calls [setConfirmedAddress]; the consumer
|
||||
* observes [confirmedAddress] and calls [clear] after applying the value so it is not re-applied on resubscription.
|
||||
*/
|
||||
@Singleton
|
||||
internal class AddressBookResultHolder @Inject constructor() {
|
||||
|
||||
val confirmedAddress: StateFlow<ValidatedAddress?>
|
||||
field = MutableStateFlow<ValidatedAddress?>(null)
|
||||
|
||||
fun setConfirmedAddress(address: ValidatedAddress) {
|
||||
confirmedAddress.value = address
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
confirmedAddress.value = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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<AddressBookRoute>()
|
||||
|
||||
init {
|
||||
// Drop any address left over from a previous session before the (possibly preloaded) stack starts collecting.
|
||||
resultHolder.clear()
|
||||
}
|
||||
|
||||
private val clickIntents = object : AddressBookClickIntents {
|
||||
|
||||
override fun onContactClick(contactId: ContactId) {
|
||||
navigation.pushNew(AddressBookRoute.EditContact(contactId = contactId.value))
|
||||
}
|
||||
|
||||
override fun onAddContactClick() {
|
||||
navigation.pushNew(AddressBookRoute.EditContact())
|
||||
}
|
||||
|
||||
override fun onEditContactBack() {
|
||||
navigation.pop()
|
||||
}
|
||||
|
||||
override fun onAddAddressClick() {
|
||||
navigation.pushNew(AddressBookRoute.AddAddress)
|
||||
}
|
||||
|
||||
override fun onAddAddressBack() {
|
||||
navigation.pop()
|
||||
}
|
||||
|
||||
override fun onAddressConfirmed(address: ValidatedAddress) {
|
||||
resultHolder.setConfirmedAddress(address)
|
||||
navigation.pop()
|
||||
}
|
||||
}
|
||||
|
||||
private val contentStack = childStack(
|
||||
key = "address_book_stack",
|
||||
source = navigation,
|
||||
serializer = AddressBookRoute.serializer(),
|
||||
initialStack = ::initialStack,
|
||||
handleBackButton = false,
|
||||
childFactory = ::screenChild,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val childStack by contentStack.subscribeAsState()
|
||||
Children(
|
||||
modifier = modifier,
|
||||
stack = childStack,
|
||||
animation = stackAnimation(slide()),
|
||||
) { child ->
|
||||
child.instance.Content(Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent {
|
||||
return childFactory.createChild(
|
||||
route = config,
|
||||
context = childByContext(componentContext),
|
||||
clickIntents = clickIntents,
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialStack(): List<AddressBookRoute> = when (val mode = params.addressBookOpenMode) {
|
||||
AddressBookOpenMode.Default -> listOf(AddressBookRoute.List)
|
||||
is AddressBookOpenMode.WithContactCreation -> listOf(
|
||||
AddressBookRoute.List,
|
||||
// Address + network are already known, so open the new contact with that address attached — no AddAddress.
|
||||
AddressBookRoute.EditContact(
|
||||
predefinedAddress = mode.address,
|
||||
predefinedNetworkId = mode.networkId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressBookComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressBookComponent.Params,
|
||||
): DefaultAddressBookComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
package com.tangem.features.addressbook
|
||||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.addressbook.AddressBookFeatureToggles
|
||||
|
||||
internal class DefaultAddressBookFeatureToggles(
|
||||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package com.tangem.features.addressbook.component
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal sealed class AddressBookRoute {
|
||||
|
||||
@Serializable
|
||||
data object List : AddressBookRoute()
|
||||
|
||||
/**
|
||||
* if [contactId] is not null we should fetch existing contact
|
||||
*/
|
||||
@Serializable
|
||||
data class EditContact(
|
||||
val contactId: String? = null,
|
||||
) : AddressBookRoute()
|
||||
|
||||
@Serializable
|
||||
data object AddAddress : AddressBookRoute()
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
package com.tangem.features.addressbook.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.stack.Children
|
||||
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.stack.StackNavigation
|
||||
import com.arkivanov.decompose.router.stack.childStack
|
||||
import com.arkivanov.decompose.router.stack.pop
|
||||
import com.arkivanov.decompose.router.stack.pushNew
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.AddressBookComponent
|
||||
import com.tangem.features.addressbook.addaddress.AddAddressComponent
|
||||
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
import com.tangem.features.addressbook.list.AddressBookListComponent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultAddressBookComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted private val params: AddressBookComponent.Params,
|
||||
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
|
||||
private val editContactComponentFactory: EditContactComponent.Factory,
|
||||
private val addAddressComponentFactory: AddAddressComponent.Factory,
|
||||
) : AddressBookComponent, AppComponentContext by context {
|
||||
|
||||
private val navigation = StackNavigation<AddressBookRoute>()
|
||||
|
||||
/**
|
||||
* Consumer for the address entered on the [AddressBookRoute.AddAddress] screen, registered by the EditContact
|
||||
* screen when it requests adding an address and invoked when AddAddress confirms. Transient by design — the
|
||||
* entered addresses live only in EditContact's in-memory state until the contact is saved.
|
||||
*/
|
||||
private var pendingAddressSink: ((ValidatedAddress) -> Unit)? = null
|
||||
|
||||
private val contentStack = childStack(
|
||||
key = "address_book_stack",
|
||||
source = navigation,
|
||||
serializer = AddressBookRoute.serializer(),
|
||||
initialConfiguration = AddressBookRoute.List,
|
||||
handleBackButton = false,
|
||||
childFactory = ::screenChild,
|
||||
)
|
||||
|
||||
@Suppress("ReusedModifierInstance")
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val childStack by contentStack.subscribeAsState()
|
||||
|
||||
Children(stack = childStack, animation = stackAnimation()) { child ->
|
||||
child.instance.Content(modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent =
|
||||
when (config) {
|
||||
AddressBookRoute.List -> addressBookListComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = AddressBookListComponent.Params(
|
||||
onContactClick = { contactId ->
|
||||
navigation.pushNew(AddressBookRoute.EditContact(contactId))
|
||||
},
|
||||
onAddContactClick = { navigation.pushNew(AddressBookRoute.EditContact()) },
|
||||
),
|
||||
)
|
||||
is AddressBookRoute.EditContact -> editContactComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = EditContactComponent.Params(
|
||||
contactId = config.contactId?.let(::ContactId),
|
||||
onBackClick = { navigation.pop() },
|
||||
onAddAddressClick = { onResult ->
|
||||
pendingAddressSink = onResult
|
||||
navigation.pushNew(AddressBookRoute.AddAddress)
|
||||
},
|
||||
),
|
||||
)
|
||||
AddressBookRoute.AddAddress -> addAddressComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = AddAddressComponent.Params(
|
||||
onBackClick = {
|
||||
pendingAddressSink = null
|
||||
navigation.pop()
|
||||
},
|
||||
onConfirm = { address ->
|
||||
pendingAddressSink?.invoke(address)
|
||||
pendingAddressSink = null
|
||||
navigation.pop()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : AddressBookComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: AddressBookComponent.Params,
|
||||
): DefaultAddressBookComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,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
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ package com.tangem.features.addressbook.di
|
|||
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.addressbook.AddressBookFeatureToggles
|
||||
import com.tangem.features.addressbook.DefaultAddressBookFeatureToggles
|
||||
import com.tangem.features.addressbook.common.DefaultAddressBookFeatureToggles
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
||||
import com.tangem.features.addressbook.editcontact.ui.EditContactContent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
|
||||
internal class DefaultEditContactComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted params: EditContactComponent.Params,
|
||||
) : EditContactComponent, AppComponentContext by context {
|
||||
internal class DefaultEditContactComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: EditContactModel = getOrCreateModel(params)
|
||||
|
||||
|
|
@ -30,11 +30,10 @@ internal class DefaultEditContactComponent @AssistedInject constructor(
|
|||
BackHandler(onBack = state.onCloseClick)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : EditContactComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: EditContactComponent.Params,
|
||||
): DefaultEditContactComponent
|
||||
}
|
||||
data class Params(
|
||||
val contactId: ContactId?,
|
||||
val predefinedAddress: ValidatedAddress? = null,
|
||||
val onBackClick: () -> Unit,
|
||||
val onAddAddressClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package com.tangem.features.addressbook.editcontact
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
|
||||
internal interface EditContactComponent : ComposableContentComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, EditContactComponent>
|
||||
|
||||
data class Params(
|
||||
val contactId: ContactId?,
|
||||
val onBackClick: () -> Unit,
|
||||
val onAddAddressClick: (onResult: (ValidatedAddress) -> Unit) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package com.tangem.features.addressbook.editcontact.contract
|
||||
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
||||
/**
|
||||
* A recipient address that has been validated and resolved to a [Network] on the AddAddress screen.
|
||||
*
|
||||
* This is the in-progress (pre-save) representation accumulated in [EditContactUM]. It is converted to a domain
|
||||
* `AddressEntry` only when the contact is persisted, since the entry's id and signature are produced at save time.
|
||||
*/
|
||||
data class ValidatedAddress(
|
||||
val address: String,
|
||||
val network: Network,
|
||||
)
|
||||
|
|
@ -1,80 +1,79 @@
|
|||
package com.tangem.features.addressbook.editcontact.model
|
||||
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.editcontact.EditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
import com.tangem.features.addressbook.common.AddressBookResultHolder
|
||||
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.state.EditContactStateController
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.AddValidatedAddressTransformer
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.SelectContactColorTransformer
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.UpdateContactNameTransformer
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.UpdateEditContactInitialStateTransformer
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class EditContactModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: EditContactStateController,
|
||||
private val resultHolder: AddressBookResultHolder,
|
||||
) : Model() {
|
||||
|
||||
private val params: EditContactComponent.Params = paramsContainer.require()
|
||||
private val params: DefaultEditContactComponent.Params = paramsContainer.require()
|
||||
|
||||
val state: StateFlow<EditContactUM>
|
||||
field = MutableStateFlow(getInitialState())
|
||||
val state: StateFlow<EditContactUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
updateInitialState()
|
||||
prefillPredefinedAddress()
|
||||
subscribeToConfirmedAddresses()
|
||||
}
|
||||
|
||||
/** In WithContactCreation mode the contact opens with the already-known address attached. */
|
||||
private fun prefillPredefinedAddress() {
|
||||
params.predefinedAddress?.let(::addAddress)
|
||||
}
|
||||
|
||||
private fun updateInitialState() {
|
||||
stateController.update(
|
||||
UpdateEditContactInitialStateTransformer(
|
||||
isExistingContact = params.contactId != null,
|
||||
onNameChange = ::onNameChange,
|
||||
onColorSelect = ::onColorSelect,
|
||||
onCloseClick = params.onBackClick,
|
||||
onAddAddressClick = params.onAddAddressClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun subscribeToConfirmedAddresses() {
|
||||
resultHolder.confirmedAddress
|
||||
.filterNotNull()
|
||||
.onEach { address ->
|
||||
addAddress(address)
|
||||
resultHolder.clear()
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun onNameChange(name: String) {
|
||||
state.update { it.copy(name = name) }
|
||||
stateController.update(UpdateContactNameTransformer(name = name))
|
||||
}
|
||||
|
||||
private fun onColorSelect(color: CryptoPortfolioIcon.Color) {
|
||||
state.update { oldState ->
|
||||
oldState.copy(
|
||||
colors = oldState.colors.copy(selected = color),
|
||||
portfolioIcon = oldState.portfolioIcon.copy(color = color),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAddAddress() {
|
||||
params.onAddAddressClick(::addAddress)
|
||||
stateController.update(SelectContactColorTransformer(color = color))
|
||||
}
|
||||
|
||||
private fun addAddress(address: ValidatedAddress) {
|
||||
state.update { it.copy(addresses = (it.addresses + address).toImmutableList()) }
|
||||
}
|
||||
|
||||
private fun getInitialState(): EditContactUM {
|
||||
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
|
||||
val selectedColor = colors.first()
|
||||
val titleResId = if (params.contactId == null) {
|
||||
R.string.address_book_new_contact
|
||||
} else {
|
||||
R.string.address_book_contact
|
||||
}
|
||||
return EditContactUM(
|
||||
title = resourceReference(titleResId),
|
||||
name = "",
|
||||
namePlaceholder = resourceReference(R.string.address_book_new_contact),
|
||||
portfolioIcon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = selectedColor,
|
||||
),
|
||||
colors = EditContactUM.Colors(
|
||||
selected = selectedColor,
|
||||
list = colors,
|
||||
onColorSelect = ::onColorSelect,
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
onNameChange = ::onNameChange,
|
||||
onCloseClick = params.onBackClick,
|
||||
onAddAddressClick = ::requestAddAddress,
|
||||
)
|
||||
stateController.update(AddValidatedAddressTransformer(address = address))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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<EditContactUM> = MutableStateFlow(value = getInitialState())
|
||||
|
||||
val uiState: StateFlow<EditContactUM> get() = mutableUiState.asStateFlow()
|
||||
|
||||
fun update(transformer: Transformer<EditContactUM>) {
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal class AddValidatedAddressTransformer(
|
||||
private val address: ValidatedAddress,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
// Skip duplicates: an address is identified by its string value (it already carries all its networks).
|
||||
if (prevState.addresses.any { it.address == address.address }) return prevState
|
||||
return prevState.copy(
|
||||
addresses = (prevState.addresses + address).toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class SelectContactColorTransformer(
|
||||
private val color: CryptoPortfolioIcon.Color,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
return prevState.copy(
|
||||
colors = prevState.colors.copy(selected = color),
|
||||
portfolioIcon = prevState.portfolioIcon.copy(color = color),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
internal class UpdateContactNameTransformer(
|
||||
private val name: String,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
return prevState.copy(name = name)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.tangem.features.addressbook.editcontact.state.transformers
|
||||
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Wires the title (derived from whether an existing contact is being edited) and the callbacks owned by
|
||||
* [com.tangem.features.addressbook.editcontact.model.EditContactModel] into the initial state.
|
||||
*/
|
||||
internal class UpdateEditContactInitialStateTransformer(
|
||||
private val isExistingContact: Boolean,
|
||||
private val onNameChange: (String) -> Unit,
|
||||
private val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
|
||||
private val onCloseClick: () -> Unit,
|
||||
private val onAddAddressClick: () -> Unit,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
val titleResId = if (isExistingContact) {
|
||||
R.string.address_book_contact
|
||||
} else {
|
||||
R.string.address_book_new_contact
|
||||
}
|
||||
return prevState.copy(
|
||||
title = resourceReference(titleResId),
|
||||
colors = prevState.colors.copy(onColorSelect = onColorSelect),
|
||||
onNameChange = onNameChange,
|
||||
onCloseClick = onCloseClick,
|
||||
onAddAddressClick = onAddAddressClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,16 @@
|
|||
package com.tangem.features.addressbook.editcontact.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
|
|
@ -22,19 +18,27 @@ import com.tangem.common.ui.account.AccountIcon
|
|||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.common.ui.account.getUiColor
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.components.block.BlockCard
|
||||
import com.tangem.core.ui.components.block.TangemBlockCardColors
|
||||
import com.tangem.core.ui.components.fields.AutoSizeTextField
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowText
|
||||
import com.tangem.core.ui.ds2.row.TangemRowTextRole
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
|
@ -63,118 +67,126 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
|
|||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ContactSummary(state = state)
|
||||
ContactColor(colors = state.colors)
|
||||
ContactAddresses(addresses = state.addresses)
|
||||
AddAddressRow(onClick = state.onAddAddressClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) {
|
||||
if (addresses.isEmpty()) return
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
addresses.fastForEach { entry ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
BlockCard(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary),
|
||||
) {
|
||||
Text(
|
||||
text = entry.network.name,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
)
|
||||
Text(
|
||||
text = entry.address,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
maxLines = 1,
|
||||
)
|
||||
ContactAddresses(addresses = state.addresses)
|
||||
AddAddressRow(onClick = state.onAddAddressClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddAddressRow(onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.secondary)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 15.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(TangemTheme.colors3.bg.status.infoSubtle),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(18.dp),
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_plus_24),
|
||||
tint = TangemTheme.colors3.text.status.info,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_add_address),
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_add_address_description),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
)
|
||||
}
|
||||
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) {
|
||||
addresses.fastForEach { entry ->
|
||||
AddressRow(entry = entry)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressRow(entry: ValidatedAddress) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Ident(text = entry.address),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = stringReference(entry.address),
|
||||
role = TangemRowTextRole.Title,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = pluralReference(
|
||||
id = R.plurals.common_networks_count,
|
||||
count = entry.networkIds.size,
|
||||
formatArgs = wrappedList(entry.networkIds.size),
|
||||
),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddAddressRow(onClick: () -> Unit) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
onClick = onClick,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Icon(
|
||||
imageVector = Icons.ic_sign_plus_20,
|
||||
tintReference = { TangemTheme.colors3.icon.brand },
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = TangemTheme.colors3.bg.status.infoSubtle,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
.padding(8.dp),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_add_address_description),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactSummary(state: EditContactUM) {
|
||||
val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
.background(TangemTheme.colors3.bg.secondary)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
SpacerH(20.dp)
|
||||
|
||||
AccountIcon(
|
||||
name = stringReference(avatarName),
|
||||
icon = state.portfolioIcon,
|
||||
size = AccountIconSize.Large,
|
||||
size = AccountIconSize.RedesignLarge,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
SpacerH(28.dp)
|
||||
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_contact_name),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
SpacerH(4.dp)
|
||||
|
||||
AutoSizeTextField(
|
||||
value = state.name,
|
||||
|
|
@ -186,7 +198,7 @@ private fun ContactSummary(state: EditContactUM) {
|
|||
color = TangemTheme.colors3.text.primary,
|
||||
placeholderColor = TangemTheme.colors3.text.tertiary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
SpacerH(8.dp)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,16 +208,16 @@ private fun ContactSummary(state: EditContactUM) {
|
|||
private fun ContactColor(colors: EditContactUM.Colors) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.secondary),
|
||||
.background(TangemTheme.colors3.bg.secondary)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
FlowRow(
|
||||
maxItemsInEachRow = 6,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
colors.list.fastForEach { color ->
|
||||
val isSelected = color == colors.selected
|
||||
|
|
@ -219,12 +231,12 @@ private fun ContactColor(colors: EditContactUM.Colors) {
|
|||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(47.dp)
|
||||
.size(48.dp)
|
||||
.border(2.dp, color.getUiColor(), shape = CircleShape),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.size(38.dp)
|
||||
.background(color = color.getUiColor(), shape = CircleShape),
|
||||
)
|
||||
} else {
|
||||
|
|
@ -260,7 +272,12 @@ private fun Preview_EditContactContent() {
|
|||
list = colors,
|
||||
onColorSelect = {},
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
addresses = persistentListOf(
|
||||
ValidatedAddress(
|
||||
address = "0x1234567890abcdef1234567890abcdef12345678",
|
||||
networkIds = persistentListOf("ethereum", "bsc", "polygon"),
|
||||
),
|
||||
),
|
||||
onNameChange = {},
|
||||
onCloseClick = {},
|
||||
onAddAddressClick = {},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package com.tangem.features.addressbook.editcontact.contract
|
||||
package com.tangem.features.addressbook.editcontact.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal data class EditContactUM(
|
||||
val title: TextReference,
|
||||
val name: String,
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.tangem.features.addressbook.editcontact.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* A recipient address validated on the AddAddress screen, together with the networks it resolves to.
|
||||
*
|
||||
* A single address can belong to several networks (e.g. the same address across EVM chains), so it carries a list of
|
||||
* [networkIds]. This is the in-progress (pre-save) representation accumulated in [EditContactUM]; the [networkIds] are
|
||||
* used to rebuild the domain `AddressEntry`s when the contact is persisted.
|
||||
*/
|
||||
@Immutable
|
||||
data class ValidatedAddress(
|
||||
val address: String,
|
||||
val networkIds: ImmutableList<String>,
|
||||
)
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package com.tangem.features.addressbook.list
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
|
||||
internal interface AddressBookListComponent : ComposableContentComponent {
|
||||
|
||||
interface Factory : ComponentFactory<Params, AddressBookListComponent>
|
||||
|
||||
data class Params(
|
||||
val onContactClick: (String) -> Unit,
|
||||
val onAddContactClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,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,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.tangem.features.addressbook.list.contract
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal sealed class AddressBookListUM {
|
||||
|
||||
data class Empty(
|
||||
val tangemButtonUM: TangemButtonUM,
|
||||
) : AddressBookListUM()
|
||||
data class AddressList(val contacts: ImmutableList<Contact>) : AddressBookListUM()
|
||||
}
|
||||
|
|
@ -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<AddressBookListComponent.Params>()
|
||||
private val params = paramsContainer.require<DefaultAddressBookListComponent.Params>()
|
||||
|
||||
val state: StateFlow<AddressBookListUM> = MutableStateFlow(
|
||||
AddressBookListUM.Empty(
|
||||
tangemButtonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_new_contact),
|
||||
tangemIconUM = TangemIconUM.Icon(
|
||||
iconRes = ic_plus_24,
|
||||
tintReference = { TangemTheme.colors3.text.inverse.primary },
|
||||
),
|
||||
iconPosition = TangemButtonIconPosition.End,
|
||||
type = TangemButtonType.Primary,
|
||||
onClick = params.onAddContactClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
val state: StateFlow<AddressBookListUM> get() = stateController.uiState
|
||||
|
||||
init {
|
||||
stateController.update(
|
||||
UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AddressBookListUM> =
|
||||
MutableStateFlow(value = getInitialState())
|
||||
|
||||
val uiState: StateFlow<AddressBookListUM> get() = mutableUiState.asStateFlow()
|
||||
|
||||
fun update(transformer: Transformer<AddressBookListUM>) {
|
||||
mutableUiState.update(function = transformer::transform)
|
||||
}
|
||||
|
||||
private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {})
|
||||
}
|
||||
|
|
@ -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<Contact, ContactUM> {
|
||||
|
||||
override fun convert(value: Contact): ContactUM = ContactUM(
|
||||
id = value.id.value,
|
||||
name = value.name.value,
|
||||
addressCount = value.addressEntries.size,
|
||||
)
|
||||
}
|
||||
|
|
@ -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<AddressBookListUM> {
|
||||
|
||||
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
|
||||
return when (prevState) {
|
||||
is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick)
|
||||
is AddressBookListUM.AddressList -> prevState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.features.addressbook.list.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
|
|
@ -9,26 +8,26 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.button.PrimaryTangemButton
|
||||
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20
|
||||
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
|
||||
|
||||
@Composable
|
||||
internal fun AddressBookEmptyScreen(
|
||||
tangemButtonUM: TangemButtonUM,
|
||||
onAddContactClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -41,26 +40,19 @@ internal fun AddressBookEmptyScreen(
|
|||
title = resourceReference(R.string.address_book_title),
|
||||
startContent = {
|
||||
TangemButton(
|
||||
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
|
||||
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_20),
|
||||
onClick = onBackClick,
|
||||
size = TangemButton.Size.X11,
|
||||
variant = TangemButton.Variant.Material,
|
||||
)
|
||||
},
|
||||
)
|
||||
NoContactInfo()
|
||||
PrimaryTangemButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
buttonUM = tangemButtonUM,
|
||||
)
|
||||
NoContactInfo(onAddClick = onAddContactClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.NoContactInfo() {
|
||||
private fun ColumnScope.NoContactInfo(onAddClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
|
|
@ -68,18 +60,24 @@ private fun ColumnScope.NoContactInfo() {
|
|||
) {
|
||||
ContactImage()
|
||||
Text(
|
||||
modifier = Modifier.padding(top = TangemTheme.dimens.spacing24),
|
||||
modifier = Modifier.padding(top = 32.dp),
|
||||
text = stringResourceSafe(R.string.address_book_no_contacts),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.heading.medium,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
text = stringResourceSafe(R.string.address_book_no_contacts_description),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
style = TangemTheme.typography3.subheading.medium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
TangemButton(
|
||||
modifier = Modifier.padding(top = 40.dp),
|
||||
text = resourceReference(R.string.address_book_add_address),
|
||||
onClick = onAddClick,
|
||||
iconEnd = TangemIconUM.Icon(imageVector = Icons.ic_sign_plus_20),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +93,7 @@ private fun ContactImage() {
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_contact_20),
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_address_book_24),
|
||||
contentDescription = stringResourceSafe(R.string.address_book_no_contacts),
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
|
|
@ -104,16 +102,11 @@ private fun ContactImage() {
|
|||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun Preview_AddressBookEmptyScreen() {
|
||||
AddressBookEmptyScreen(
|
||||
tangemButtonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_new_contact),
|
||||
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_plus_24),
|
||||
iconPosition = TangemButtonIconPosition.End,
|
||||
type = TangemButtonType.Secondary,
|
||||
onClick = {},
|
||||
),
|
||||
onBackClick = {},
|
||||
)
|
||||
TangemThemePreviewRedesign {
|
||||
AddressBookEmptyScreen(
|
||||
onAddContactClick = {},
|
||||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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<ContactUM>) : AddressBookListUM
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -3,24 +3,23 @@ package com.tangem.features.addressbook.addaddress.model
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.common.ui.extensions.iconResId
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.account.models.AccountList
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.features.addressbook.addaddress.AddAddressComponent
|
||||
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
|
||||
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
|
||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.test.mock.MockAccounts
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -28,11 +27,7 @@ import kotlinx.coroutines.test.StandardTestDispatcher
|
|||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.api.*
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
|
|
@ -73,7 +68,6 @@ internal class AddAddressModelTest {
|
|||
|
||||
// Assert
|
||||
assertThat(state.addressField.value).isEmpty()
|
||||
assertThat(state.addressField.isValuePasted).isFalse()
|
||||
assertThat(state.buttonUM.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
|
|
@ -87,13 +81,11 @@ internal class AddAddressModelTest {
|
|||
model.state.value.onAddressChange(address)
|
||||
|
||||
// Assert
|
||||
val field = model.state.value.addressField
|
||||
assertThat(field.value).isEqualTo(address)
|
||||
assertThat(field.isValuePasted).isFalse()
|
||||
assertThat(model.state.value.addressField.value).isEqualTo(address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty field WHEN onPasteClick THEN value marked as pasted`() = runTest {
|
||||
fun `GIVEN empty field WHEN onPasteClick THEN value taken from clipboard`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel(testScope = this)
|
||||
val address = "0xABC"
|
||||
|
|
@ -103,13 +95,10 @@ internal class AddAddressModelTest {
|
|||
model.state.value.onPasteClick()
|
||||
|
||||
// Assert
|
||||
val field = model.state.value.addressField
|
||||
assertThat(field.value).isEqualTo(address)
|
||||
assertThat(field.isValuePasted).isTrue()
|
||||
assertThat(model.state.value.addressField.value).isEqualTo(address)
|
||||
}
|
||||
|
||||
// validateAndConfirm() is an unimplemented seam — the button click must NOT emit a result yet.
|
||||
// This guards the foundation and will fail (prompting an update) once validation is wired in.
|
||||
@Test
|
||||
fun `GIVEN typed address WHEN button clicked THEN onConfirm not called yet`() = runTest {
|
||||
// Arrange
|
||||
|
|
@ -127,13 +116,12 @@ internal class AddAddressModelTest {
|
|||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class AddressInput {
|
||||
inner class Validation {
|
||||
|
||||
@Test
|
||||
fun `GIVEN coins available WHEN valid address typed THEN matching network chosen`() = runTest {
|
||||
fun `GIVEN coins available WHEN valid address typed THEN no error AND button enabled`() = runTest {
|
||||
// Arrange
|
||||
every { multiAccountListSupplier.invoke() } returns
|
||||
flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
|
|
@ -143,16 +131,14 @@ internal class AddAddressModelTest {
|
|||
|
||||
// Assert
|
||||
val state = model.state.value
|
||||
assertThat(state.availableNetworks).containsExactly(ethereum.network)
|
||||
assertThat(state.chosenNetworkStateUM)
|
||||
.isEqualTo(resultOf(ethereum.network))
|
||||
assertThat(state.addressField.isError).isFalse()
|
||||
assertThat(state.buttonUM.isEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coins available WHEN address matches no network THEN empty state`() = runTest {
|
||||
fun `GIVEN coins available WHEN address matches no network THEN error AND button disabled`() = runTest {
|
||||
// Arrange
|
||||
every { multiAccountListSupplier.invoke() } returns
|
||||
flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
|
|
@ -162,31 +148,32 @@ internal class AddAddressModelTest {
|
|||
|
||||
// Assert
|
||||
val state = model.state.value
|
||||
assertThat(state.availableNetworks).isEmpty()
|
||||
assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
|
||||
assertThat(state.addressField.isError).isTrue()
|
||||
assertThat(state.addressField.label)
|
||||
.isEqualTo(resourceReference(R.string.address_book_invalid_address_error))
|
||||
assertThat(state.buttonUM.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no coins available WHEN valid address typed THEN empty state`() = runTest {
|
||||
// Arrange — supplier emits no accounts.
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
|
||||
fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest {
|
||||
// Arrange
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum)))
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
||||
model.state.value.onAddressChange("")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val state = model.state.value
|
||||
assertThat(state.availableNetworks).isEmpty()
|
||||
assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
|
||||
assertThat(state.addressField.isError).isFalse()
|
||||
assertThat(state.buttonUM.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
// Covers the "not initialized yet" case: the address is typed before coins load, and the
|
||||
// chosen network must resolve reactively once the supplier emits them.
|
||||
// The address is typed before coins load; validity must resolve reactively once the supplier emits them.
|
||||
@Test
|
||||
fun `GIVEN address typed before coins load WHEN coins emitted THEN network resolved reactively`() = runTest {
|
||||
fun `GIVEN address typed before coins load WHEN coins emitted THEN validated reactively`() = runTest {
|
||||
// Arrange
|
||||
val accountsFlow = MutableStateFlow<List<AccountList>>(emptyList())
|
||||
every { multiAccountListSupplier.invoke() } returns accountsFlow
|
||||
|
|
@ -197,29 +184,19 @@ internal class AddAddressModelTest {
|
|||
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
||||
advanceUntilIdle()
|
||||
// Assert intermediate: nothing to match yet
|
||||
assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
|
||||
assertThat(model.state.value.buttonUM.isEnabled).isFalse()
|
||||
|
||||
// Act — coins arrive later
|
||||
accountsFlow.value = listOf(accountListWith(ethereum, bitcoin))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.state.value.chosenNetworkStateUM)
|
||||
.isEqualTo(resultOf(ethereum.network))
|
||||
val state = model.state.value
|
||||
assertThat(state.buttonUM.isEnabled).isTrue()
|
||||
assertThat(state.addressField.isError).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resultOf(vararg networks: Network) = AddAddressUM.ChosenNetworkStateUM.Result(
|
||||
networkUMList = networks
|
||||
.map { network ->
|
||||
AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM(
|
||||
networkName = network.name,
|
||||
iconResId = network.iconResId,
|
||||
)
|
||||
}
|
||||
.toImmutableList(),
|
||||
)
|
||||
|
||||
private fun accountListWith(vararg currencies: CryptoCurrency): AccountList {
|
||||
val walletId = MockAccounts.userWalletId
|
||||
val accounts = listOf(
|
||||
|
|
@ -239,7 +216,7 @@ internal class AddAddressModelTest {
|
|||
private fun createModel(
|
||||
testScope: TestScope,
|
||||
onConfirm: (ValidatedAddress) -> Unit = {},
|
||||
params: AddAddressComponent.Params = AddAddressComponent.Params(
|
||||
params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params(
|
||||
onBackClick = {},
|
||||
onConfirm = onConfirm,
|
||||
),
|
||||
|
|
@ -250,6 +227,7 @@ internal class AddAddressModelTest {
|
|||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||
multiAccountListSupplier = multiAccountListSupplier,
|
||||
clipboardManager = clipboardManager,
|
||||
stateController = AddAddressStateController(),
|
||||
).also { model = it }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ internal sealed class DetailsItemUM {
|
|||
override val id: String = "wallet_connect"
|
||||
}
|
||||
|
||||
data class WalletConnectAddressBookBlock(val items: List<Item>) : DetailsItemUM() {
|
||||
data class WalletActionBlock(val items: ImmutableList<Item>) : DetailsItemUM() {
|
||||
override val id: String = "wallet_connect_address_book"
|
||||
|
||||
sealed class Item(open val onClick: () -> Unit) {
|
||||
|
||||
data class WalletConnect(override val onClick: () -> Unit) : Item(onClick)
|
||||
data class AddressBook(override val onClick: () -> Unit) : Item(onClick)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ package com.tangem.features.details.ui
|
|||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.scrollable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -27,19 +28,27 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBar
|
|||
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
|
||||
import com.tangem.core.ui.components.block.BlockCard
|
||||
import com.tangem.core.ui.components.block.BlockItem
|
||||
import com.tangem.core.ui.components.inputrow.InputRowImageBase
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowText
|
||||
import com.tangem.core.ui.ds2.row.TangemRowTextRole
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_chevron_right_20
|
||||
import com.tangem.core.ui.test.DetailsScreenTestTags
|
||||
import com.tangem.features.details.component.preview.PreviewDetailsComponent
|
||||
import com.tangem.features.details.entity.DetailsFooterUM
|
||||
import com.tangem.features.details.entity.DetailsItemUM
|
||||
import com.tangem.features.details.entity.DetailsUM
|
||||
import com.tangem.features.details.impl.R
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
internal fun DetailsScreen(
|
||||
|
|
@ -159,9 +168,9 @@ private fun Block(
|
|||
onClick = model.onClick,
|
||||
)
|
||||
}
|
||||
is DetailsItemUM.WalletConnectAddressBookBlock -> {
|
||||
is DetailsItemUM.WalletActionBlock -> {
|
||||
BlockCard {
|
||||
WalletConnectAddressBookBlockItems(
|
||||
WalletActionsBlock(
|
||||
items = model.items,
|
||||
modifier = itemModifier,
|
||||
)
|
||||
|
|
@ -170,37 +179,106 @@ private fun Block(
|
|||
is DetailsItemUM.UserWalletList -> {
|
||||
userWalletListBlockContent.Content(modifier = itemModifier)
|
||||
}
|
||||
is DetailsItemUM.UnderSectionText -> { /* Handled above */
|
||||
}
|
||||
is DetailsItemUM.UnderSectionText -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletConnectAddressBookBlockItems(
|
||||
items: List<DetailsItemUM.WalletConnectAddressBookBlock.Item>,
|
||||
private fun WalletActionsBlock(
|
||||
items: ImmutableList<DetailsItemUM.WalletActionBlock.Item>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
items.fastForEach { item ->
|
||||
when (item) {
|
||||
is DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect -> InputRowImageBase(
|
||||
modifier = modifier.clickable(onClick = item.onClick).padding(12.dp),
|
||||
iconResVector = R.drawable.ic_wallet_connect_24,
|
||||
iconTint = TangemTheme.colors.icon.primary1,
|
||||
subtitle = TextReference.Res(R.string.wallet_connect_title),
|
||||
caption = TextReference.Res(R.string.wallet_connect_subtitle),
|
||||
is DetailsItemUM.WalletActionBlock.Item.WalletConnect -> WalletConnectActionRow(
|
||||
onClick = item.onClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
is DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook -> InputRowImageBase(
|
||||
modifier = modifier.clickable(onClick = item.onClick).padding(12.dp),
|
||||
iconResVector = R.drawable.ic_contact_20,
|
||||
iconTint = TangemTheme.colors.icon.accent,
|
||||
subtitle = TextReference.Res(R.string.address_book_title),
|
||||
caption = TextReference.Res(R.string.address_book_description),
|
||||
is DetailsItemUM.WalletActionBlock.Item.AddressBook -> AddressBookActionRow(
|
||||
onClick = item.onClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletConnectActionRow(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Image(R.drawable.img_wallet_connect_76),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.wallet_connect_title),
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.wallet_connect_subtitle),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
endSlot = { ActionRowChevron() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddressBookActionRow(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
startSlot = {
|
||||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Icon(
|
||||
iconRes = R.drawable.ic_address_book_24,
|
||||
tintReference = { TangemTheme.colors3.icon.brand },
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = TangemTheme.colors3.bg.status.infoSubtle,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(8.dp),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_title),
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_description),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
},
|
||||
endSlot = { ActionRowChevron() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionRowChevron() {
|
||||
Icon(
|
||||
imageVector = Icons.ic_chevron_right_20,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.secondary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnderSectionTextBlock(text: TextReference, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ internal class ItemsBuilder @Inject constructor(
|
|||
onBuyClick: () -> Unit,
|
||||
): ImmutableList<DetailsItemUM> = buildList {
|
||||
if (isAddressBookAvailable) {
|
||||
buildWalletConnectAddressBookBlock(isWalletConnectAvailable, userWalletId)
|
||||
buildWalletActionBlock(isWalletConnectAvailable, userWalletId)
|
||||
} else {
|
||||
buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
|
||||
}
|
||||
|
|
@ -91,29 +91,29 @@ internal class ItemsBuilder @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun MutableList<DetailsItemUM>.buildWalletConnectAddressBookBlock(
|
||||
private fun MutableList<DetailsItemUM>.buildWalletActionBlock(
|
||||
isWalletConnectAvailable: Boolean,
|
||||
userWalletId: UserWalletId,
|
||||
) {
|
||||
val walletConnectAddressBookItems = buildList {
|
||||
val walletActionItems = buildList {
|
||||
if (isWalletConnectAvailable) add(buildWalletConnectButton(userWalletId))
|
||||
add(buildAddressBookButton())
|
||||
}
|
||||
if (walletConnectAddressBookItems.isNotEmpty()) {
|
||||
add(DetailsItemUM.WalletConnectAddressBookBlock(walletConnectAddressBookItems))
|
||||
}.toImmutableList()
|
||||
if (walletActionItems.isNotEmpty()) {
|
||||
add(DetailsItemUM.WalletActionBlock(walletActionItems))
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildWalletConnectButton(
|
||||
userWalletId: UserWalletId,
|
||||
): DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect {
|
||||
return DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect(
|
||||
): DetailsItemUM.WalletActionBlock.Item.WalletConnect {
|
||||
return DetailsItemUM.WalletActionBlock.Item.WalletConnect(
|
||||
onClick = { router.push(AppRoute.WalletConnectSessions(userWalletId)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildAddressBookButton(): DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook {
|
||||
return DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook(
|
||||
private fun buildAddressBookButton(): DetailsItemUM.WalletActionBlock.Item.AddressBook {
|
||||
return DetailsItemUM.WalletActionBlock.Item.AddressBook(
|
||||
onClick = { router.push(AppRoute.AddressBook()) },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,10 +65,10 @@ internal class ItemsBuilderTest {
|
|||
"support",
|
||||
).inOrder()
|
||||
|
||||
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
|
||||
val block = result.first() as DetailsItemUM.WalletActionBlock
|
||||
assertThat(block.items.map { it::class.java }).containsExactly(
|
||||
DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect::class.java,
|
||||
DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java,
|
||||
DetailsItemUM.WalletActionBlock.Item.WalletConnect::class.java,
|
||||
DetailsItemUM.WalletActionBlock.Item.AddressBook::class.java,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +86,9 @@ internal class ItemsBuilderTest {
|
|||
"support",
|
||||
).inOrder()
|
||||
|
||||
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
|
||||
val block = result.first() as DetailsItemUM.WalletActionBlock
|
||||
assertThat(block.items.map { it::class.java }).containsExactly(
|
||||
DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java,
|
||||
DetailsItemUM.WalletActionBlock.Item.AddressBook::class.java,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -109,9 +109,9 @@ internal class ItemsBuilderTest {
|
|||
fun `GIVEN combined block walletConnect item WHEN clicked THEN router pushes WalletConnectSessions`() {
|
||||
// Arrange
|
||||
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true)
|
||||
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
|
||||
val block = result.first() as DetailsItemUM.WalletActionBlock
|
||||
val walletConnect = block.items
|
||||
.filterIsInstance<DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect>()
|
||||
.filterIsInstance<DetailsItemUM.WalletActionBlock.Item.WalletConnect>()
|
||||
.single()
|
||||
|
||||
// Act
|
||||
|
|
@ -125,9 +125,9 @@ internal class ItemsBuilderTest {
|
|||
fun `GIVEN combined block addressBook item WHEN clicked THEN router pushes AddressBook`() {
|
||||
// Arrange
|
||||
val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true)
|
||||
val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
|
||||
val block = result.first() as DetailsItemUM.WalletActionBlock
|
||||
val addressBook = block.items
|
||||
.filterIsInstance<DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook>()
|
||||
.filterIsInstance<DetailsItemUM.WalletActionBlock.Item.AddressBook>()
|
||||
.single()
|
||||
|
||||
// Act
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue