Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-19 18:16:09 +03:00
commit daa96ef62a
79 changed files with 2943 additions and 843 deletions

View file

@ -10,6 +10,9 @@ android {
dependencies {
/* Project - Common */
api(projects.common.routing)
/* Project - Domain */
implementation(projects.domain.models)

View file

@ -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)
}

View file

@ -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,
)
}

View file

@ -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,
)
}

View file

@ -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
}

View file

@ -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 = {},
)
}

View file

@ -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),
)
}
}

View file

@ -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),
),
)
}
}

View file

@ -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),
)
}
}

View file

@ -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 = {},
),
)
}

View file

@ -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 = {})
}
}
}

View file

@ -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

View file

@ -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,

View file

@ -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,
)

View file

@ -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))
}
}

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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,

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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,
)
}

View file

@ -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,
)
}

View file

@ -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,
)

View file

@ -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))
}
}

View file

@ -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 = {},
)
}
}

View file

@ -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(),
)
}
}

View file

@ -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),
)
}
}

View file

@ -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)
}
}

View file

@ -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,
)
}
}

View file

@ -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 = {},

View file

@ -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,

View file

@ -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>,
)

View file

@ -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,
)
}

View file

@ -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,
)
}

View file

@ -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()
}

View file

@ -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),
)
}
}

View file

@ -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 = {})
}

View file

@ -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,
)
}

View file

@ -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
}
}
}

View file

@ -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 = {},
)
}
}

View file

@ -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
}

View file

@ -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,
)

View file

@ -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()
}

View file

@ -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 }
}

View file

@ -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 {

View file

@ -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)
}

View file

@ -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(

View file

@ -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()) },
)
}

View file

@ -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

View file

@ -14,11 +14,13 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
import com.tangem.features.txhistory.impl.R
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import com.tangem.utils.toBriefAddressFormat
import kotlinx.collections.immutable.persistentListOf
import org.joda.time.DateTime
/**
@ -37,13 +39,18 @@ internal class TxInfoToTxHistoryDetailsUMConverter(
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: TxInfo): TxHistoryDetailsUM = when (value.type) {
is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets(header = value.toHeaderUM())
// TODO([REDACTED_TASK_KEY]): populate `from` / `to` legs once TxInfo exposes the swap legs (amounts, currencies, fiat).
// Until then the card falls back to the header-only placeholder (the TwoAssetsBlock UI is already wired).
is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets(
header = value.toHeaderUM(),
statusBanner = value.toStatusBannerUM(),
)
else -> TxHistoryDetailsUM.SingleAsset(
header = value.toHeaderUM(),
amountBlock = value.toAmountBlockUM(),
counterparty = value.toCounterpartyUM(),
// TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo.
rows = emptyList(),
rows = persistentListOf(),
)
}
@ -54,6 +61,31 @@ internal class TxInfoToTxHistoryDetailsUMConverter(
subtitle = headerSubtitle(),
)
/**
* Express status plaque under the swap block. A stopgap over the three generic [TxInfo.TransactionStatus] values
* so [Severity.Warning] (verification) is not reachable yet.
*
* [REDACTED_TODO_COMMENT]
*/
private fun TxInfo.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (status) {
is TxInfo.TransactionStatus.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
isLoading = true,
)
is TxInfo.TransactionStatus.Confirmed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
)
is TxInfo.TransactionStatus.Failed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
)
}
private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = iconStateConverter.convert(currency),
amount = stringReference(signedAmount(currency)),

View file

@ -8,6 +8,7 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
/**
* UI model for the in-app transaction details ("Operation") card.
@ -28,14 +29,79 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
override val header: HeaderUM,
val amountBlock: AmountBlockUM,
val counterparty: CounterpartyUM?,
val rows: List<InfoRowUM>,
val rows: ImmutableList<InfoRowUM>,
) : TxHistoryDetailsUM
/** Two-asset layout: Swap / Onramp */
/**
* Two-asset layout: Swap / Onramp.
*
* [from] ("You sent") [to] ("You receive") exchange block. Both are nullable: the converter can't populate the
* legs yet (`TxInfo` exposes no swap amounts/currencies/fiat), so the card falls back to a header-only placeholder
* until that data lands. [statusBanner] is the express status plaque under the block, `null` until status is known.
*/
data class TwoAssets(
override val header: HeaderUM,
val from: AssetUM? = null,
val to: AssetUM? = null,
val statusBanner: StatusBannerUM? = null,
) : TxHistoryDetailsUM
/**
* Express status plaque under the two-asset block. The UI animates between successive emissions.
*
* @property severity Plaque colors (background tint + text/icon color).
* @property title Status line, e.g. "Awaiting funds" / "Confirmed" / "Failed".
* @property subtitle Optional second line (e.g. the refund hint on a failed terminal).
* @property isLoading `true` trailing rotating loader (in-progress); `false` static [severity] glyph.
*/
data class StatusBannerUM(
val severity: Severity,
val title: TextReference,
val subtitle: TextReference? = null,
val isLoading: Boolean,
) {
/** Visual severity of the [StatusBannerUM] — selects the background tint and the text/icon color. */
enum class Severity { Info, Success, Error, Warning }
}
/**
* One side of the two-asset block: the [label] over the signed [amount], with the [currencyIcon] on the trailing
* side. [owner] `null` plain label ("You sent"); non-null "From"/"To" prefix plus the resolved own account /
* wallet decoration. [isFaded] renders the unsettled/failed amount (struck through, recolored to tertiary).
*/
data class AssetUM(
val label: TextReference,
val owner: AssetOwnerUM?,
val amount: TextReference,
val currencyIcon: CurrencyIconState,
val isFaded: Boolean,
)
/**
* Counterparty rendered inline in an [AssetUM.label] when a swap leg resolves to one of the user's own portfolios.
* Carries the [name] plus a kind-specific 16dp decoration. Only own account / own wallet are decorated here (no
* address case, unlike the single-asset [CounterpartyAvatar]).
*/
@Immutable
sealed interface AssetOwnerUM {
val name: TextReference
/** User's own account — the [iconResId] glyph tinted over [backgroundColor], shown **before** the [name]. */
data class Account(
override val name: TextReference,
@DrawableRes val iconResId: Int,
val backgroundColor: Color,
) : AssetOwnerUM
/** User's own wallet — the wallet card [deviceIconUM], shown **after** the [name]. */
data class Wallet(
override val name: TextReference,
val deviceIconUM: DeviceIconUM,
) : AssetOwnerUM
}
/**
* Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto
* [amount] and the secondary [fiatAmount].
@ -43,7 +109,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* [isFailed] drives the failed visual state the amount is struck through, recolored to tertiary and carries no
* `+`/`` sign (mirrors the status-driven recolor in the shared header).
*/
@Immutable
data class AmountBlockUM(
val currencyIcon: CurrencyIconState,
val amount: TextReference,
@ -55,7 +120,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* A single info row of the details card: a [label] on the leading side and its [value] on the trailing side
* (e.g. `Network fee` `0.00056 ETH`, `Rate` `1 POL 0.36 USDT`). Rendered by [TxHistoryDetailsInfoRows].
*/
@Immutable
data class InfoRowUM(
val label: TextReference,
val value: TextReference,
@ -76,7 +140,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* @property avatar Leading avatar.
* @property onCopyClick Copy action; `null` hides the copy button (e.g. own-wallet has nothing to copy).
*/
@Immutable
data class CounterpartyUM(
val label: TextReference,
val title: TextReference,
@ -105,7 +168,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* Shared bottom-sheet top bar. The icon glyph and [title] text come from the transaction type; [status] drives
* the three visual states (in-progress / confirmed / failed) recoloring the icon circle and the title.
*/
@Immutable
data class HeaderUM(
@DrawableRes val iconRes: Int,
val status: TransactionItemUM.Content.Status,

View file

@ -19,8 +19,7 @@ import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
internal fun TxHistoryDetailsContent(state: TxHistoryDetailsUM, modifier: Modifier = Modifier) {
when (state) {
is TxHistoryDetailsUM.SingleAsset -> SingleAssetContent(state = state, modifier = modifier)
// TODO([REDACTED_TASK_KEY]): two-asset (Swap / Onramp) body — out of scope for the single-asset amount block ticket.
is TxHistoryDetailsUM.TwoAssets -> TwoAssetsPlaceholder(state = state, modifier = modifier)
is TxHistoryDetailsUM.TwoAssets -> TwoAssetsContent(state = state, modifier = modifier)
}
}
@ -45,6 +44,35 @@ private fun SingleAssetContent(state: TxHistoryDetailsUM.SingleAsset, modifier:
}
}
@Composable
private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modifier = Modifier) {
val from = state.from
val to = state.to
Column(modifier = modifier.fillMaxWidth().padding(bottom = 16.dp)) {
if (from != null && to != null) {
TxHistoryDetailsTwoAssetsBlock(
from = from,
to = to,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
)
} else {
// TODO([REDACTED_TASK_KEY]): the converter cannot populate the swap legs yet (TxInfo exposes no two-leg / fiat /
// provider data). Until those fields land, fall back to the header-only placeholder.
TwoAssetsPlaceholder(state = state)
}
// Express status plaque under the exchange block. The top gap is owned by the banner (inside its collapsing
// region), so only horizontal padding is applied here.
TxHistoryDetailsStatusBanner(
state = state.statusBanner,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
@Composable
private fun TwoAssetsPlaceholder(state: TxHistoryDetailsUM.TwoAssets, modifier: Modifier = Modifier) {
Box(

View file

@ -20,6 +20,8 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Info-rows block of the transaction details card: a vertical list of DS3 [TangemRow]s (label on the leading side,
@ -36,7 +38,7 @@ import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM
* @param modifier Modifier applied to the list container.
*/
@Composable
internal fun TxHistoryDetailsInfoRows(rows: List<InfoRowUM>, modifier: Modifier = Modifier) {
internal fun TxHistoryDetailsInfoRows(rows: ImmutableList<InfoRowUM>, modifier: Modifier = Modifier) {
if (rows.isEmpty()) return
Column(
modifier = modifier,
@ -74,7 +76,7 @@ private fun TxHistoryDetailsInfoRowsPreview() {
) {
// Multiple rows — dividers between rows, none after the last
TxHistoryDetailsInfoRows(
rows = listOf(
rows = persistentListOf(
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
InfoRowUM(label = stringReference("Rate"), value = stringReference("1 POL ≈ 0.36 USDT")),
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
@ -83,7 +85,7 @@ private fun TxHistoryDetailsInfoRowsPreview() {
// Single row — no divider
TxHistoryDetailsInfoRows(
modifier = Modifier.padding(top = 16.dp),
rows = listOf(
rows = persistentListOf(
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),
)

View file

@ -14,6 +14,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import kotlinx.collections.immutable.persistentListOf
/**
* The transaction details bottom sheet ("Operation"): the [TangemModalBottomSheet] shell shared by all transaction
@ -79,7 +80,7 @@ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
),
onCopyClick = {},
),
rows = listOf(
rows = persistentListOf(
TxHistoryDetailsUM.InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),
)

View file

@ -0,0 +1,311 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
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_error_20
import com.tangem.core.ui.res.generated.icons.ic_info_20
import com.tangem.core.ui.res.generated.icons.ic_success_20
import com.tangem.core.ui.res.generated.icons.ic_warning_20
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
// Animation timings in ms (ProtoPie spec). The status swap is two-phase: the old status fades out, then the new one
// fades/slides in after ENTER_DELAY. Most steps run over the default duration; the trailing loader/glyph fades faster
// (FAST_FADE), and the plaque grows over GROW to make room for a subtitle.
private const val DEFAULT_ANIMATION_MILLIS = 300
private const val FAST_FADE_MILLIS = 200
private const val GROW_MILLIS = 400
private const val ENTER_DELAY_MILLIS = DEFAULT_ANIMATION_MILLIS // phase 2 waits for the phase-1 fade-out to clear
private const val SUBTITLE_DELAY_MILLIS = ENTER_DELAY_MILLIS + 100 // subtitle trails the title
private const val TITLE_SLIDE_FRACTION = 12 // in-progress/Success title slides in 1/12 width from the right
private const val CONTENT_RISE_FRACTION = 2 // Warning/Error title floats up 1/2 height from below
private const val ICON_ENTER_SCALE = 0.6f
/** Gap between the exchange block above and the plaque; kept inside the collapsing region so it folds away cleanly. */
private val BANNER_TOP_GAP = 12.dp
/** Gap between the title row and the subtitle; lives inside the subtitle slot so it folds away when there's no line. */
private val SUBTITLE_TOP_GAP = 4.dp
/** Key for the title [AnimatedContent]: the resolved [text] plus the [severity] that selects the swap motion. */
private data class StatusBannerTitle(val text: String, val severity: Severity)
/**
* Title transition picked by the *target* severity: Info/Success slide in from the right ([titleSlide]); Warning/Error
* float up from below ([titleRise]). Both fade the old status out fully before fading the new one in.
*/
private fun titleTransition(target: Severity): ContentTransform = when (target) {
Severity.Warning, Severity.Error -> titleRise()
Severity.Info, Severity.Success -> titleSlide()
}
/** In-progress / success swap: old status fades out, new one fades in sliding from the right. */
private fun titleSlide(): ContentTransform = ContentTransform(
targetContentEnter = fadeIn(tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) +
slideInHorizontally(
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
) { width -> width / TITLE_SLIDE_FRACTION },
initialContentExit = fadeOut(tween(durationMillis = DEFAULT_ANIMATION_MILLIS)),
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
)
/** Terminal warning / error swap: old status fades out, new one fades in floating up a touch from below. */
private fun titleRise(): ContentTransform = ContentTransform(
targetContentEnter = fadeIn(tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) +
slideInVertically(
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
) { height -> height / CONTENT_RISE_FRACTION },
initialContentExit = fadeOut(tween(durationMillis = DEFAULT_ANIMATION_MILLIS)),
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
)
/** Trailing-slot swap (loader → glyph): loader fades out (Phase 1), then the glyph "pops" in (Phase 2). */
private fun iconSwapTransition(): ContentTransform = ContentTransform(
targetContentEnter = fadeIn(tween(durationMillis = FAST_FADE_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) +
scaleIn(
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
initialScale = ICON_ENTER_SCALE,
),
initialContentExit = fadeOut(tween(durationMillis = FAST_FADE_MILLIS)),
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
)
/**
* Express status plaque of the Swap / Onramp transaction details, rendered under the two-asset exchange block.
*
* [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1370-114172)
*
* Two animation layers: [AnimatedVisibility] grows the plaque in from its top edge / collapses it to the bottom;
* in-place status transitions ([StatusBannerContent]) morph the title, background tint and trailing loaderglyph as
* the model re-emits the latest [state].
*
* @param state Current status to render, or `null` to hide the plaque (animated out).
* @param modifier Modifier applied to the plaque container.
*/
@Composable
internal fun TxHistoryDetailsStatusBanner(state: StatusBannerUM?, modifier: Modifier = Modifier) {
// Retain the last non-null state so content stays rendered through the exit (collapse+fade). The retained value
// only backfills the exit (when [state] is null); published in a SideEffect, not written during composition.
val lastState = remember { mutableStateOf<StatusBannerUM?>(null) }
SideEffect { if (state != null) lastState.value = state }
val content = state ?: lastState.value
AnimatedVisibility(
visible = state != null,
// Fade and size share one tween so alpha and height finish together (mismatched default springs leave a jerk).
enter = fadeIn(tween(DEFAULT_ANIMATION_MILLIS)) +
expandVertically(tween(DEFAULT_ANIMATION_MILLIS), expandFrom = Alignment.Top),
exit = fadeOut(tween(DEFAULT_ANIMATION_MILLIS)) +
shrinkVertically(tween(DEFAULT_ANIMATION_MILLIS), shrinkTowards = Alignment.Bottom),
modifier = modifier,
) {
// Leading gap lives inside the animated region so it collapses together with the plaque (no residual margin).
content?.let { StatusBannerContent(state = it, modifier = Modifier.padding(top = BANNER_TOP_GAP)) }
}
}
@Composable
private fun StatusBannerContent(state: StatusBannerUM, modifier: Modifier = Modifier) {
val backgroundColor by animateColorAsState(
targetValue = state.severity.backgroundColor(),
// Delayed into Phase 2, so the tint starts shifting only once the old title has faded out, matching the spec.
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
label = "StatusBannerBackground",
)
val contentColor = state.severity.contentColor()
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Animate the title as the status advances. Keyed on (text, severity) so [titleTransition] picks the motion
// by target; the key also colors each content from its own severity (see [color] below).
AnimatedContent(
targetState = StatusBannerTitle(state.title.resolveReference(), state.severity),
transitionSpec = { titleTransition(target = targetState.severity) },
label = "StatusBannerTitle",
modifier = Modifier.weight(1f),
) { title ->
Text(
text = title.text,
style = TangemTheme.typography3.body.medium,
// From this title's own key, so the outgoing title fades out in its colour instead of snapping.
color = title.severity.contentColor(),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
StatusBannerTrailing(isLoading = state.isLoading, severity = state.severity)
}
// Retain the last non-null subtitle so the line stays rendered while it fades out (mirrors the retain above).
val lastSubtitle = remember { mutableStateOf<TextReference?>(null) }
SideEffect { if (state.subtitle != null) lastSubtitle.value = state.subtitle }
AnimatedVisibility(
visible = state.subtitle != null,
// The subtitle owns the plaque's growth: expandVertically opens its slot in Phase 2, then the text fades in
// a touch later so it trails the title. expandVertically (not animateContentSize) lets us delay the growth.
enter = expandVertically(
animationSpec = tween(GROW_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
expandFrom = Alignment.Top,
) + fadeIn(tween(DEFAULT_ANIMATION_MILLIS, delayMillis = SUBTITLE_DELAY_MILLIS)),
exit = shrinkVertically(tween(DEFAULT_ANIMATION_MILLIS), shrinkTowards = Alignment.Top) +
fadeOut(tween(DEFAULT_ANIMATION_MILLIS)),
) {
(state.subtitle ?: lastSubtitle.value)?.let { subtitle ->
Text(
text = subtitle.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = contentColor,
modifier = Modifier.padding(top = SUBTITLE_TOP_GAP),
)
}
}
}
}
/** Key for the trailing [AnimatedContent]: whether the loader or a glyph shows, plus the [severity] that tints it. */
private data class StatusBannerGlyph(val isLoading: Boolean, val severity: Severity)
/** Trailing slot: rotating loader while in progress, the static severity status glyph once terminal. */
@Composable
private fun StatusBannerTrailing(isLoading: Boolean, severity: Severity, modifier: Modifier = Modifier) {
// Keyed on (isLoading, severity) so the tint comes from each content's own key — the outgoing loader then fades
// out in its colour instead of snapping to the incoming status'.
AnimatedContent(
targetState = StatusBannerGlyph(isLoading, severity),
transitionSpec = { iconSwapTransition() },
label = "StatusBannerTrailing",
modifier = modifier,
) { glyph ->
val tint = glyph.severity.contentColor()
if (glyph.isLoading) {
TangemLoader(size = TangemLoaderSize.X20, color = tint)
} else {
Icon(
imageVector = glyph.severity.statusIcon(),
contentDescription = null,
tint = tint,
modifier = Modifier.size(20.dp),
)
}
}
}
@Composable
private fun Severity.backgroundColor(): Color = when (this) {
Severity.Info -> TangemTheme.colors3.bg.status.infoSubtle
Severity.Success -> TangemTheme.colors3.bg.status.successSubtle
Severity.Error -> TangemTheme.colors3.bg.status.errorSubtle
Severity.Warning -> TangemTheme.colors3.bg.status.warningSubtle
}
@Composable
private fun Severity.contentColor(): Color = when (this) {
Severity.Info -> TangemTheme.colors3.text.status.info
Severity.Success -> TangemTheme.colors3.text.status.success
Severity.Error -> TangemTheme.colors3.text.status.error
Severity.Warning -> TangemTheme.colors3.text.status.warning
}
private fun Severity.statusIcon() = when (this) {
Severity.Success -> Icons.ic_success_20
Severity.Error -> Icons.ic_error_20
Severity.Warning -> Icons.ic_warning_20
Severity.Info -> Icons.ic_info_20
}
// region Preview
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TxHistoryDetailsStatusBannerPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(Severity.Info, stringReference("Awaiting funds"), isLoading = true),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(Severity.Info, stringReference("Deposit confirmed"), isLoading = true),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(Severity.Success, stringReference("Confirmed"), isLoading = false),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(
severity = Severity.Error,
title = stringReference("Failed"),
subtitle = stringReference("Visit provider's website to refund your money"),
isLoading = false,
),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(
severity = Severity.Warning,
title = stringReference("Verification required"),
subtitle = stringReference("Visit provider's website to refund your money"),
isLoading = false,
),
)
}
}
}
// endregion

View file

@ -0,0 +1,300 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
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.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon
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.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetOwnerUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetUM
/**
* Two-asset ("exchange") block of the details card, used by Swap (and later Onramp): one `bg.tertiary` rounded cell
* with the [from] ("You sent") side over the [to] ("You receive") side, split by an inset dashed divider with a
* centered down-arrow masking the line. Each side is a [TangemRow]: label over the signed amount, avatar trailing.
*
* [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1265-87546)
*
* @param from Sent ("You sent" / "From …") side.
* @param to Received ("You receive" / "To …") side.
* @param modifier Modifier applied to the block container.
*/
@Composable
internal fun TxHistoryDetailsTwoAssetsBlock(from: AssetUM, to: AssetUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.background(TangemTheme.colors3.bg.tertiary),
) {
Column(
modifier = Modifier.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
TwoAssetsSideRow(asset = from)
DashedDivider()
TwoAssetsSideRow(asset = to)
}
// Centered exchange arrow. Both rows are equal-height, so the block center sits on the divider; the
// `bg.tertiary` chip behind the icon masks the dashed line, reproducing the Figma center gap.
Box(
modifier = Modifier
.align(Alignment.Center)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.tertiary)
.padding(4.dp),
) {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_down_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
modifier = Modifier.size(16.dp),
)
}
}
}
@Composable
private fun TwoAssetsSideRow(asset: AssetUM, modifier: Modifier = Modifier) {
TangemRow(
modifier = modifier,
contentLead = TangemRowContentLead.Start,
verticalAlignment = TangemRowVerticalAlignment.Center,
titleSlot = { TwoAssetsSideLabel(label = asset.label, owner = asset.owner) },
subtitleSlot = {
Text(
text = asset.amount.resolveReference(),
style = TangemTheme.typography3.heading.small,
color = if (asset.isFaded) {
TangemTheme.colors3.text.tertiary
} else {
TangemTheme.colors3.text.primary
},
textDecoration = if (asset.isFaded) TextDecoration.LineThrough else null,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
},
endSlot = {
TangemCurrencyIcon(
state = asset.currencyIcon,
modifier = Modifier.size(40.dp),
)
},
)
}
/**
* Caption label above a leg amount. Renders the [label] prefix ("You sent" / "You receive", or "From" / "To" when an
* [owner] is present) and, for a resolved [owner], its inline 16dp decoration in the Figma order the account avatar
* leads its name, the wallet key-card icon trails its name.
*/
@Composable
private fun TwoAssetsSideLabel(label: TextReference, owner: AssetOwnerUM?, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LabelText(text = label)
when (owner) {
is AssetOwnerUM.Account -> {
AssetOwnerIcon(owner = owner)
LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false))
}
is AssetOwnerUM.Wallet -> {
LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false))
AssetOwnerIcon(owner = owner)
}
null -> Unit
}
}
}
@Composable
private fun LabelText(text: TextReference, modifier: Modifier = Modifier) {
Text(
text = text.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
/** 16dp inline owner decoration: the account glyph over its color, or the wallet device card. */
@Composable
private fun AssetOwnerIcon(owner: AssetOwnerUM, modifier: Modifier = Modifier) {
val iconModifier = modifier.size(16.dp)
when (owner) {
is AssetOwnerUM.Account -> Box(
modifier = iconModifier
.clip(RoundedCornerShape(4.dp))
.background(owner.backgroundColor),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = owner.iconResId),
contentDescription = null,
// staticDark == white in both themes (the constant glyph tone for a colored avatar), matching the
// white-on-color account avatar in Figma and the counterparty card / history-list account icon.
tint = TangemTheme.colors3.icon.staticDark,
modifier = Modifier.size(8.dp),
)
}
is AssetOwnerUM.Wallet -> TangemDeviceIcon(
state = owner.deviceIconUM,
modifier = iconModifier,
)
}
}
/** 1px inset dashed divider between the two sides, matching the Figma `divider` (dashed `line`). */
@Composable
private fun DashedDivider(modifier: Modifier = Modifier) {
val color = TangemTheme.colors3.border.tertiary
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(1.dp)
.drawBehind {
val stroke = 1.dp.toPx()
val y = size.height / 2f
drawLine(
color = color,
start = Offset(x = 0f, y = y),
end = Offset(x = size.width, y = y),
strokeWidth = stroke,
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(2.dp.toPx(), 4.dp.toPx()),
),
)
},
)
}
// region Preview
@Suppress("MagicNumber")
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TxHistoryDetailsTwoAssetsBlockPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Plain swap (no resolved owner) — both sides settled.
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false),
to = previewAsset(label = "You receive", amount = "+ 1,800.00 POL", isFaded = false),
)
// Unsettled swap — the "You receive" side is struck through until the funds arrive.
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false),
to = previewAsset(label = "You receive", amount = "1,800.00 POL", isFaded = true),
)
// Account -> another account (own-to-own transfer between two of the user's accounts).
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(
label = "From",
amount = "- 390 USDT",
isFaded = false,
owner = AssetOwnerUM.Account(
name = stringReference("Main account"),
iconResId = R.drawable.ic_rounded_star_24,
backgroundColor = Color(0xFF007FFF),
),
),
to = previewAsset(
label = "To",
amount = "+ 1,800.00 POL",
isFaded = false,
owner = AssetOwnerUM.Account(
name = stringReference("Family"),
iconResId = R.drawable.ic_family_24,
backgroundColor = Color(0xFF744FF1),
),
),
)
// Wallet -> another wallet (own-to-own transfer between two of the user's wallets).
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(
label = "From",
amount = "- 390 USDT",
isFaded = false,
owner = AssetOwnerUM.Wallet(
name = stringReference("Tangem 2.0"),
deviceIconUM = DeviceIconUM.Card(mainColor = Color(0xFF1E1E1E), secondColor = null),
),
),
to = previewAsset(
label = "To",
amount = "+ 1,800.00 POL",
isFaded = false,
owner = AssetOwnerUM.Wallet(
name = stringReference("My Wallet"),
deviceIconUM = DeviceIconUM.Ring(mainColor = Color(0xFF9F86FF)),
),
),
)
}
}
}
private fun previewAsset(label: String, amount: String, isFaded: Boolean, owner: AssetOwnerUM? = null) = AssetUM(
label = stringReference(label),
owner = owner,
amount = stringReference(amount),
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
isFaded = isFaded,
)
// endregion

View file

@ -102,6 +102,61 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
assertThat(header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24)
}
@Test
fun `GIVEN unconfirmed Swap WHEN convert THEN info status banner with loader`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Unconfirmed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
isLoading = true,
),
)
}
@Test
fun `GIVEN confirmed Swap WHEN convert THEN success status banner without loader`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Confirmed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
),
)
}
@Test
fun `GIVEN failed Swap WHEN convert THEN error status banner with refund subtitle`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Failed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
),
)
}
@Test
fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() {
// Arrange