Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 22:43:52 +03:00
commit d6f9f59866
1729 changed files with 67614 additions and 9361 deletions

View file

@ -73,7 +73,8 @@ internal fun AccountCreateEditContent(
.nestedScroll(nestedScrollConnection)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.weight(1f),
.weight(1f)
.testTag(AccountInfoEditScreenTestTags.ACCOUNT_DETAILS_CONTAINER),
) {
AccountSummary(state.account, isCreateMode)
SpacerH24()
@ -87,7 +88,8 @@ internal fun AccountCreateEditContent(
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(16.dp)
.testTag(AccountInfoEditScreenTestTags.SAVE_ACCOUNT_BUTTON),
enabled = state.buttonState.isButtonEnabled,
showProgress = state.buttonState.shouldShowProgress,
text = state.buttonState.text.resolveReference(),

1
features/address-book/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
build/

View file

@ -0,0 +1,22 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.addressbook.api"
}
dependencies {
/* Project - Domain */
implementation(projects.domain.models)
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/* Compose */
implementation(deps.compose.runtime)
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.addressbook
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface AddressBookComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddressBookComponent>
data class Params(val predefinedAddress: String?)
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.addressbook
interface AddressBookFeatureToggles {
val isAddressBookEnabled: Boolean
}

1
features/address-book/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
build/

View file

@ -0,0 +1,57 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.addressbook.impl"
}
dependencies {
/** Api */
implementation(projects.features.addressBook.api)
/** Domain */
implementation(projects.domain.account)
implementation(projects.domain.addressBook)
implementation(projects.domain.models)
/** Common */
implementation(projects.common.ui)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.utils)
/** Compose */
implementation(deps.compose.runtime)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.material3)
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
implementation(deps.decompose.ext.compose)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.kotlin.immutable.collections)
/** Utils */
implementation(projects.libs.blockchainSdk)
implementation(tangemDeps.blockchain)
/** Tests */
testImplementation(projects.test.core)
testImplementation(projects.test.mock)
testImplementation(projects.common.test)
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.addressbook
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
internal class DefaultAddressBookFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : AddressBookFeatureToggles {
override val isAddressBookEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_83_ADDRESS_BOOK_ENABLED)
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.addressbook.addaddress
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
internal interface AddAddressComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddAddressComponent>
data class Params(
val onBackClick: () -> Unit,
val onConfirm: (ValidatedAddress) -> Unit,
)
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.addressbook.addaddress
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.addressbook.addaddress.model.AddAddressModel
import com.tangem.features.addressbook.addaddress.ui.AddAddressContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddAddressComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: AddAddressComponent.Params,
) : AddAddressComponent, AppComponentContext by context {
private val model: AddAddressModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
AddAddressContent(
state = state,
modifier = modifier,
)
BackHandler(onBack = state.onBackClick)
}
@AssistedFactory
interface Factory : AddAddressComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddAddressComponent.Params,
): DefaultAddAddressComponent
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.addressbook.addaddress.contract
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.domain.models.network.Network
import kotlinx.collections.immutable.ImmutableList
internal data class AddAddressUM(
val addressField: AddressFieldUM,
val availableNetworks: ImmutableList<Network>,
val buttonUM: TangemButtonUM,
val chosenNetworkStateUM: ChosenNetworkStateUM,
val onAddressChange: (String) -> Unit,
val onAddressClear: () -> Unit,
val onPasteClick: () -> Unit,
val onQrClick: () -> Unit,
val onBackClick: () -> Unit,
) {
@Immutable
sealed class ChosenNetworkStateUM {
data object Loading : ChosenNetworkStateUM()
data object Empty : ChosenNetworkStateUM()
data class Result(
val networkUMList: ImmutableList<NetworkUM>,
) : ChosenNetworkStateUM() {
data class NetworkUM(
val networkName: String,
@DrawableRes val iconResId: Int,
)
}
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.addressbook.addaddress.contract
import com.tangem.core.ui.extensions.TextReference
internal data class AddressFieldUM(
val value: String,
val placeholder: TextReference,
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val isValuePasted: Boolean = false,
val blockchainAddress: String? = null,
)

View file

@ -0,0 +1,149 @@
package com.tangem.features.addressbook.addaddress.model
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.ui.extensions.iconResId
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.R
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.addaddress.AddAddressComponent
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import kotlin.collections.map
@OptIn(FlowPreview::class)
@ModelScoped
internal class AddAddressModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
multiAccountListSupplier: MultiAccountListSupplier,
private val clipboardManager: ClipboardManager,
) : Model() {
private val params: AddAddressComponent.Params = paramsContainer.require()
val state: StateFlow<AddAddressUM>
field = MutableStateFlow(getInitialState())
private val availableCoins: StateFlow<List<CryptoCurrency.Coin>> = multiAccountListSupplier()
.map { accountLists ->
accountLists
.flatMap { it.flattenCurrencies() }
.filterIsInstance<CryptoCurrency.Coin>()
.distinctBy { it.network.id }
}
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
private val addressInput = state
.map { it.addressField.value }
.distinctUntilChanged()
.debounce(ADD_ADDRESS_DEBOUNCE)
init {
subscribeToAddressInput()
}
private fun onAddressChange(value: String, isPasted: Boolean = false) {
state.update { oldState ->
oldState.copy(
addressField = oldState.addressField.copy(
value = value,
isValuePasted = isPasted,
isError = false,
error = null,
),
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading,
)
}
}
private fun subscribeToAddressInput() {
combine(addressInput, availableCoins) { input, coins ->
getUniqueNetworks(input, coins)
}
.onEach { availableNetworks ->
state.update { oldState ->
oldState.copy(
availableNetworks = availableNetworks,
chosenNetworkStateUM = createChosenNetworkState(availableNetworks),
)
}
}
.launchIn(modelScope)
}
private fun createChosenNetworkState(availableNetworks: ImmutableList<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)
}
private fun validateAndConfirm() {
// TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm
}
private fun getInitialState(): AddAddressUM = AddAddressUM(
addressField = AddressFieldUM(
value = "",
placeholder = resourceReference(R.string.common_address),
label = resourceReference(R.string.address_book_enter_address),
isError = false,
error = null,
isValuePasted = false,
),
availableNetworks = persistentListOf(),
buttonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_add_address),
type = TangemButtonType.Primary,
isEnabled = false,
onClick = ::validateAndConfirm,
),
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
onAddressChange = { onAddressChange(value = it) },
onAddressClear = { onAddressChange("") },
onPasteClick = ::onPaste,
onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
onBackClick = params.onBackClick,
)
companion object {
private const val ADD_ADDRESS_DEBOUNCE = 500L
}
}

View file

@ -0,0 +1,103 @@
package com.tangem.features.addressbook.addaddress.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.ds.button.PrimaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors3.bg.primary)
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.address_book_add_address),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
onClick = state.onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
RecipientRow(
addressField = state.addressField,
onValueChange = state.onAddressChange,
onAddressClear = state.onAddressClear,
onQrClick = state.onQrClick,
onPasteClick = state.onPasteClick,
)
SpacerH12()
NetworkBlock(state.chosenNetworkStateUM)
PrimaryButton(state.buttonUM)
}
}
@Composable
private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) {
Spacer(modifier = Modifier.weight(1f))
PrimaryTangemButton(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
buttonUM = buttonUM,
)
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_AddAddressContent() {
TangemThemePreviewRedesign {
AddAddressContent(
state = AddAddressUM(
addressField = AddressFieldUM(
value = "",
placeholder = resourceReference(R.string.address_book_enter_address),
label = resourceReference(R.string.common_address),
),
availableNetworks = persistentListOf(),
buttonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_add_address),
type = TangemButtonType.Primary,
isEnabled = false,
onClick = { },
),
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
onAddressChange = {},
onAddressClear = {},
onPasteClick = {},
onQrClick = {},
onBackClick = {},
),
)
}
}

View file

@ -0,0 +1,207 @@
package com.tangem.features.addressbook.addaddress.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
private const val MAX_VISIBLE_NETWORKS = 3
private val NetworkIconSize = 24.dp
// Horizontal advance per icon. Smaller than the icon size so icons overlap; the bg-colored ring on
// the icon drawn on top carves the crescent cut-out from the icon below.
private val NetworkIconStep = 18.dp
@Composable
internal fun NetworkBlock(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(color = TangemTheme.colors3.bg.secondary)
.padding(horizontal = 4.dp),
titleSlot = {
Text(
text = stringResourceSafe(R.string.common_network),
style = TangemTheme.typography.body2,
color = TangemTheme.colors3.text.primary,
)
},
endSlot = {
SelectNetworkButton(chosenNetworkStateUM)
},
)
}
@Composable
private fun SelectNetworkButton(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
when (chosenNetworkStateUM) {
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList)
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader()
AddAddressUM.ChosenNetworkStateUM.Empty -> {
Text(
modifier = Modifier.padding(start = 8.dp),
text = stringResourceSafe(R.string.address_book_select_network),
style = TangemTheme.typography.body2,
color = TangemTheme.colors3.text.secondary,
)
ChevronIcon()
}
}
}
}
@Composable
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
when (networks.size) {
0 -> Unit
1 -> {
val network = networks.first()
Image(
painter = painterResource(id = network.iconResId),
contentDescription = null,
)
Text(
modifier = Modifier.padding(start = 8.dp),
text = network.networkName,
style = TangemTheme.typography.body2,
color = TangemTheme.colors3.text.secondary,
)
ChevronIcon()
}
// 3 and any larger count share the same rendering: up to MAX_VISIBLE_NETWORKS overlapping
// icons, plus a "+N" badge that appears only when there are more than that.
else -> {
OverlappingNetworkIcons(networks)
ChevronIcon()
}
}
}
@Composable
private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
val visible = networks.take(MAX_VISIBLE_NETWORKS)
val remaining = networks.size - visible.size
Box(modifier = Modifier.wrapContentWidth()) {
visible.forEachIndexed { index, network ->
Image(
painter = painterResource(id = network.iconResId),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = NetworkIconStep * index)
.networkIconRing()
.size(NetworkIconSize),
)
}
if (remaining > 0) {
Box(
modifier = Modifier
.padding(start = NetworkIconStep * visible.size)
.networkIconRing()
.background(color = TangemTheme.colors3.bg.tertiary)
.size(NetworkIconSize),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$remaining",
style = TangemTheme.typography.caption1,
color = TangemTheme.colors3.text.secondary,
)
}
}
}
}
// bg-colored ring + clip applied to every overlapping element so the one drawn on top carves a
// crescent out of the one below it. The ring color must match the surface the icons sit on.
@Composable
private fun Modifier.networkIconRing(): Modifier = this
.border(width = 2.dp, color = TangemTheme.colors3.bg.secondary, shape = CircleShape)
.padding(2.dp)
.clip(CircleShape)
@Composable
private fun ChevronIcon() {
Image(
modifier = Modifier.padding(start = 8.dp),
painter = painterResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NetworkBlock() {
TangemThemePreviewRedesign {
Column {
NetworkBlock(
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = persistentListOf(
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
),
),
)
SpacerH12()
NetworkBlock(
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = persistentListOf(
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22),
NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22),
),
),
)
SpacerH12()
NetworkBlock(
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = List(15) {
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
}.toImmutableList(),
),
)
SpacerH12()
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading)
SpacerH12()
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty)
}
}
}

View file

@ -0,0 +1,143 @@
package com.tangem.features.addressbook.addaddress.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.components.fields.SimpleTextField
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
@Composable
internal fun RecipientRow(
addressField: AddressFieldUM,
onValueChange: (String) -> Unit,
onAddressClear: () -> Unit,
onQrClick: () -> Unit,
onPasteClick: () -> Unit,
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
text = stringResourceSafe(R.string.common_address),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors3.text.secondary,
)
TangemRow(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = TangemRowVerticalAlignment.Center,
contentLead = TangemRowContentLead.Start,
startSlot = {
TangemIcon(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.tertiary),
tangemIconUM = TangemIconUM.Ident(text = addressField.value),
)
},
titleSlot = {
SimpleTextField(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
value = addressField.value,
onValueChange = onValueChange,
placeholder = TextReference.Res(R.string.address_book_enter_address),
singleLine = false,
)
},
endSlot = {
if (addressField.value.isNotEmpty()) {
Icon(
modifier = Modifier.clickable(onClick = onAddressClear),
imageVector = Icons.ic_cross_circle_20_filled,
tint = TangemTheme.colors3.icon.tertiary,
contentDescription = null,
)
} else {
Row {
TangemButton(
variant = TangemButton.Variant.Secondary,
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_qrcode_scaner_24),
onClick = onQrClick,
)
SpacerW8()
TangemButton(
variant = TangemButton.Variant.Primary,
text = TextReference.Res(id = R.string.common_paste),
onClick = onPasteClick,
)
}
}
},
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_RecipientRow() {
TangemThemePreviewRedesign {
Column {
RecipientRow(
addressField = AddressFieldUM(
value = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359",
placeholder = resourceReference(R.string.address_book_enter_address),
label = resourceReference(R.string.common_address),
),
onValueChange = {},
onAddressClear = {},
onQrClick = {},
onPasteClick = {},
)
SpacerH12()
RecipientRow(
addressField = AddressFieldUM(
value = "",
placeholder = resourceReference(R.string.address_book_enter_address),
label = resourceReference(R.string.common_address),
),
onValueChange = {},
onAddressClear = {},
onQrClick = {},
onPasteClick = {},
)
}
}
}

View file

@ -0,0 +1,21 @@
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

@ -0,0 +1,108 @@
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

@ -0,0 +1,38 @@
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 dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
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

@ -0,0 +1,32 @@
package com.tangem.features.addressbook.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.addressbook.addaddress.model.AddAddressModel
import com.tangem.features.addressbook.list.model.AddressBookListModel
import com.tangem.features.addressbook.editcontact.model.EditContactModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface AddressBookModelModule {
@Binds
@IntoMap
@ClassKey(AddressBookListModel::class)
fun bindAddressBookModel(model: AddressBookListModel): Model
@Binds
@IntoMap
@ClassKey(EditContactModel::class)
fun bindEditContactModel(model: EditContactModel): Model
@Binds
@IntoMap
@ClassKey(AddAddressModel::class)
fun bindAddAddressModel(model: AddAddressModel): Model
}

View file

@ -0,0 +1,21 @@
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 dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AddressBookModule {
@Provides
@Singleton
fun provideAddressBookFeatureToggles(featureTogglesManager: FeatureTogglesManager): AddressBookFeatureToggles {
return DefaultAddressBookFeatureToggles(featureTogglesManager)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.addressbook.editcontact
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.addressbook.editcontact.model.EditContactModel
import com.tangem.features.addressbook.editcontact.ui.EditContactContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultEditContactComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: EditContactComponent.Params,
) : EditContactComponent, AppComponentContext by context {
private val model: EditContactModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
EditContactContent(
state = state,
modifier = modifier,
)
BackHandler(onBack = state.onCloseClick)
}
@AssistedFactory
interface Factory : EditContactComponent.Factory {
override fun create(
context: AppComponentContext,
params: EditContactComponent.Params,
): DefaultEditContactComponent
}
}

View file

@ -0,0 +1,17 @@
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

@ -0,0 +1,25 @@
package com.tangem.features.addressbook.editcontact.contract
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
internal data class EditContactUM(
val title: TextReference,
val name: String,
val namePlaceholder: TextReference,
val portfolioIcon: AccountIconUM.CryptoPortfolio,
val colors: Colors,
val addresses: ImmutableList<ValidatedAddress>,
val onNameChange: (String) -> Unit,
val onCloseClick: () -> Unit,
val onAddAddressClick: () -> Unit,
) {
data class Colors(
val selected: CryptoPortfolioIcon.Color,
val list: ImmutableList<CryptoPortfolioIcon.Color>,
val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
)
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.addressbook.editcontact.contract
import com.tangem.domain.models.network.Network
/**
* A recipient address that has been validated and resolved to a [Network] on the AddAddress screen.
*
* This is the in-progress (pre-save) representation accumulated in [EditContactUM]. It is converted to a domain
* `AddressEntry` only when the contact is persisted, since the entry's id and signature are produced at save time.
*/
data class ValidatedAddress(
val address: String,
val network: Network,
)

View file

@ -0,0 +1,80 @@
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.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 javax.inject.Inject
@ModelScoped
internal class EditContactModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params: EditContactComponent.Params = paramsContainer.require()
val state: StateFlow<EditContactUM>
field = MutableStateFlow(getInitialState())
private fun onNameChange(name: String) {
state.update { it.copy(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)
}
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,
)
}
}

View file

@ -0,0 +1,270 @@
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.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
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.account.AccountIconSize
import com.tangem.core.ui.components.fields.AutoSizeTextField
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.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors3.bg.primary)
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = state.title,
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
onClick = state.onCloseClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.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),
) {
Text(
text = entry.network.name,
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
)
Text(
text = entry.address,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
maxLines = 1,
)
}
}
}
}
@Composable
private fun AddAddressRow(onClick: () -> Unit) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.status.infoSubtle),
) {
Icon(
modifier = Modifier.size(18.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_plus_24),
tint = TangemTheme.colors3.text.status.info,
contentDescription = null,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = stringResourceSafe(R.string.address_book_add_address),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
)
Text(
text = stringResourceSafe(R.string.address_book_add_address_description),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
)
}
}
}
@Composable
private fun ContactSummary(state: EditContactUM) {
val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() }
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(24.dp))
AccountIcon(
name = stringReference(avatarName),
icon = state.portfolioIcon,
size = AccountIconSize.Large,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResourceSafe(R.string.address_book_contact_name),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
)
Spacer(modifier = Modifier.height(2.dp))
AutoSizeTextField(
value = state.name,
onValueChange = state.onNameChange,
centered = true,
singleLine = true,
placeholder = state.namePlaceholder,
textStyle = TangemTheme.typography3.heading.medium,
color = TangemTheme.colors3.text.primary,
placeholderColor = TangemTheme.colors3.text.tertiary,
)
Spacer(modifier = Modifier.height(20.dp))
}
}
@OptIn(ExperimentalLayoutApi::class)
@Suppress("MagicNumber")
@Composable
private fun ContactColor(colors: EditContactUM.Colors) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
FlowRow(
maxItemsInEachRow = 6,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
) {
colors.list.fastForEach { color ->
val isSelected = color == colors.selected
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = { colors.onColorSelect(color) })
.size(48.dp),
) {
if (isSelected) {
Box(
modifier = Modifier
.size(47.dp)
.border(2.dp, color.getUiColor(), shape = CircleShape),
)
Box(
modifier = Modifier
.size(36.dp)
.background(color = color.getUiColor(), shape = CircleShape),
)
} else {
Box(
modifier = Modifier
.size(40.dp)
.background(color = color.getUiColor(), shape = CircleShape),
)
}
}
}
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_EditContactContent() {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
TangemThemePreviewRedesign {
EditContactContent(
state = EditContactUM(
title = stringReference("New contact"),
name = "",
namePlaceholder = stringReference("New contact"),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = colors.first(),
),
colors = EditContactUM.Colors(
selected = colors.first(),
list = colors,
onColorSelect = {},
),
addresses = persistentListOf(),
onNameChange = {},
onCloseClick = {},
onAddAddressClick = {},
),
)
}
}

View file

@ -0,0 +1,14 @@
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

@ -0,0 +1,43 @@
package com.tangem.features.addressbook.list
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.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
internal class DefaultAddressBookListComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted val params: AddressBookListComponent.Params,
) : AddressBookListComponent, AppComponentContext by context {
private val model: AddressBookListModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
when (val addressBookListUM = state) {
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
tangemButtonUM = addressBookListUM.tangemButtonUM,
onBackClick = router::pop,
modifier = modifier,
)
is AddressBookListUM.AddressList -> TODO("[REDACTED_TASK_KEY]")
}
}
@AssistedFactory
interface Factory : AddressBookListComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookListComponent.Params,
): DefaultAddressBookListComponent
}
}

View file

@ -0,0 +1,15 @@
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

@ -0,0 +1,43 @@
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.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ModelScoped
internal class AddressBookListModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<AddressBookListComponent.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,
),
),
)
}

View file

@ -0,0 +1,119 @@
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.*
import androidx.compose.foundation.shape.CircleShape
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.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
@Composable
internal fun AddressBookEmptyScreen(
tangemButtonUM: TangemButtonUM,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.address_book_title),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
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,
)
}
}
@Composable
private fun ColumnScope.NoContactInfo() {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
ContactImage()
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing24),
text = stringResourceSafe(R.string.address_book_no_contacts),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.medium,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
text = stringResourceSafe(R.string.address_book_no_contacts_description),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun ContactImage() {
Box(
modifier = Modifier
.size(80.dp)
.background(
color = TangemTheme.colors3.bg.status.infoSubtle,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(R.drawable.ic_contact_20),
contentDescription = stringResourceSafe(R.string.address_book_no_contacts),
modifier = Modifier.size(28.dp),
)
}
}
@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 = {},
)
}

View file

@ -0,0 +1,271 @@
package com.tangem.features.addressbook.addaddress.model
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.ui.extensions.iconResId
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.addaddress.AddAddressComponent
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.test.mock.MockAccounts
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AddAddressModelTest {
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val clipboardManager: ClipboardManager = mockk()
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val ethereum = cryptoCurrencyFactory.createCoin(Blockchain.Ethereum)
private val bitcoin = cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin)
private var model: AddAddressModel? = null
@BeforeEach
fun resetMocks() {
clearMocks(multiAccountListSupplier, clipboardManager)
// Default: no accounts, so no coins are available unless a test overrides it.
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
}
@AfterEach
fun tearDown() {
// Cancels modelScope, stopping the long-lived availableCoins / address-input collectors.
model?.onDestroy()
model = null
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class AddressField {
@Test
fun `WHEN model created THEN field is empty AND button disabled`() = runTest {
// Act
val model = createModel(testScope = this)
val state = model.state.value
// Assert
assertThat(state.addressField.value).isEmpty()
assertThat(state.addressField.isValuePasted).isFalse()
assertThat(state.buttonUM.isEnabled).isFalse()
}
@Test
fun `GIVEN empty field WHEN onAddressChange THEN value updated`() = runTest {
// Arrange
val model = createModel(testScope = this)
val address = "0xABC"
// Act
model.state.value.onAddressChange(address)
// Assert
val field = model.state.value.addressField
assertThat(field.value).isEqualTo(address)
assertThat(field.isValuePasted).isFalse()
}
@Test
fun `GIVEN empty field WHEN onPasteClick THEN value marked as pasted`() = runTest {
// Arrange
val model = createModel(testScope = this)
val address = "0xABC"
every { clipboardManager.getText() } returns address
// Act
model.state.value.onPasteClick()
// Assert
val field = model.state.value.addressField
assertThat(field.value).isEqualTo(address)
assertThat(field.isValuePasted).isTrue()
}
// validateAndConfirm() is an unimplemented seam — the button click must NOT emit a result yet.
// This guards the foundation and will fail (prompting an update) once validation is wired in.
@Test
fun `GIVEN typed address WHEN button clicked THEN onConfirm not called yet`() = runTest {
// Arrange
var confirmed: ValidatedAddress? = null
val model = createModel(testScope = this, onConfirm = { confirmed = it })
model.state.value.onAddressChange("0xABC")
// Act
model.state.value.buttonUM.onClick()
// Assert
assertThat(confirmed).isNull()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class AddressInput {
@Test
fun `GIVEN coins available WHEN valid address typed THEN matching network chosen`() = runTest {
// Arrange
every { multiAccountListSupplier.invoke() } returns
flowOf(listOf(accountListWith(ethereum, bitcoin)))
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
advanceUntilIdle()
// Assert
val state = model.state.value
assertThat(state.availableNetworks).containsExactly(ethereum.network)
assertThat(state.chosenNetworkStateUM)
.isEqualTo(resultOf(ethereum.network))
}
@Test
fun `GIVEN coins available WHEN address matches no network THEN empty state`() = runTest {
// Arrange
every { multiAccountListSupplier.invoke() } returns
flowOf(listOf(accountListWith(ethereum, bitcoin)))
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange("not-an-address")
advanceUntilIdle()
// Assert
val state = model.state.value
assertThat(state.availableNetworks).isEmpty()
assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
}
@Test
fun `GIVEN no coins available WHEN valid address typed THEN empty state`() = runTest {
// Arrange — supplier emits no accounts.
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
advanceUntilIdle()
// Assert
val state = model.state.value
assertThat(state.availableNetworks).isEmpty()
assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
}
// Covers the "not initialized yet" case: the address is typed before coins load, and the
// chosen network must resolve reactively once the supplier emits them.
@Test
fun `GIVEN address typed before coins load WHEN coins emitted THEN network resolved reactively`() = runTest {
// Arrange
val accountsFlow = MutableStateFlow<List<AccountList>>(emptyList())
every { multiAccountListSupplier.invoke() } returns accountsFlow
val model = createModel(testScope = this)
advanceUntilIdle()
// Act — type while coins are still empty
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
advanceUntilIdle()
// Assert intermediate: nothing to match yet
assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty)
// Act — coins arrive later
accountsFlow.value = listOf(accountListWith(ethereum, bitcoin))
advanceUntilIdle()
// Assert
assertThat(model.state.value.chosenNetworkStateUM)
.isEqualTo(resultOf(ethereum.network))
}
}
private fun resultOf(vararg networks: Network) = AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = networks
.map { network ->
AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM(
networkName = network.name,
iconResId = network.iconResId,
)
}
.toImmutableList(),
)
private fun accountListWith(vararg currencies: CryptoCurrency): AccountList {
val walletId = MockAccounts.userWalletId
val accounts = listOf(
Account.CryptoPortfolio.createMainAccount(
userWalletId = walletId,
cryptoCurrencies = currencies.toList(),
),
)
return AccountList(
userWalletId = walletId,
accounts = accounts,
totalAccounts = accounts.size,
totalArchivedAccounts = 0,
).getOrNull()!!
}
private fun createModel(
testScope: TestScope,
onConfirm: (ValidatedAddress) -> Unit = {},
params: AddAddressComponent.Params = AddAddressComponent.Params(
onBackClick = {},
onConfirm = onConfirm,
),
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
): AddAddressModel {
return AddAddressModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
multiAccountListSupplier = multiAccountListSupplier,
clipboardManager = clipboardManager,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private companion object {
// EIP-55 checksummed address from the spec — guaranteed to pass Ethereum validation.
const val VALID_ETH_ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
}
}

View file

@ -0,0 +1,141 @@
package com.tangem.features.addressbook.editcontact.model
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
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.extensions.resourceReference
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class EditContactModelTest {
@Test
fun `WHEN model created THEN initial state is correct`() = runTest {
val expectedColors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val expectedSelectedColor = expectedColors.first()
val model = createModel(testScope = this)
val state = model.state.value
val expected = EditContactUM(
title = resourceReference(R.string.address_book_new_contact),
name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = expectedSelectedColor,
),
colors = EditContactUM.Colors(
selected = expectedSelectedColor,
list = expectedColors,
onColorSelect = state.colors.onColorSelect,
),
addresses = persistentListOf(),
onNameChange = state.onNameChange,
onCloseClick = state.onCloseClick,
onAddAddressClick = state.onAddAddressClick,
)
assertThat(state).isEqualTo(expected)
}
@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 = {},
)
// Act
val model = createModel(testScope = this, params = params)
val state = model.state.value
// Assert
assertThat(state.title).isEqualTo(resourceReference(R.string.address_book_contact))
}
@Test
fun `GIVEN initial state WHEN onNameChange THEN name updated`() = runTest {
val model = createModel(testScope = this)
val newName = "Satoshi"
model.state.value.onNameChange(newName)
assertThat(model.state.value.name).isEqualTo(newName)
}
@Test
fun `GIVEN initial state WHEN onColorSelect THEN selected color and portfolio icon updated`() = runTest {
val model = createModel(testScope = this)
val newColor = CryptoPortfolioIcon.Color.entries.last()
model.state.value.colors.onColorSelect(newColor)
val state = model.state.value
assertThat(state.colors.selected).isEqualTo(newColor)
assertThat(state.portfolioIcon.color).isEqualTo(newColor)
}
@Test
fun `GIVEN add address requested WHEN result delivered THEN address appended to state`() = runTest {
// Arrange
var capturedSink: ((ValidatedAddress) -> Unit)? = null
val params = EditContactComponent.Params(
contactId = null,
onBackClick = {},
onAddAddressClick = { onResult -> capturedSink = onResult },
)
val model = createModel(testScope = this, params = params)
val validatedAddress = ValidatedAddress(address = "0xABC", network = mockk())
// Act
model.state.value.onAddAddressClick()
capturedSink?.invoke(validatedAddress)
// Assert
assertThat(model.state.value.addresses).containsExactly(validatedAddress)
}
private fun createModel(
testScope: TestScope,
params: EditContactComponent.Params = EditContactComponent.Params(
contactId = null,
onBackClick = {},
onAddAddressClick = {},
),
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
): EditContactModel {
return EditContactModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.approval.api
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
/**
* Entry component that wraps the two approval-flow variants:
*
* - [Mode.FullApproval] original [GiveApprovalComponent] which renders the approval-type
* selector together with the fee selector and submits the approval transaction.
* - [Mode.SelectOnly] [SelectApprovalTypeComponent] which only collects the approval-type
* choice and returns it to the caller via its own [SelectApprovalTypeComponent.Callback].
*
* Callers depend only on this single factory and pass the appropriate [Mode]; the entry
* component internally creates the corresponding child and delegates the bottom sheet
* rendering and dismissal to it.
*/
interface GiveApprovalEntryComponent : ComposableBottomSheetComponent {
data class Params(
val mode: Mode,
)
sealed interface Mode {
/** Full flow: approval-type selector + fee selector + transaction submission. */
data class FullApproval(
val params: GiveApprovalComponent.Params,
) : Mode
/** Selection-only flow: returns the chosen approval type without sending anything. */
data class SelectOnly(
val params: SelectApprovalTypeComponent.Params,
) : Mode
}
interface Factory {
fun create(context: AppComponentContext, params: Params): GiveApprovalEntryComponent
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.approval.api
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
/**
* Selection-only variant of [GiveApprovalComponent].
*
* Shows the same approval-type selector UI (LIMITED vs UNLIMITED) but does NOT submit the
* approval transaction. Instead, the chosen [ApproveType] is returned to the caller via
* [Callback.onApproveTypeSelected] when the user confirms. The caller is responsible for any
* downstream action (e.g. building the transaction, sending it, navigation).
*
* Intended for flows where the approval-type choice has to be collected separately from the
* actual fee selection / transaction submission step.
*/
interface SelectApprovalTypeComponent : ComposableBottomSheetComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val amountFooter: TextReference,
val initialApproveType: ApproveType = ApproveType.LIMITED,
val spenderAddress: String,
val callback: Callback,
)
interface Callback {
fun onApproveTypeSelected(spenderAddress: String, approveType: ApproveType)
fun onCancelClick()
}
interface Factory {
fun create(context: AppComponentContext, params: Params): SelectApprovalTypeComponent
}
}

View file

@ -9,16 +9,11 @@ plugins {
android {
namespace = "com.tangem.features.approval.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Feature */
implementation(projects.features.approval.api)
implementation(projects.features.sendV2.api)
implementation(projects.features.send.api)
/** Core */
implementation(projects.core.configToggles)
@ -64,7 +59,6 @@ dependencies {
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
// endregion

View file

@ -17,10 +17,10 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.impl.model.GiveApprovalModel
import com.tangem.features.approval.impl.ui.GiveApprovalContent
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import com.tangem.features.send.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.params.FeeSelectorParams
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

View file

@ -0,0 +1,58 @@
package com.tangem.features.approval.impl
import androidx.compose.runtime.Composable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Default implementation of [GiveApprovalEntryComponent].
*
* Picks the concrete child component (full [GiveApprovalComponent] or selection-only
* [SelectApprovalTypeComponent]) at construction time based on
* [GiveApprovalEntryComponent.Params.mode] and delegates [BottomSheet] and [dismiss] to it.
*
* Callers only need to depend on [GiveApprovalEntryComponent.Factory] regardless of the
* underlying mode.
*/
internal class DefaultGiveApprovalEntryComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: GiveApprovalEntryComponent.Params,
giveApprovalComponentFactory: GiveApprovalComponent.Factory,
selectApprovalTypeComponentFactory: SelectApprovalTypeComponent.Factory,
) : GiveApprovalEntryComponent, AppComponentContext by appComponentContext {
private val delegate: ComposableBottomSheetComponent = when (val mode = params.mode) {
is GiveApprovalEntryComponent.Mode.FullApproval -> giveApprovalComponentFactory.create(
context = child("giveApprovalEntry_full"),
params = mode.params,
)
is GiveApprovalEntryComponent.Mode.SelectOnly -> selectApprovalTypeComponentFactory.create(
context = child("giveApprovalEntry_select"),
params = mode.params,
)
}
override fun dismiss() {
delegate.dismiss()
}
@Composable
override fun BottomSheet() {
delegate.BottomSheet()
}
@AssistedFactory
interface Factory : GiveApprovalEntryComponent.Factory {
override fun create(
context: AppComponentContext,
params: GiveApprovalEntryComponent.Params,
): DefaultGiveApprovalEntryComponent
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.features.approval.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.features.approval.impl.model.SelectApprovalTypeModel
import com.tangem.features.approval.impl.ui.SelectApprovalTypeContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Default implementation of [SelectApprovalTypeComponent].
*
* Renders the same selection UI as the full [com.tangem.features.approval.api.GiveApprovalComponent]
* but without the fee selector block and without dispatching the on-chain approval transaction.
* Dismissing the bottom sheet (close button or external dismiss) is treated as a cancel.
*/
internal class DefaultSelectApprovalTypeComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SelectApprovalTypeComponent.Params,
) : SelectApprovalTypeComponent, AppComponentContext by appComponentContext {
private val model: SelectApprovalTypeModel = getOrCreateModel(params = params)
private val currency: String = params.cryptoCurrencyStatus.currency.symbol
override fun dismiss() {
params.callback.onCancelClick()
}
@Composable
override fun BottomSheet() {
val uiState by model.uiState.collectAsStateWithLifecycle()
val config = remember {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
}
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
titleText = resourceReference(R.string.give_permission_title),
titleAction = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_close_new_20,
onClicked = model::onCancelClick,
),
) {
SelectApprovalTypeContent(
currency = currency,
uiState = uiState,
onChangeApproveType = model::onChangeApproveType,
onConfirmClick = model::onConfirmClick,
)
}
}
@AssistedFactory
interface Factory : SelectApprovalTypeComponent.Factory {
override fun create(
context: AppComponentContext,
params: SelectApprovalTypeComponent.Params,
): DefaultSelectApprovalTypeComponent
}
}

View file

@ -3,10 +3,15 @@ package com.tangem.features.approval.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalEntryComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalFeatureToggles
import com.tangem.features.approval.impl.DefaultSelectApprovalTypeComponent
import com.tangem.features.approval.impl.model.GiveApprovalModel
import com.tangem.features.approval.impl.model.SelectApprovalTypeModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -26,6 +31,18 @@ internal interface GiveApprovalFeatureModule {
@Binds
@Singleton
fun bindComponentFactory(factory: DefaultGiveApprovalComponent.Factory): GiveApprovalComponent.Factory
@Binds
@Singleton
fun bindSelectApprovalTypeComponentFactory(
factory: DefaultSelectApprovalTypeComponent.Factory,
): SelectApprovalTypeComponent.Factory
@Binds
@Singleton
fun bindGiveApprovalEntryComponentFactory(
factory: DefaultGiveApprovalEntryComponent.Factory,
): GiveApprovalEntryComponent.Factory
}
@Module
@ -36,4 +53,9 @@ internal interface GiveApprovalModelModule {
@IntoMap
@ClassKey(GiveApprovalModel::class)
fun bindModel(model: GiveApprovalModel): Model
@Binds
@IntoMap
@ClassKey(SelectApprovalTypeModel::class)
fun bindSelectApprovalTypeModel(model: SelectApprovalTypeModel): Model
}

View file

@ -33,10 +33,10 @@ import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeItem
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.api.entity.FeeItem
import com.tangem.features.send.api.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow

View file

@ -0,0 +1,52 @@
package com.tangem.features.approval.impl.model
import androidx.compose.runtime.Stable
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
/**
* Model for [SelectApprovalTypeComponent].
*
* Keeps the currently selected [ApproveType] and exposes intents to change it, open the
* learn-more URL, confirm the selection, and cancel. Unlike [GiveApprovalModel] this model
* does NOT load fees or submit any transaction confirmation simply notifies the caller
* via the params callback with the selected [ApproveType].
*/
@Stable
@ModelScoped
internal class SelectApprovalTypeModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
) : Model() {
private val params: SelectApprovalTypeComponent.Params = paramsContainer.require()
val uiState: StateFlow<SelectApprovalTypeUM>
field = MutableStateFlow(
SelectApprovalTypeUM(
approveType = params.initialApproveType,
subtitle = params.amountFooter,
),
)
fun onChangeApproveType(approveType: ApproveType) {
if (uiState.value.approveType == approveType) return
uiState.update { it.copy(approveType = approveType) }
}
fun onConfirmClick() {
params.callback.onApproveTypeSelected(params.spenderAddress, uiState.value.approveType)
}
fun onCancelClick() {
params.callback.onCancelClick()
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.approval.impl.model
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
internal data class SelectApprovalTypeUM(
val subtitle: TextReference,
val approveType: ApproveType,
val approveItems: ImmutableList<ApproveType> = ApproveType.entries.toImmutableList(),
)

View file

@ -0,0 +1,183 @@
package com.tangem.features.approval.impl.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import androidx.compose.material3.Text as M3Text
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import kotlinx.collections.immutable.ImmutableList
/**
* Reusable row that shows "Amount for {currency}" on the left and the currently selected
* [ApproveType] on the right, with a dropdown to switch between the available types.
*
* Used by both [GiveApprovalContent] (full approval flow) and [SelectApprovalTypeContent]
* (selection-only flow).
*/
@Composable
internal fun ApprovalTypeSelectorRow(
currency: String,
approveType: ApproveType,
approveItems: ImmutableList<ApproveType>,
onChangeApproveType: (ApproveType) -> Unit,
modifier: Modifier = Modifier,
) {
var isExpandSelector by remember { mutableStateOf(false) }
var amountSize by remember { mutableStateOf(IntSize.Zero) }
Box(
modifier = modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = { isExpandSelector = true },
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { amountSize = it }
.padding(vertical = 12.dp, horizontal = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
M3Text(
text = stringResourceSafe(id = R.string.give_permission_rows_amount, currency),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
)
SpacerWMax()
M3Text(
text = approveType.text.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body1,
maxLines = 1,
)
Icon(
painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.ic_chevron_24)),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
)
}
ApprovalTypeDropdown(
isExpanded = isExpandSelector,
onDismiss = { isExpandSelector = false },
onItemClick = { type ->
isExpandSelector = false
onChangeApproveType(type)
},
items = approveItems,
selectedType = approveType,
amountSize = amountSize,
)
}
}
@Suppress("LongParameterList")
@Composable
private fun ApprovalTypeDropdown(
isExpanded: Boolean,
onDismiss: () -> Unit,
onItemClick: (ApproveType) -> Unit,
items: ImmutableList<ApproveType>,
selectedType: ApproveType,
amountSize: IntSize,
) {
var dropDownWidth by remember { mutableStateOf(IntSize.Zero) }
val offsetY = amountSize.height.times(-1)
val offsetX = amountSize.width - dropDownWidth.width
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(surface = TangemTheme.colors.background.action),
shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(TangemTheme.dimens.radius16)),
) {
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss,
properties = PopupProperties(clippingEnabled = false),
offset = with(LocalDensity.current) {
DpOffset(x = offsetX.toDp(), y = offsetY.toDp())
},
modifier = Modifier
.wrapContentSize()
.background(TangemTheme.colors.background.action)
.onSizeChanged { dropDownWidth = it },
) {
items.forEach { item ->
val color = if (item == selectedType) TangemTheme.colors.icon.accent else Color.Transparent
DropdownMenuItem(
modifier = Modifier.fillMaxWidth(),
text = {
Row {
M3Text(
text = when (item) {
ApproveType.LIMITED -> stringResourceSafe(
id = R.string.give_permission_current_transaction,
)
ApproveType.UNLIMITED -> stringResourceSafe(
id = R.string.give_permission_unlimited,
)
},
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body1,
maxLines = 1,
)
SpacerWMax()
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(id = R.drawable.ic_check_24),
),
tint = color,
contentDescription = null,
modifier = Modifier.padding(start = TangemTheme.dimens.size20),
)
}
},
onClick = {
onItemClick.invoke(item)
},
)
}
}
}
}

View file

@ -34,7 +34,7 @@ import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.approval.impl.model.GiveApprovalUM
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.FeeSelectorBlockComponent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -2,8 +2,8 @@ package com.tangem.features.approval.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.api.FeeSelectorBlockComponent
import com.tangem.features.send.api.entity.FeeSelectorUM
internal class PreviewFeeSelectorBlockComponent : FeeSelectorBlockComponent {
override fun updateState(feeSelectorUM: FeeSelectorUM) {

View file

@ -0,0 +1,157 @@
package com.tangem.features.approval.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH18
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.approval.impl.model.SelectApprovalTypeUM
import kotlinx.collections.immutable.persistentListOf
/**
* UI for the selection-only approval variant. Reuses [ApprovalTypeSelectorRow] for the
* approval-type picker. The primary button calls [onConfirmClick] which is wired to a
* callback that returns the chosen [ApproveType]
* to the caller (instead of submitting an on-chain transaction).
*/
@Composable
@Suppress("LongParameterList")
internal fun SelectApprovalTypeContent(
currency: String,
uiState: SelectApprovalTypeUM,
onChangeApproveType: (ApproveType) -> Unit,
onConfirmClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = uiState.subtitle.resolveAnnotatedReference(),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(
top = 2.dp,
start = 16.dp,
end = 16.dp,
),
)
SpacerH18()
ApprovalTypeSelectorRow(
currency = currency,
approveType = uiState.approveType,
approveItems = uiState.approveItems,
onChangeApproveType = onChangeApproveType,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
)
SpacerH(height = TangemTheme.dimens.spacing20)
PrimaryButton(
text = stringResourceSafe(id = R.string.common_continue),
onClick = onConfirmClick,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
)
SpacerH16()
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SelectApprovalTypeContentPreview(
@PreviewParameter(SelectApprovalTypeContentPreviewProvider::class) params: SelectApprovalTypePreviewParams,
) {
TangemThemePreview {
SelectApprovalTypeContent(
currency = params.currency,
uiState = params.uiState,
onChangeApproveType = {},
onConfirmClick = {},
)
}
}
private data class SelectApprovalTypePreviewParams(
val currency: String,
val uiState: SelectApprovalTypeUM,
)
private class SelectApprovalTypeContentPreviewProvider : PreviewParameterProvider<SelectApprovalTypePreviewParams> {
override val values: Sequence<SelectApprovalTypePreviewParams>
get() = sequenceOf(
SelectApprovalTypePreviewParams(
currency = "USDT",
uiState = SelectApprovalTypeUM(
subtitle = combinedReference(
resourceReference(
id = R.string.give_permission_swap_subtitle_v2,
// Arg is only used in iOS
formatArgs = wrappedList(""),
),
styledResourceReference(
id = R.string.common_learn_more,
spanStyleReference = {
TangemTheme.typography.caption2
.copy(color = TangemTheme.colors.text.accent)
.toSpanStyle()
},
onClick = { },
),
),
approveType = ApproveType.LIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
),
),
SelectApprovalTypePreviewParams(
currency = "USDC",
uiState = SelectApprovalTypeUM(
subtitle = combinedReference(
resourceReference(
id = com.tangem.common.ui.R.string.give_permission_swap_subtitle_v2,
// Arg is only used in iOS
formatArgs = wrappedList(""),
),
styledResourceReference(
id = com.tangem.common.ui.R.string.common_learn_more,
spanStyleReference = {
TangemTheme.typography.caption2
.copy(color = TangemTheme.colors.text.accent)
.toSpanStyle()
},
onClick = {},
),
),
approveType = ApproveType.UNLIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
),
),
)
}
// endregion

View file

@ -26,7 +26,7 @@ import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify

View file

@ -1,14 +1,29 @@
package com.tangem.features.commonfeatures.api.addfunds
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
interface AddFundsComponent : ComposableContentComponent {
interface AddFundsComponent : ComposableBottomSheetComponent {
data class Params(
val userWalletId: UserWalletId,
val launchMode: LaunchMode,
val onDismiss: () -> Unit,
)
sealed interface LaunchMode {
data class ChooseToken(val userWalletId: UserWalletId) : LaunchMode
data class TokenActionsOnly(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
) : LaunchMode
data class FilteredByRawId(
val rawCurrencyId: CryptoCurrency.RawID,
) : LaunchMode
}
interface Factory : ComponentFactory<Params, AddFundsComponent>
}

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.SharedFlow
@ -94,7 +95,14 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal {
val wallet: UserWallet,
val account: AccountStatus.CryptoPortfolio,
val addedCurrency: CryptoCurrencyStatus,
val meta: FinishMeta = FinishMeta.None,
)
sealed interface FinishMeta {
data object None : FinishMeta
data object OnQuickAction : FinishMeta
data class OnBottomAction(val action: BottomAction) : FinishMeta
}
}
/**

View file

@ -11,6 +11,7 @@ import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
interface ChooseTokenBridge : ChooseTokenBridgeInternal {
@ -29,6 +30,12 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal {
val title: TextReference,
val isShowMarketBlock: Boolean,
val isShowPaymentAccount: Boolean,
val isAppBarShown: Boolean = true,
/**
* When `true`, single-currency wallets (and single-currency-with-token wallets like NODL)
* are shown as selectable tabs. Swap flows keep this `false` only multi-currency wallets apply there.
*/
val isShowSingleCurrencyWallets: Boolean = false,
) {
companion object {
val SwapFrom = Settings(
@ -45,6 +52,8 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal {
title = resourceReference(R.string.swapping_to_title),
isShowMarketBlock = true,
isShowPaymentAccount = false,
isAppBarShown = false,
isShowSingleCurrencyWallets = true,
)
}
}
@ -67,6 +76,9 @@ interface ChooseTokenBridgeInternal {
val searchQueryState: StateFlow<SearchQuery>
val fullPortfolioBlock: StateFlow<ChooseTokenPortfolioFullBlockUM?>
/** Currently selected wallet tab. Used to constrain feature blocks (e.g. market block) to the wallet's type. */
val selectedWalletFlow: SharedFlow<UserWallet>
fun onSearchQuery(query: SearchQuery)
fun onSearchQuery(query: String) = onSearchQuery(SearchQuery(query))
@ -90,6 +102,11 @@ data class ChooseTokenResult(
val analyticsPayload: Set<ChooseTokenAnalyticsPayload> = emptySet(),
) {
val walletId get() = wallet.walletId
val wasJustAdded: Boolean
get() = analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.IsMarketTokenSelected>()
.any { it.value }
}
sealed interface ChooseTokenAnalyticsPayload {

View file

@ -0,0 +1,3 @@
package com.tangem.features.commonfeatures.api.tokenactions
enum class BottomAction { GoToToken, None }

View file

@ -10,11 +10,6 @@ plugins {
android {
namespace = "com.tangem.features.commonfeatures.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.commonFeatures.api)
@ -86,7 +81,6 @@ dependencies {
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)

View file

@ -1,93 +1,232 @@
package com.tangem.features.commonfeatures.impl.addfunds
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addfunds.model.AddFundsModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addfunds.model.uiSpec
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.core.ui.R as CoreR
@Suppress("LongParameterList")
internal class DefaultAddFundsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: AddFundsComponent.Params,
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
tokenActionsComponentFactory: TokenActionsComponent.Factory,
userPortfolioComponentFactory: UserPortfolioComponent.Factory,
walletFeatureToggles: WalletFeatureToggles,
) : AppComponentContext by appComponentContext, AddFundsComponent {
private val model: AddFundsModel = getOrCreateModel(params)
private val chooseTokenComponent: ChooseTokenComponent = chooseTokenComponentFactory.create(
context = child(key = "addFundsChooseToken"),
params = ChooseTokenComponent.Params(bridge = model.chooseTokenBridge),
)
private val isCompactTokenActions: Boolean = params.launchMode is AddFundsComponent.LaunchMode.TokenActionsOnly
private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create(
context = child(key = "addFundsTokenActions"),
params = TokenActionsComponent.Params(
data = model.tokenActionsData,
callbacks = model,
bottomAction = TokenActionsComponent.BottomAction.GoToToken,
isRedesignForced = true,
),
)
private val isAddFundsStage1Enabled: Boolean = walletFeatureToggles.isAddFundsStage1Enabled
private val tokenActionsComponent: TokenActionsComponent by lazy {
tokenActionsComponentFactory.create(
context = child(key = "addFundsTokenActions"),
params = TokenActionsComponent.Params(
data = model.tokenActionsData,
callbacks = model,
bottomAction = model.currentBottomAction,
isRedesignForced = true,
isCompact = isCompactTokenActions,
),
)
}
private val chooseTokenComponent: ChooseTokenComponent? by lazy {
(params.launchMode as? AddFundsComponent.LaunchMode.ChooseToken)?.let {
chooseTokenComponentFactory.create(
context = child(key = "addFundsChooseToken"),
params = ChooseTokenComponent.Params(
bridge = model.chooseTokenBridge,
),
)
}
}
private val userPortfolioComponent: UserPortfolioComponent by lazy {
userPortfolioComponentFactory.create(
context = child(key = "addFundsUserPortfolio"),
params = UserPortfolioComponent.Params(
uiState = model.userPortfolioStateController.uiState,
callbacks = object : UserPortfolioComponent.Callbacks {
override fun onContinueFromUserPortfolio() = Unit
},
),
)
}
override fun dismiss() = model.onDismiss()
@Composable
override fun Content(modifier: Modifier) {
chooseTokenComponent.Content(modifier)
val isTokenActionsShown by model.isTokenActionsShown.collectAsStateWithLifecycle()
if (isTokenActionsShown) {
// force use redesign theme here according to the task requirements, will be reworked in the next release
TangemThemeRedesign {
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = model::onTokenActionsDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors2.surface.level2,
scrollableContent = true,
title = {
TangemModalBottomSheetTitle(
modifier = Modifier.fillMaxWidth(),
title = resourceReference(R.string.common_get_token),
endIconRes = R.drawable.ic_close_24,
onEndClick = model::onTokenActionsDismiss,
)
},
content = { _ ->
Column(
modifier = Modifier.padding(
start = TangemTheme.dimens2.x4,
top = TangemTheme.dimens2.x2,
end = TangemTheme.dimens2.x4,
bottom = TangemTheme.dimens2.x4,
),
) {
tokenActionsComponent.Content(Modifier)
}
},
)
}
override fun BottomSheet() {
val route by model.uiRoute.collectAsStateWithLifecycle()
val canGoBack by model.canGoBack.collectAsStateWithLifecycle()
LaunchedEffect(route) {
if (route != AddFundsModel.UiRoute.UserPortfolio) return@LaunchedEffect
val mode = params.launchMode as? AddFundsComponent.LaunchMode.FilteredByRawId ?: return@LaunchedEffect
model.userPortfolioStateController.updateAndWaitNotNullState(
allAvailableData = model.buildAvailableToAddDataForChooser(),
rawCurrencyId = mode.rawCurrencyId,
)
}
WithOptionalRedesignTheme(isEnabled = isAddFundsStage1Enabled) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
onBack = if (canGoBack) model::onBack else ::dismiss,
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = when (params.launchMode) {
is AddFundsComponent.LaunchMode.TokenActionsOnly -> TangemBottomSheetType.Modal
is AddFundsComponent.LaunchMode.ChooseToken -> TangemBottomSheetType.Default
is AddFundsComponent.LaunchMode.FilteredByRawId ->
if (route is AddFundsModel.UiRoute.TokenActions) {
TangemBottomSheetType.Default
} else {
TangemBottomSheetType.Modal
}
},
containerColor = TangemTheme.colors2.surface.level2,
title = {
AddFundsBottomSheetTitle(
route = route,
canGoBack = canGoBack,
onBackClick = model::onBack,
onCloseClick = ::dismiss,
)
},
content = {
val animatedContentModifier =
if (params.launchMode is AddFundsComponent.LaunchMode.ChooseToken) {
Modifier.fillMaxSize()
} else {
Modifier
}
AnimatedContent(
targetState = route,
modifier = animatedContentModifier,
label = "AddFundsContentAnimation",
) { animatedRoute ->
AddFundsRouteContent(
route = animatedRoute,
shouldFillHeight = !isCompactTokenActions && animatedRoute.uiSpec().shouldFillHeight,
)
}
},
)
}
}
@Composable
private fun AddFundsRouteContent(route: AddFundsModel.UiRoute, shouldFillHeight: Boolean) {
val spec = route.uiSpec()
val horizontalPadding = if (spec.shouldApplyHorizontalPadding) {
Modifier.padding(horizontal = TangemTheme.dimens2.x4)
} else {
Modifier
}
val sizeModifier = if (shouldFillHeight) Modifier.fillMaxSize() else Modifier.fillMaxWidth()
RenderRoute(route, horizontalPadding.then(sizeModifier))
}
@Composable
private fun RenderRoute(route: AddFundsModel.UiRoute, modifier: Modifier = Modifier) {
when (route) {
AddFundsModel.UiRoute.Loading -> Unit
AddFundsModel.UiRoute.ChooseToken -> chooseTokenComponent?.Content(modifier)
AddFundsModel.UiRoute.UserPortfolio -> CompositionLocalProvider(
LocalTangemBottomSheetContentBottomInset provides TangemTheme.dimens2.x4,
) {
userPortfolioComponent.Content(modifier)
}
AddFundsModel.UiRoute.TokenActions -> tokenActionsComponent.Content(modifier)
}
}
@Composable
private fun AddFundsBottomSheetTitle(
route: AddFundsModel.UiRoute,
canGoBack: Boolean,
onBackClick: () -> Unit,
onCloseClick: () -> Unit,
) {
TangemTopBar(
title = route.uiSpec().title,
type = TangemTopBarType.BottomSheet,
startContent = if (canGoBack) {
{ CircleIconButton(iconRes = CoreR.drawable.ic_arrow_back_28, onClick = onBackClick) }
} else {
null
},
endContent = {
CircleIconButton(iconRes = R.drawable.ic_close_24, onClick = onCloseClick)
},
)
}
@Composable
private fun WithOptionalRedesignTheme(isEnabled: Boolean, content: @Composable () -> Unit) {
if (isEnabled) {
TangemThemeRedesign(content = content)
} else {
content()
}
}
@Composable
private fun CircleIconButton(iconRes: Int, onClick: () -> Unit) {
Icon(
imageVector = ImageVector.vectorResource(id = iconRes),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.background(
color = TangemTheme.colors2.button.backgroundSecondary,
shape = CircleShape,
)
.clickableSingle(onClick = onClick)
.padding(TangemTheme.dimens2.x2),
)
}
@AssistedFactory

View file

@ -19,8 +19,6 @@ internal sealed class AddFundsAnalyticsEvent(
class ButtonReceive : AddFundsAnalyticsEvent(event = "Button - Receive")
class ButtonGoToToken : AddFundsAnalyticsEvent(event = "Button - Go to Token")
companion object {
private const val CATEGORY = "Add Funds"
const val SOURCE_MAIN_SCREEN = "Main Screen"

View file

@ -1,5 +1,6 @@
package com.tangem.features.commonfeatures.impl.addfunds.model
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.markets.action.CryptoCurrencyData
@ -8,14 +9,25 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import com.tangem.features.commonfeatures.impl.addfunds.analytics.AddFundsAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.userportfolio.state.UserPortfolioStateController
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
@ -23,98 +35,257 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class AddFundsModel @Inject constructor(
paramsContainer: ParamsContainer,
chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
userPortfolioStateControllerFactory: UserPortfolioStateController.Factory,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
private val appRouter: AppRouter,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
override val dispatchers: CoroutineDispatcherProvider,
) : Model(), TokenActionsComponent.Callbacks {
private val params = paramsContainer.require<AddFundsComponent.Params>()
val launchMode: AddFundsComponent.LaunchMode = params.launchMode
val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create(
modelScope = modelScope,
settings = ChooseTokenBridge.Settings.AddFunds,
analyticsPayload = setOf(
ChooseTokenAnalyticsPayload.ScreensSources(SCREEN_SOURCE),
),
)
private val routeStack = MutableStateFlow(listOf<UiRoute>(UiRoute.Loading))
private val selectedToken = MutableStateFlow<ChooseTokenResult?>(null)
val uiRoute: StateFlow<UiRoute> = routeStack
.map { it.last() }
.distinctUntilChanged()
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = UiRoute.Loading)
val isTokenActionsShown: StateFlow<Boolean> = selectedToken
.map { it != null }
val canGoBack: StateFlow<Boolean> = routeStack
.map { it.size > 1 }
.distinctUntilChanged()
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = false)
private val tokenActionsTrigger = MutableStateFlow<TokenActionsRequest?>(null)
private val filteredEntries = MutableStateFlow<List<FilteredEntry>>(emptyList())
val currentBottomAction: MutableStateFlow<BottomAction> =
MutableStateFlow(BottomAction.None)
@OptIn(ExperimentalCoroutinesApi::class)
val tokenActionsData: Flow<CryptoCurrencyData> = selectedToken
val tokenActionsData: Flow<CryptoCurrencyData> = tokenActionsTrigger
.filterNotNull()
.flatMapLatest { result ->
val cryptoPortfolio = result.account as? AccountStatus.CryptoPortfolio
?: return@flatMapLatest emptyFlow()
getCryptoCurrencyActionsUseCase(
accountId = cryptoPortfolio.account.accountId,
currency = result.currency.currency,
).map { actionsState ->
.flatMapLatest { request ->
combine(
getCryptoCurrencyActionsUseCase(
accountId = request.account.account.accountId,
currency = request.status.currency,
),
isAccountsModeEnabledUseCase(),
) { actionsState, isAccountMode ->
CryptoCurrencyData(
userWallet = result.wallet,
userWallet = request.userWallet,
status = actionsState.cryptoCurrencyStatus,
actions = actionsState.states,
isAccountMode = false,
account = cryptoPortfolio,
isAccountMode = isAccountMode,
account = request.account,
)
}
}
val chooseTokenBridge: ChooseTokenBridge by lazy {
chooseTokenBridgeFactory.create(
modelScope = modelScope,
settings = ChooseTokenBridge.Settings.AddFunds,
analyticsPayload = setOf(ChooseTokenAnalyticsPayload.ScreensSources(SCREEN_SOURCE)),
)
}
val userPortfolioStateController: UserPortfolioStateController = userPortfolioStateControllerFactory.create(
modelScope = modelScope,
onTokenSelected = { result ->
openTokenActions(
request = TokenActionsRequest(
userWallet = result.wallet,
account = result.account,
status = result.addedCurrency,
),
bottomAction = BottomAction.None,
)
},
)
init {
chooseTokenBridge.selectWalletTab(params.userWalletId)
analyticsEventHandler.send(
AddFundsAnalyticsEvent.MethodScreenOpened(source = AddFundsAnalyticsEvent.SOURCE_MAIN_SCREEN),
)
observeBridge()
when (val mode = launchMode) {
is AddFundsComponent.LaunchMode.ChooseToken -> initChooseToken(mode)
is AddFundsComponent.LaunchMode.TokenActionsOnly -> initTokenActionsOnly(mode)
is AddFundsComponent.LaunchMode.FilteredByRawId -> initFilteredByRawId(mode)
}
}
override fun onBottomActionClick() {
val result = selectedToken.value ?: return
selectedToken.value = null
analyticsEventHandler.send(AddFundsAnalyticsEvent.ButtonGoToToken())
appRouter.replaceCurrent(
AppRoute.CurrencyDetails(
userWalletId = result.wallet.walletId,
currency = result.currency.currency,
),
)
fun onBack() {
routeStack.update { stack -> if (stack.size > 1) stack.dropLast(1) else stack }
}
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action) {
fun onDismiss() = params.onDismiss()
override fun onBottomActionClick(bottomAction: BottomAction) {
val request = tokenActionsTrigger.value
if (bottomAction == BottomAction.GoToToken && request != null) {
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = request.userWallet.walletId,
currency = request.status.currency,
),
)
}
params.onDismiss()
}
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {
val event = when (action) {
TokenActionsBSContentUM.Action.Buy -> AddFundsAnalyticsEvent.ButtonBuy()
TokenActionsBSContentUM.Action.Exchange -> AddFundsAnalyticsEvent.ButtonSwap()
TokenActionsBSContentUM.Action.Receive -> AddFundsAnalyticsEvent.ButtonReceive()
else -> return
else -> null
}
event?.let { analyticsEventHandler.send(it) }
if (shouldDismiss) {
params.onDismiss()
}
analyticsEventHandler.send(event)
}
fun onTokenActionsDismiss() {
selectedToken.value = null
fun buildAvailableToAddDataForChooser(): AvailableToAddData {
val byWallet = filteredEntries.value.groupBy { it.userWallet.walletId }
return AvailableToAddData(
availableToAddWallets = byWallet.mapValues { (_, entries) ->
AvailableToAddWallet(
userWallet = entries.first().userWallet,
accounts = entries.map { it.account }.distinct(),
availableNetworks = emptySet(),
availableToAddAccounts = emptyMap(),
)
},
)
}
private fun observeBridge() {
private fun initChooseToken(mode: AddFundsComponent.LaunchMode.ChooseToken) {
chooseTokenBridge.selectWalletTab(mode.userWalletId)
analyticsEventHandler.send(
AddFundsAnalyticsEvent.MethodScreenOpened(source = AddFundsAnalyticsEvent.SOURCE_MAIN_SCREEN),
)
replaceRoot(UiRoute.ChooseToken)
modelScope.launch {
chooseTokenBridge.onCurrencyChosen.receiveAsFlow().collect { result ->
selectedToken.value = result
}
chooseTokenBridge.onCurrencyChosen.receiveAsFlow().collect(::openTokenActionsFromBridge)
}
modelScope.launch {
chooseTokenBridge.onClose.receiveAsFlow().collect {
appRouter.pop()
chooseTokenBridge.onClose.receiveAsFlow().collect { params.onDismiss() }
}
}
private fun initTokenActionsOnly(mode: AddFundsComponent.LaunchMode.TokenActionsOnly) {
modelScope.launch {
val wallet = getUserWalletUseCase.invokeFlow(mode.userWalletId)
.mapNotNull { it.getOrNull() }
.first()
val match = multiAccountStatusListSupplier()
.first()
.firstOrNull { it.userWalletId == mode.userWalletId }
?.accountStatuses
?.filterCryptoPortfolio()
?.firstNotNullOfOrNull { accountStatus ->
accountStatus.tokenList.flattenCurrencies()
.firstOrNull { it.currency.id == mode.currency.id }
?.let { accountStatus to it }
}
?: run {
params.onDismiss()
return@launch
}
tokenActionsTrigger.value = TokenActionsRequest(wallet, match.first, match.second)
replaceRoot(UiRoute.TokenActions)
}
}
private fun initFilteredByRawId(mode: AddFundsComponent.LaunchMode.FilteredByRawId) {
modelScope.launch {
val entries = collectFilteredEntries(mode.rawCurrencyId)
when (entries.size) {
0 -> params.onDismiss()
1 -> {
val entry = entries.first()
tokenActionsTrigger.value = TokenActionsRequest(entry.userWallet, entry.account, entry.status)
replaceRoot(UiRoute.TokenActions)
}
else -> {
filteredEntries.value = entries
replaceRoot(UiRoute.UserPortfolio)
}
}
}
}
private suspend fun collectFilteredEntries(rawCurrencyId: CryptoCurrency.RawID): List<FilteredEntry> {
val accountLists = multiAccountStatusListSupplier().first()
return accountLists.flatMap { accountStatusList ->
val wallet = getUserWalletUseCase.invokeFlow(accountStatusList.userWalletId)
.mapNotNull { it.getOrNull() }
.firstOrNull()
?: return@flatMap emptyList()
accountStatusList.accountStatuses.filterCryptoPortfolio().flatMap { accountStatus ->
accountStatus.tokenList.flattenCurrencies()
.filter { status ->
val id = status.currency.id.rawCurrencyId ?: return@filter false
getTokenIdIfL2Network(id.value) == rawCurrencyId.value
}
.map { status -> FilteredEntry(wallet, accountStatus, status) }
}
}
}
private fun openTokenActionsFromBridge(result: ChooseTokenResult) {
val account = result.account as? AccountStatus.CryptoPortfolio ?: return
openTokenActions(
request = TokenActionsRequest(result.wallet, account, result.currency),
bottomAction = if (result.wasJustAdded) {
BottomAction.GoToToken
} else {
BottomAction.None
},
)
}
private fun openTokenActions(request: TokenActionsRequest, bottomAction: BottomAction) {
tokenActionsTrigger.value = request
currentBottomAction.value = bottomAction
pushRoute(UiRoute.TokenActions)
}
private fun replaceRoot(route: UiRoute) {
routeStack.value = listOf(route)
}
private fun pushRoute(route: UiRoute) {
routeStack.update { it + route }
}
sealed interface UiRoute {
data object Loading : UiRoute
data object ChooseToken : UiRoute
data object UserPortfolio : UiRoute
data object TokenActions : UiRoute
}
private data class TokenActionsRequest(
val userWallet: UserWallet,
val account: AccountStatus.CryptoPortfolio,
val status: CryptoCurrencyStatus,
)
private data class FilteredEntry(
val userWallet: UserWallet,
val account: AccountStatus.CryptoPortfolio,
val status: CryptoCurrencyStatus,
)
private companion object {
const val SCREEN_SOURCE = "AddFunds"
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.commonfeatures.impl.addfunds.model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.commonfeatures.impl.R
import com.tangem.core.ui.R as CoreR
internal data class AddFundsRouteUiSpec(
val title: TextReference,
val shouldApplyHorizontalPadding: Boolean,
val shouldFillHeight: Boolean,
)
internal fun AddFundsModel.UiRoute.uiSpec(): AddFundsRouteUiSpec = when (this) {
AddFundsModel.UiRoute.Loading -> AddFundsRouteUiSpec(
title = resourceReference(R.string.common_add_funds),
shouldApplyHorizontalPadding = false,
shouldFillHeight = false,
)
AddFundsModel.UiRoute.ChooseToken -> AddFundsRouteUiSpec(
title = resourceReference(R.string.common_add_funds),
shouldApplyHorizontalPadding = false,
shouldFillHeight = true,
)
AddFundsModel.UiRoute.UserPortfolio -> AddFundsRouteUiSpec(
title = resourceReference(R.string.common_add_funds),
shouldApplyHorizontalPadding = false,
shouldFillHeight = false,
)
AddFundsModel.UiRoute.TokenActions -> AddFundsRouteUiSpec(
title = resourceReference(CoreR.string.common_get_token),
shouldApplyHorizontalPadding = true,
shouldFillHeight = true,
)
}

View file

@ -23,7 +23,7 @@ import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioFooterKind
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.uiSpec
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
import dev.chrisbanes.haze.rememberHazeState
@Composable

View file

@ -6,7 +6,7 @@ import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
@Composable
internal fun AddToPortfolioBottomSheetSwitch(

View file

@ -22,7 +22,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.uiSpec
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
@Composable
internal fun AddToPortfolioBottomSheetV2(
@ -39,6 +39,11 @@ internal fun AddToPortfolioBottomSheetV2(
contentStack.value = stack
}
val type = if (stack.active.configuration is AddToPortfolioRoutes.TokenActions) {
TangemBottomSheetType.Default
} else {
TangemBottomSheetType.Modal
}
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
onBack = onBack,
config = TangemBottomSheetConfig(
@ -46,7 +51,7 @@ internal fun AddToPortfolioBottomSheetV2(
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = TangemBottomSheetType.Modal,
type = type,
containerColor = TangemTheme.colors2.surface.level2,
title = {
AddToPortfolioBottomSheetTitle(
@ -86,7 +91,9 @@ private fun AddToPortfolioRouteContent(animatedStack: ChildStack<AddToPortfolioR
Spacer(modifier = Modifier.height(scrollBottomReserve))
}
} else {
animatedStack.active.instance.Content(modifier = baseModifier)
val isFullScreenRoute = animatedStack.active.configuration is AddToPortfolioRoutes.TokenActions
val sizeModifier = if (isFullScreenRoute) Modifier.fillMaxSize() else Modifier
animatedStack.active.instance.Content(modifier = baseModifier.then(sizeModifier))
}
}

View file

@ -15,11 +15,14 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.flowOf
@Suppress("LongParameterList")
internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
@ -60,6 +63,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
params = TokenActionsComponent.Params(
callbacks = model,
data = model.tokenActionsData,
bottomAction = flowOf(BottomAction.GoToToken),
),
)
}

View file

@ -92,13 +92,5 @@ internal class PortfolioAnalyticsEvent(
if (source != null) put("Source", source)
},
)
fun getTokenLater() = PortfolioAnalyticsEvent(
event = "Popup Get token - Button Later",
category = category,
params = buildMap {
if (source != null) put("Source", source)
},
)
}
}

View file

@ -4,8 +4,8 @@ import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioCompo
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.DefaultAddToPortfolioManager
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.DefaultUserPortfolioComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
import com.tangem.features.commonfeatures.impl.userportfolio.DefaultUserPortfolioComponent
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn

View file

@ -5,8 +5,8 @@ import com.tangem.core.decompose.model.Model
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel
import com.tangem.features.commonfeatures.impl.tokenactions.model.TokenActionsModel
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn

View file

@ -29,18 +29,20 @@ import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.*
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state.UserPortfolioStateController
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import com.tangem.features.commonfeatures.impl.userportfolio.state.UserPortfolioStateController
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -107,10 +109,6 @@ internal class AddToPortfolioModel @Inject constructor(
startRedesignAddToPortfolioFlow()
}
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action) {
analyticsEventHandler.send(eventBuilder.getTokenActionClick(actionUM = action))
}
private fun <T> replayMutableSharedFlow() = MutableSharedFlow<T>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
@ -155,7 +153,7 @@ internal class AddToPortfolioModel @Inject constructor(
}
}
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun startRedesignAddToPortfolioFlow() {
channelFlow<Unit> {
fun finishSuccessFlow(result: AddToPortfolioManager.Result) {
@ -312,9 +310,15 @@ internal class AddToPortfolioModel @Inject constructor(
.onEmpty { finishSuccessFlow(result) }
.launchIn(this)
callbackDelegate.onChooseTokenBottomActionClick.receiveAsFlow().first()
analyticsEventHandler.send(eventBuilder.getTokenLater())
finishSuccessFlow(result)
when (val meta = terminalTokenActionsFlow().first()) {
is AddToPortfolioManager.FinishMeta.OnBottomAction -> {
finishSuccessFlow(result.copy(meta = meta))
}
AddToPortfolioManager.FinishMeta.OnQuickAction -> {
finishSuccessFlow(result.copy(meta = meta))
}
AddToPortfolioManager.FinishMeta.None -> Unit
}
}
.catch { throwable ->
TangemLogger.e("Error", throwable)
@ -323,6 +327,21 @@ internal class AddToPortfolioModel @Inject constructor(
.launchIn(modelScope)
}
private fun terminalTokenActionsFlow() = channelFlow {
callbackDelegate.onChooseTokenBottomActionClick.receiveAsFlow()
.onEach { bottomAction ->
channel.send(AddToPortfolioManager.FinishMeta.OnBottomAction(bottomAction))
}
.launchIn(this)
callbackDelegate.onQuickActionClick.receiveAsFlow()
.onEach { (action, shouldDismiss) ->
analyticsEventHandler.send(eventBuilder.getTokenActionClick(actionUM = action))
if (shouldDismiss) channel.send(AddToPortfolioManager.FinishMeta.OnQuickAction)
}
.launchIn(this)
awaitClose()
}
private suspend fun getInitialSelection(
initialData: AvailableToAddData,
): AddToPortfolioInitialSelectionResolver.InitialSelection? {
@ -529,7 +548,8 @@ internal class AddToPortfolioCallbackDelegate @Inject constructor() :
UserPortfolioComponent.Callbacks {
val onNetworkSelected = Channel<TokenMarketInfo.Network>()
val onChooseTokenBottomActionClick = Channel<Unit>()
val onChooseTokenBottomActionClick = Channel<BottomAction>()
val onQuickActionClick = Channel<Pair<TokenActionsBSContentUM.Action, Boolean>>()
val onChangeNetworkClick = Channel<Unit>()
val onChangePortfolioClick = Channel<Unit>()
val onTokenAdded = Channel<CryptoCurrencyStatus>()
@ -539,8 +559,12 @@ internal class AddToPortfolioCallbackDelegate @Inject constructor() :
onNetworkSelected.trySend(network)
}
override fun onBottomActionClick() {
onChooseTokenBottomActionClick.trySend(Unit)
override fun onBottomActionClick(bottomAction: BottomAction) {
onChooseTokenBottomActionClick.trySend(bottomAction)
}
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {
onQuickActionClick.trySend(action to shouldDismiss)
}
override fun onChangeNetworkClick() {

View file

@ -44,7 +44,7 @@ internal fun AddToPortfolioRoutes.uiSpec(): AddToPortfolioRouteUiSpec = when (th
)
AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec(
title = resourceReference(R.string.common_get_token),
isScrollable = true,
isScrollable = false,
shouldApplyHorizontalPadding = true,
footer = AddToPortfolioFooterKind.None,
)

View file

@ -2,6 +2,7 @@ package com.tangem.features.commonfeatures.impl.choosetoken
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
@ -9,9 +10,9 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge.Sett
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel
import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioFullBlockDelegate
import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioListBlockDelegate
import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -32,7 +33,7 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor(
private val onSearchQuery: Channel<SearchQuery> = Channel()
override val searchQueryState: StateFlow<SearchQuery> = onSearchQuery.receiveAsFlow()
.debounce(ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY)
.debounce(ChooseTokenModel.DEBOUNCE_SEARCH_DELAY)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = SearchQuery.Empty)
private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create(
@ -45,6 +46,7 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor(
modelScope = modelScope,
searchQueryState = searchQueryState,
portfolioListBlockDelegate = portfolioListBlockDelegate,
featureSettings = settings,
)
override val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>
@ -53,6 +55,9 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor(
override val fullPortfolioBlock: StateFlow<ChooseTokenPortfolioFullBlockUM?>
get() = portfolioFullBlockDelegate.fullPortfolioBlock
override val selectedWalletFlow: SharedFlow<UserWallet>
get() = portfolioFullBlockDelegate.selectedWalletFlow
init {
portfolioListBlockDelegate.onTokenChosen.receiveAsFlow()
.onEach { chooseResult -> onCurrencyChosen(chooseResult) }

View file

@ -41,7 +41,7 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor(
override fun Content(modifier: Modifier) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by model.state.collectAsStateWithLifecycle()
ChooseTokenScreen(state = state)
ChooseTokenScreen(state = state, modifier = modifier)
bottomSheet.child?.instance?.BottomSheet()
}

View file

@ -87,6 +87,7 @@ internal class ChooseTokenListItemConverter(
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.toPortfolioItem(params)
is AccountStatus.Payment -> accountStatus.createPaymentAccountItem(params.expandedAccounts)
is AccountStatus.Virtual -> null
}
}
.filter { portfolio -> portfolio.tokens.isNotEmpty() }

View file

@ -6,6 +6,8 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.commonfeatures.api.R
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
@ -14,10 +16,9 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarToggleTransformer
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
import com.tangem.features.commonfeatures.api.R
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ -41,6 +42,8 @@ internal class ChooseTokenModel @Inject constructor(
screensSourcesName = bridge.analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
.firstOrNull()?.value.orEmpty(),
selectedWalletFlow = bridge.selectedWalletFlow,
shouldShowSingleCurrencyWallets = bridge.settings.isShowSingleCurrencyWallets,
)
val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot
@ -78,18 +81,10 @@ internal class ChooseTokenModel @Inject constructor(
.onEach { marketBlockDelegate.addToPortfolioSlot.dismiss() }
.launchIn(modelScope)
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
.onEach { addedResult ->
val isSearched = ChooseTokenAnalyticsPayload.IsSearched(isSearchingState)
val isMarketToken = ChooseTokenAnalyticsPayload.IsMarketTokenSelected(true)
val chooseTokenResult = ChooseTokenResult(
currency = addedResult.addedCurrency,
account = addedResult.account,
wallet = addedResult.wallet,
analyticsPayload = setOf(isSearched, isMarketToken),
)
bridge.onCurrencyChosen(chooseTokenResult)
marketBlockDelegate.addToPortfolioSlot.dismiss()
}
.onEach { notifyCurrencyChosen(it, isMarketTokenSelected = true) }
.launchIn(modelScope)
addToPortfolioManager.onAddedTokenClick.receiveAsFlow()
.onEach { notifyCurrencyChosen(it, isMarketTokenSelected = false) }
.launchIn(modelScope)
}
@ -97,6 +92,20 @@ internal class ChooseTokenModel @Inject constructor(
bridge.onClose()
}
private fun notifyCurrencyChosen(addedResult: AddToPortfolioManager.Result, isMarketTokenSelected: Boolean) {
val chooseTokenResult = ChooseTokenResult(
currency = addedResult.addedCurrency,
account = addedResult.account,
wallet = addedResult.wallet,
analyticsPayload = setOf(
ChooseTokenAnalyticsPayload.IsSearched(isSearchingState),
ChooseTokenAnalyticsPayload.IsMarketTokenSelected(isMarketTokenSelected),
),
)
bridge.onCurrencyChosen(chooseTokenResult)
marketBlockDelegate.addToPortfolioSlot.dismiss()
}
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
@ -112,6 +121,7 @@ internal class ChooseTokenModel @Inject constructor(
private fun getInitState() = ChooseTokenInitialUM(
screenTitle = bridge.settings.title,
isAppBarShown = bridge.settings.isAppBarShown,
onCloseClick = ::onBackClicked,
searchBar = getInitialSearchBar(),
)

View file

@ -6,7 +6,10 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketListConfig
@ -14,9 +17,9 @@ import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute
import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
@ -25,11 +28,9 @@ import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlin.collections.filter
import kotlin.collections.map
import kotlin.collections.orEmpty
@Suppress("LongParameterList")
internal class MarketBlockDelegate @AssistedInject constructor(
@ -37,9 +38,12 @@ internal class MarketBlockDelegate @AssistedInject constructor(
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val screensSourcesName: String,
@Assisted private val selectedWalletFlow: SharedFlow<UserWallet>,
@Assisted private val shouldShowSingleCurrencyWallets: Boolean,
) {
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
@ -52,7 +56,7 @@ internal class MarketBlockDelegate @AssistedInject constructor(
analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName),
)
val marketsStateFlow: Flow<SwapMarketState> = searchQueryState
private val baseMarketsStateFlow: Flow<SwapMarketState> = searchQueryState
// Switch between default and search market flows
.map { it.value.isEmpty() }
.distinctUntilChanged()
@ -66,6 +70,24 @@ internal class MarketBlockDelegate @AssistedInject constructor(
}
}
/**
* Market block constrained by the currently selected wallet:
* - single-currency wallet: hidden entirely (`null`) - no market tokens can be added;
* - single-currency-with-token wallet (e.g. NODL): items filtered to the wallet's network,
* block hidden when nothing remains;
* - multi-currency wallet: shown as is.
*
* When single-currency wallets aren't selectable here (e.g. swap), the wallet is always
* multi-currency, so we skip the per-wallet logic entirely and return [baseMarketsStateFlow].
*/
val marketsStateFlow: Flow<SwapMarketState?> = if (!shouldShowSingleCurrencyWallets) {
baseMarketsStateFlow
} else {
selectedWalletFlow
.flatMapLatest(::marketsFlowForWallet)
.distinctUntilChanged()
}
private val defaultMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
@ -181,6 +203,44 @@ internal class MarketBlockDelegate @AssistedInject constructor(
}
}
private fun marketsFlowForWallet(wallet: UserWallet): Flow<SwapMarketState?> {
if (wallet !is UserWallet.Cold) return baseMarketsStateFlow
val resolver = wallet.scanResponse.cardTypesResolver
return when {
// Single-currency wallet can't hold market tokens - hide the whole block.
resolver.isSingleWallet() -> flowOf(null)
// Single-currency-with-token wallet (NODL) - keep only tokens available on the wallet's network(s).
resolver.isSingleWalletWithToken() -> combine(
baseMarketsStateFlow,
singleAccountStatusListSupplier(wallet.walletId),
) { state, accountStatusList ->
filterStateByNetwork(state, accountStatusList.allowedNetworkIds())
}
// Multi-currency wallet - the common case, no filtering needed.
else -> baseMarketsStateFlow
}
}
private fun AccountStatusList.allowedNetworkIds(): Set<String> =
flattenCurrencies().mapTo(hashSetOf()) { it.currency.network.rawId }
private fun filterStateByNetwork(state: SwapMarketState, allowedNetworkIds: Set<String>): SwapMarketState? {
if (state !is SwapMarketState.Content) return state
if (allowedNetworkIds.isEmpty()) return null
val filteredItems = state.items.filter { item ->
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id)
tokenMarket?.networks?.any { allowedNetworkIds.contains(it.networkId) } == true
}.toImmutableList()
return if (filteredItems.isEmpty()) {
null
} else {
state.copy(items = filteredItems, total = filteredItems.size)
}
}
private fun addToPortfolioItem(item: MarketsListItemUM) {
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id) ?: return
@ -218,6 +278,8 @@ internal class MarketBlockDelegate @AssistedInject constructor(
searchQueryState: StateFlow<SearchQuery>,
modelScope: CoroutineScope,
screensSourcesName: String,
selectedWalletFlow: SharedFlow<UserWallet>,
shouldShowSingleCurrencyWallets: Boolean,
): MarketBlockDelegate
}
}

View file

@ -4,11 +4,11 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
@ -35,9 +35,11 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
@Assisted private val modelScope: CoroutineScope,
@Assisted private val portfolioListBlockDelegate: PortfolioListBlockDelegate,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val featureSettings: ChooseTokenBridge.Settings,
) {
private val isSearchingState: Boolean get() = searchQueryState.isSearchingState
private val isOnlyMultiCurrency: Boolean get() = !featureSettings.isShowSingleCurrencyWallets
private val onWalletSelected = Channel<UserWalletId>(capacity = Channel.BUFFERED)
val selectedWalletFlow: SharedFlow<UserWallet> = onWalletSelected.receiveAsFlow()
@ -51,9 +53,11 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
init {
val globalSelectedWallet = selectedWalletUseCase.sync().getOrNull()
val allWallets = getWalletsUseCase.invokeSync().filter { it.isMultiCurrency }
val allWallets = getWalletsUseCase.invokeSync()
.filter { !isOnlyMultiCurrency || it.isMultiCurrency }
val firstSelectedWallet = when {
globalSelectedWallet?.isMultiCurrency == true -> globalSelectedWallet
globalSelectedWallet != null && (!isOnlyMultiCurrency || globalSelectedWallet.isMultiCurrency) ->
globalSelectedWallet
allWallets.isNotEmpty() -> allWallets.first()
else -> null
}
@ -61,8 +65,10 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
}
private fun buildFlow() = flow {
val walletsFlow = getWalletsUseCase.invokeAsMap()
.map { wallets -> wallets.filterNot { (_, wallet) -> wallet.isLocked } }
val walletsFlow = getWalletsUseCase.invokeAsMap(
isOnlyMultiCurrency = isOnlyMultiCurrency,
filterLocked = true,
)
val fullPortfolioBlockFlow = combine(
flow = walletsFlow,
flow2 = portfolioListBlockDelegate.portfolioList,
@ -109,6 +115,7 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
modelScope: CoroutineScope,
portfolioListBlockDelegate: PortfolioListBlockDelegate,
searchQueryState: StateFlow<SearchQuery>,
featureSettings: ChooseTokenBridge.Settings,
): PortfolioFullBlockDelegate
}
}

View file

@ -39,6 +39,7 @@ import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
@ -82,12 +83,20 @@ private val ChooseTokenFullUM.isEmptyState: Boolean
internal fun ChooseTokenScreen(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.secondary)
.background(
color = if (LocalRedesignEnabled.current) {
TangemTheme.colors2.surface.level2
} else {
TangemTheme.colors.background.secondary
},
)
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBar(title = state.initialUM.screenTitle, onBackClick = state.initialUM.onCloseClick, Modifier)
if (state.initialUM.isAppBarShown) {
AppBar(title = state.initialUM.screenTitle, onBackClick = state.initialUM.onCloseClick, Modifier)
}
Content(
state = state,
@ -465,6 +474,7 @@ private val wallets
private val initialUM = ChooseTokenInitialUM(
screenTitle = stringReference("Choose token"),
isAppBarShown = true,
onCloseClick = {},
searchBar = searchBar,
)

View file

@ -13,6 +13,7 @@ internal data class ChooseTokenFullUM(
internal data class ChooseTokenInitialUM(
val screenTitle: TextReference,
val isAppBarShown: Boolean,
val onCloseClick: () -> Unit,
val searchBar: SearchBarUM,
)

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio
package com.tangem.features.commonfeatures.impl.tokenactions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -18,14 +18,16 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContentV2
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import com.tangem.features.commonfeatures.impl.tokenactions.model.TokenActionsModel
import com.tangem.features.commonfeatures.impl.tokenactions.ui.TokenActionsContent
import com.tangem.features.commonfeatures.impl.tokenactions.ui.TokenActionsContentV2
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
internal class TokenActionsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@ -74,15 +76,14 @@ internal class TokenActionsComponent @AssistedInject constructor(
data class Params(
val data: Flow<CryptoCurrencyData>,
val callbacks: Callbacks,
val bottomAction: BottomAction = BottomAction.Later,
val bottomAction: Flow<BottomAction> = flowOf(BottomAction.None),
val isRedesignForced: Boolean = false,
val isCompact: Boolean = false,
)
enum class BottomAction { Later, GoToToken }
interface Callbacks {
fun onBottomActionClick()
fun onQuickActionClick(action: TokenActionsBSContentUM.Action) {}
fun onBottomActionClick(bottomAction: BottomAction)
fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {}
}
@AssistedFactory

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
package com.tangem.features.commonfeatures.impl.tokenactions.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
@ -12,8 +12,8 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -45,7 +45,10 @@ internal class TokenActionsModel @Inject constructor(
private val tokenActionsHandler: TokenActionsHandler =
tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) },
onHandleQuickAction = { handledAction, shouldDismiss ->
handledQuickAction(handledAction, shouldDismiss)
},
coroutineScope = modelScope,
)
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
@ -55,15 +58,17 @@ internal class TokenActionsModel @Inject constructor(
combine(
params.data,
getBalanceHidingSettingsUseCase.isBalanceHidden(),
) { cryptoCurrencyData, isBalanceHidden ->
cryptoCurrencyData to isBalanceHidden
params.bottomAction,
) { cryptoCurrencyData, isBalanceHidden, bottomAction ->
Triple(cryptoCurrencyData, isBalanceHidden, bottomAction)
}
.mapLatest { (cryptoCurrencyData, isBalanceHidden) ->
.mapLatest { (cryptoCurrencyData, isBalanceHidden, bottomAction) ->
uiBuilder.build(
cryptoCurrencyData = cryptoCurrencyData,
tokenActionsHandler = tokenActionsHandler,
appCurrency = currentAppCurrency.value,
isBalanceHidden = isBalanceHidden,
bottomAction = bottomAction,
)
}
.flowOn(dispatchers.default)
@ -73,16 +78,18 @@ internal class TokenActionsModel @Inject constructor(
initialValue = null,
)
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) = modelScope.launch {
params.callbacks.onQuickActionClick(handledAction.action)
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (!isReceive) return@launch
val tokenConfig = withContext(dispatchers.default) {
receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
)
} ?: return@launch
bottomSheetNavigation.activate(tokenConfig)
}
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction, shouldDismiss: Boolean) =
modelScope.launch {
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (isReceive) {
val tokenConfig = withContext(dispatchers.default) {
receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
)
}
if (tokenConfig != null) bottomSheetNavigation.activate(tokenConfig)
}
params.callbacks.onQuickActionClick(handledAction.action, shouldDismiss)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
package com.tangem.features.commonfeatures.impl.tokenactions.model
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.getTotalCryptoAmount
@ -11,6 +11,7 @@ import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToI
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
@ -30,9 +31,9 @@ import com.tangem.features.commonfeatures.impl.R
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.PortfolioBadgeUM
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.PortfolioBadgeUM
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
import java.math.BigDecimal
import javax.inject.Inject
@ -50,6 +51,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
tokenActionsHandler: TokenActionsHandler,
appCurrency: AppCurrency,
isBalanceHidden: Boolean,
bottomAction: BottomAction,
): TokenActionsUM {
return if (designFeatureToggles.isRedesignEnabled || params.isRedesignForced) {
buildV2(
@ -57,11 +59,13 @@ internal class TokenActionsUiBuilder @Inject constructor(
tokenActionsHandler = tokenActionsHandler,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
bottomAction = bottomAction,
)
} else {
buildV1(
cryptoCurrencyData = cryptoCurrencyData,
tokenActionsHandler = tokenActionsHandler,
bottomAction = bottomAction,
)
}
}
@ -69,6 +73,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
private fun buildV1(
cryptoCurrencyData: CryptoCurrencyData,
tokenActionsHandler: TokenActionsHandler,
bottomAction: BottomAction,
): TokenActionsUM {
val status = cryptoCurrencyData.status
val tokenUM = TokenItemState.Content(
@ -88,9 +93,9 @@ internal class TokenActionsUiBuilder @Inject constructor(
tokenActionsHandler = tokenActionsHandler,
isRedesignEnabled = false,
),
bottomActionText = bottomActionText(params.bottomAction),
bottomActionText = bottomActionText(bottomAction),
onBottomActionClick = {
params.callbacks.onBottomActionClick()
params.callbacks.onBottomActionClick(bottomAction)
},
)
}
@ -100,6 +105,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
tokenActionsHandler: TokenActionsHandler,
appCurrency: AppCurrency,
isBalanceHidden: Boolean,
bottomAction: BottomAction,
): TokenActionsUM {
val status = cryptoCurrencyData.status
val tokenUM = TokenItemState.Content(
@ -119,19 +125,20 @@ internal class TokenActionsUiBuilder @Inject constructor(
tokenActionsHandler = tokenActionsHandler,
isRedesignEnabled = true,
),
bottomActionText = bottomActionText(params.bottomAction),
bottomActionText = bottomActionText(bottomAction),
onBottomActionClick = {
params.callbacks.onBottomActionClick()
params.callbacks.onBottomActionClick(bottomAction)
},
isBalancesHidden = isBalanceHidden,
portfolioBadge = createPortfolioBadge(cryptoCurrencyData = cryptoCurrencyData),
isCompact = params.isCompact,
)
}
private fun bottomActionText(action: TokenActionsComponent.BottomAction): TextReference {
private fun bottomActionText(action: BottomAction): TextReference? {
return when (action) {
TokenActionsComponent.BottomAction.Later -> resourceReference(R.string.common_later)
TokenActionsComponent.BottomAction.GoToToken -> resourceReference(R.string.common_go_to_token)
BottomAction.GoToToken -> resourceReference(R.string.common_go_to_token)
BottomAction.None -> null
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
package com.tangem.features.commonfeatures.impl.tokenactions.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
@ -39,7 +39,7 @@ import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
@ -73,13 +73,15 @@ internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Mod
}
}
SpacerH16()
if (state.bottomActionText != null) {
SpacerH16()
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = state.bottomActionText.resolveReference(),
onClick = state.onBottomActionClick,
)
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = state.bottomActionText.resolveReference(),
onClick = state.onBottomActionClick,
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
package com.tangem.features.commonfeatures.impl.tokenactions.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
@ -43,8 +43,8 @@ import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.core.ui.res.*
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.PortfolioBadgeUM
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.PortfolioBadgeUM
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@ -52,53 +52,84 @@ import java.util.UUID
@Composable
internal fun TokenActionsContentV2(state: TokenActionsUM, modifier: Modifier = Modifier) {
if (state.isCompact) {
CompactLayout(state, modifier)
} else {
FullLayout(state, modifier)
}
}
@Composable
private fun CompactLayout(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
QuickActionsList(state)
SpacerH(TangemTheme.dimens2.x4)
}
}
@Composable
private fun FullLayout(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
TokenHeader(
addedToken = state.token,
portfolioBadge = state.portfolioBadge,
isBalanceHidden = state.isBalancesHidden,
)
SpacerH(TangemTheme.dimens2.x2)
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
state.quickActions.actions.fastForEach { actionUM ->
key(actionUM.title) {
val transitionState = remember {
MutableTransitionState(initialState = false).apply { targetState = true }
}
AnimatedVisibility(
visibleState = transitionState,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
TokenActionRow(
iconRes = actionUM.icon,
title = actionUM.title,
description = actionUM.description,
onClick = { state.quickActions.onQuickActionClick(actionUM) },
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }
.takeIf { actionUM.isLongClickAvailable },
)
}
}
TokenHeader(
addedToken = state.token,
portfolioBadge = state.portfolioBadge,
isBalanceHidden = state.isBalancesHidden,
)
}
QuickActionsList(state)
val bottomText = state.bottomActionText
if (bottomText != null) {
SpacerH(TangemTheme.dimens2.x6)
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
SecondaryTangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = state.onBottomActionClick,
text = bottomText,
size = TangemButtonSize.X12,
shape = TangemButtonShape.Rounded,
)
}
}
SpacerH(TangemTheme.dimens2.x4)
}
}
SpacerH(TangemTheme.dimens2.x6)
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
SecondaryTangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = state.onBottomActionClick,
text = state.bottomActionText,
size = TangemButtonSize.X12,
shape = TangemButtonShape.Rounded,
)
@Composable
private fun QuickActionsList(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
state.quickActions.actions.fastForEach { actionUM ->
key(actionUM.title) {
val transitionState = remember {
MutableTransitionState(initialState = false).apply { targetState = true }
}
AnimatedVisibility(
visibleState = transitionState,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
TokenActionRow(
iconRes = actionUM.icon,
title = actionUM.title,
description = actionUM.description,
onClick = { state.quickActions.onQuickActionClick(actionUM) },
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }
.takeIf { actionUM.isLongClickAvailable },
)
}
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state
package com.tangem.features.commonfeatures.impl.tokenactions.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.action.QuickActions
@ -10,10 +10,11 @@ import com.tangem.core.ui.extensions.TextReference
internal data class TokenActionsUM(
val token: TokenItemState,
val quickActions: QuickActions,
val bottomActionText: TextReference,
val bottomActionText: TextReference?,
val onBottomActionClick: () -> Unit,
val isBalancesHidden: Boolean = false,
val portfolioBadge: PortfolioBadgeUM = PortfolioBadgeUM.None,
val isCompact: Boolean = false,
)
@Immutable

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio
package com.tangem.features.commonfeatures.impl.userportfolio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -7,7 +7,7 @@ import com.tangem.common.ui.markets.tokenselector.TokenSelectorEmbeddedContent
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

View file

@ -1,8 +1,8 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio
package com.tangem.features.commonfeatures.impl.userportfolio
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
import kotlinx.coroutines.flow.StateFlow
internal interface UserPortfolioComponent : ComposableContentComponent {

View file

@ -1,9 +1,9 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model
package com.tangem.features.commonfeatures.impl.userportfolio.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.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model
package com.tangem.features.commonfeatures.impl.userportfolio.model
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state
package com.tangem.features.commonfeatures.impl.userportfolio.state
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -7,8 +7,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer.UserPortfolioSectionsTransformer
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
import com.tangem.features.commonfeatures.impl.userportfolio.transformer.UserPortfolioSectionsTransformer
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer
package com.tangem.features.commonfeatures.impl.userportfolio.transformer
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.ui.account.toUM
@ -21,7 +21,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toImmutableList

View file

@ -0,0 +1,258 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.card.WalletData
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.test.core.getEmittedValues
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MarketBlockDelegateTest {
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory = mockk()
private val excludedBlockchains: ExcludedBlockchains = mockk(relaxed = true)
private val getUserWalletsUseCase: GetWalletsUseCase = mockk(relaxed = true)
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory = mockk()
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
private val defaultManager: MarketsListBatchFlowManager = mockk(relaxed = true)
private val searchManager: MarketsListBatchFlowManager = mockk(relaxed = true)
private val searchQueryState = MutableStateFlow(SearchQuery.Empty)
private val defaultUiItems = MutableStateFlow<ImmutableList<MarketsListItemUM>>(persistentListOf())
// Keyed by the raw id value: CryptoCurrency.RawID is a value class, unboxed to String at the JVM boundary.
private val tokenMarketsByRawId = mutableMapOf<String, TokenMarket>()
@BeforeEach
fun setup() {
clearMocks(
marketsListBatchFlowManagerFactory,
addToPortfolioManagerFactory,
singleAccountStatusListSupplier,
defaultManager,
searchManager,
)
searchQueryState.value = SearchQuery.Empty
defaultUiItems.value = persistentListOf()
tokenMarketsByRawId.clear()
every {
marketsListBatchFlowManagerFactory.create(
GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
any(),
any(),
any()
)
} returns defaultManager
every {
marketsListBatchFlowManagerFactory.create(
GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
any(),
any(),
any()
)
} returns searchManager
every { addToPortfolioManagerFactory.create(any(), any(), any()) } returns mockk(relaxed = true)
every { defaultManager.uiItems } returns defaultUiItems
every { defaultManager.isInInitialLoadingErrorState } returns MutableStateFlow(false)
every { defaultManager.totalCount } returns MutableStateFlow(null)
every { defaultManager.getTokenMarketById(any()) } answers { tokenMarketsByRawId[firstArg<String>()] }
every { searchManager.uiItems } returns MutableStateFlow(persistentListOf())
every { searchManager.isInInitialLoadingErrorState } returns MutableStateFlow(false)
every { searchManager.isSearchNotFoundState } returns MutableStateFlow(false)
every { searchManager.totalCount } returns MutableStateFlow(null)
every { searchManager.getTokenMarketById(any()) } returns null
}
@Test
fun `GIVEN multi-currency wallet WHEN trending emitted THEN all items shown unchanged`() = runTest {
// Arrange
val item1 = marketItem("token-1")
val item2 = marketItem("token-2")
defaultUiItems.value = persistentListOf(item1, item2)
val delegate = createDelegate(wallet = MockUserWalletFactory.create())
// Act
val result = lastMarketState(delegate)
// Assert
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
assertThat((result as SwapMarketState.Content).items).containsExactly(item1, item2).inOrder()
}
@Test
fun `GIVEN single-currency wallet WHEN trending emitted THEN market block is hidden`() = runTest {
// Arrange
defaultUiItems.value = persistentListOf(marketItem("token-1"))
val delegate = createDelegate(wallet = createSingleCurrencyWallet())
// Act
val result = lastMarketState(delegate)
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN single-currency wallets not shown WHEN trending emitted THEN base state returned without filtering`() =
runTest {
// Arrange
val item1 = marketItem("token-1")
defaultUiItems.value = persistentListOf(item1)
// Single-currency wallet would normally hide the block, but the setting short-circuits the per-wallet logic.
val delegate = createDelegate(wallet = createSingleCurrencyWallet(), showSingleCurrencyWallets = false)
// Act
val result = lastMarketState(delegate)
// Assert
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
assertThat((result as SwapMarketState.Content).items).containsExactly(item1)
}
@Test
fun `GIVEN NODL wallet WHEN trending emitted THEN only items on wallet network are shown`() = runTest {
// Arrange
val nodlWallet = MockUserWalletFactory.createSingleWalletWithToken()
val itemOnWalletNetwork = marketItem("token-stellar")
val itemOnOtherNetwork = marketItem("token-eth")
tokenMarketsByRawId["token-stellar"] = tokenMarket(STELLAR_NETWORK_ID)
tokenMarketsByRawId["token-eth"] = tokenMarket(ETHEREUM_NETWORK_ID)
defaultUiItems.value = persistentListOf(itemOnWalletNetwork, itemOnOtherNetwork)
every {
singleAccountStatusListSupplier(nodlWallet.walletId)
} returns flowOf(accountStatusList(STELLAR_NETWORK_ID))
// Act
val result = lastMarketState(createDelegate(wallet = nodlWallet))
// Assert
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
assertThat((result as SwapMarketState.Content).items).containsExactly(itemOnWalletNetwork)
assertThat(result.total).isEqualTo(1)
}
@Test
fun `GIVEN NODL wallet WHEN no trending tokens on wallet network THEN market block is hidden`() = runTest {
// Arrange
val nodlWallet = MockUserWalletFactory.createSingleWalletWithToken()
val itemOnOtherNetwork = marketItem("token-eth")
tokenMarketsByRawId["token-eth"] = tokenMarket(ETHEREUM_NETWORK_ID)
defaultUiItems.value = persistentListOf(itemOnOtherNetwork)
every {
singleAccountStatusListSupplier(nodlWallet.walletId)
} returns flowOf(accountStatusList(STELLAR_NETWORK_ID))
// Act
val result = lastMarketState(createDelegate(wallet = nodlWallet))
// Assert
assertThat(result).isNull()
}
// region Helpers
private fun TestScope.lastMarketState(delegate: MarketBlockDelegate): SwapMarketState? {
val emittedValues = getEmittedValues(delegate.marketsStateFlow)
advanceUntilIdle()
return emittedValues.last()
}
private fun TestScope.createDelegate(
wallet: UserWallet,
showSingleCurrencyWallets: Boolean = true,
): MarketBlockDelegate {
val selectedWalletFlow = MutableSharedFlow<UserWallet>(replay = 1)
selectedWalletFlow.tryEmit(wallet)
return MarketBlockDelegate(
marketsListBatchFlowManagerFactory = marketsListBatchFlowManagerFactory,
excludedBlockchains = excludedBlockchains,
getUserWalletsUseCase = getUserWalletsUseCase,
addToPortfolioManagerFactory = addToPortfolioManagerFactory,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
modelScope = backgroundScope,
searchQueryState = searchQueryState,
screensSourcesName = "test",
selectedWalletFlow = selectedWalletFlow,
shouldShowSingleCurrencyWallets = showSingleCurrencyWallets,
)
}
private fun marketItem(id: String): MarketsListItemUM = mockk {
every { this@mockk.id } returns CryptoCurrency.RawID(id)
}
private fun tokenMarket(vararg networkIds: String): TokenMarket = mockk {
every { networks } returns networkIds.map { networkId ->
TokenMarket.Network(networkId = networkId, contractAddress = null, decimalCount = null)
}
}
private fun accountStatusList(vararg networkIds: String): AccountStatusList = mockk {
every { flattenCurrencies() } returns networkIds.map { networkId ->
mockk<CryptoCurrencyStatus> {
every { currency.network.rawId } returns networkId
}
}
}
private fun createSingleCurrencyWallet(): UserWallet.Cold = UserWallet.Cold(
name = "Single",
walletId = UserWalletId("022"),
cardsInWallet = emptySet(),
isMultiCurrency = false,
scanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(maxWalletCount = 2),
derivedKeys = emptyMap(),
).copy(
productType = ProductType.Note,
walletData = WalletData(blockchain = "XLM", token = null),
),
hasBackupError = false,
)
// endregion
private companion object {
const val STELLAR_NETWORK_ID = "stellar"
const val ETHEREUM_NETWORK_ID = "ethereum"
}
}

View file

@ -10,11 +10,6 @@ plugins {
android {
namespace = "com.tangem.features.createwalletstart.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.createWalletStart.api)
@ -79,7 +74,6 @@ dependencies {
/** Test */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)

View file

@ -79,7 +79,7 @@ internal class CreateWalletStartModelTest {
coEvery { appsFlyerStore.get() } returns null
coEvery { settingsRepository.shouldSaveAccessCodes() } returns false
every { coldUserWalletBuilderFactory.create(any()) } returns coldUserWalletBuilder
every { coldUserWalletBuilder.build() } returns testColdWallet
coEvery { coldUserWalletBuilder.build() } returns testColdWallet
every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false
coEvery {
scanCardProcessor.scan(
@ -356,7 +356,7 @@ internal class CreateWalletStartModelTest {
@Test
fun `GIVEN builder returns null WHEN proceedWithScanResponse THEN saveWalletUseCase not called`() = runTest {
every { coldUserWalletBuilder.build() } returns null
coEvery { coldUserWalletBuilder.build() } returns null
coEvery {
scanCardProcessor.scan(
analyticsSource = any(),

View file

@ -10,11 +10,6 @@ plugins {
android {
namespace = "com.tangem.features.details.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/* Project - API */
@ -24,6 +19,7 @@ dependencies {
implementation(projects.features.tester.api)
implementation(projects.features.createWalletSelection.api)
implementation(projects.features.onboardingV2.api)
implementation(projects.features.addressBook.api)
/* Project - Core */
implementation(projects.core.decompose)
@ -87,7 +83,6 @@ dependencies {
/* Test */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)

View file

@ -2,14 +2,8 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>HasPlatformType:DetailsModel.kt$DetailsModel.Companion$val APP_LANGUAGE = Locale.getDefault().language</ID>
<ID>HasPlatformType:DetailsModel.kt$DetailsModel.Companion$val SYSTEM_LANGUAGE = runCatching { Resources.getSystem().configuration.locales[0].language }.getOrElse { "" }</ID>
<ID>MultilineLambdaItParameter:DetailsModel.kt$DetailsModel${ Timber.w("Unable to check WalletConnect availability: $it") false }</ID>
<ID>MultilineLambdaItParameter:DetailsModel.kt$DetailsModel${ it.copy( selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig( isShown = true, onDismissRequest = { state.update { it.copy( selectFeedbackEmailTypeBSConfig = it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), ) } }, content = SelectEmailFeedbackTypeBS( onOptionClick = { option -&gt; onEmailFeedbackTypeOptionSelected( selectedWalletMetaInfo = selectedWalletMetaInfo, option = option, ) state.update { it.copy( selectFeedbackEmailTypeBSConfig = it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), ) } }, ), ), ) }</ID>
<ID>MultilineLambdaItParameter:DetailsModel.kt$DetailsModel${ it.copy( selectFeedbackEmailTypeBSConfig = it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), ) }</ID>
<ID>MultilineLambdaItParameter:PreviewUserWalletListComponent.kt$PreviewUserWalletListComponent${ it.copy( balance = UserWalletItemUM.Balance.Loaded( value = "1.000 BTC", isFlickering = true, ), ) }</ID>
<ID>MultilineLambdaItParameter:UserWalletSaver.kt$UserWalletSaver${ val message = it.message if (!message.isNullOrEmpty()) { messageSender.send(SnackbarMessage(message)) } }</ID>
<ID>RedundantSuspendModifier:UserWalletSaver.kt$UserWalletSaver$suspend</ID>
<ID>UnnecessaryLet:ItemsBuilder.kt$ItemsBuilder$let(::add)</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -29,6 +29,7 @@ internal class PreviewDetailsComponent : DetailsComponent {
},
).buildAll(
isWalletConnectAvailable = true,
isAddressBookAvailable = true,
isSupportChatAvailable = true,
hasAnyMobileWallet = true,
userWalletId = UserWalletId(""),

View file

@ -25,6 +25,15 @@ internal sealed class DetailsItemUM {
override val id: String = "wallet_connect"
}
data class WalletConnectAddressBookBlock(val items: List<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)
}
}
data object UserWalletList : DetailsItemUM() {
override val id: String = "user_wallet_list"
}

View file

@ -26,6 +26,7 @@ import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM
@ -51,6 +52,7 @@ internal class DetailsModel @Inject constructor(
socialsBuilder: SocialsBuilder,
paramsContainer: ParamsContainer,
feedbackFeatureToggles: FeedbackFeatureToggles,
addressBookFeatureToggles: AddressBookFeatureToggles,
private val itemsBuilder: ItemsBuilder,
private val appInfoProvider: AppInfoProvider,
private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase,
@ -86,6 +88,7 @@ internal class DetailsModel @Inject constructor(
items = MutableStateFlow(
itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable,
isAddressBookAvailable = addressBookFeatureToggles.isAddressBookEnabled,
isSupportChatAvailable = feedbackFeatureToggles.isUsedeskEnabled,
hasAnyMobileWallet = getWalletsUseCase.invokeSync().any { it is UserWallet.Hot },
userWalletId = params.userWalletId,

View file

@ -2,10 +2,10 @@ 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.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
@ -13,17 +13,21 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.SpacerH16
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.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
@ -155,10 +159,44 @@ private fun Block(
onClick = model.onClick,
)
}
is DetailsItemUM.WalletConnectAddressBookBlock -> {
BlockCard {
WalletConnectAddressBookBlockItems(
items = model.items,
modifier = itemModifier,
)
}
}
is DetailsItemUM.UserWalletList -> {
userWalletListBlockContent.Content(modifier = itemModifier)
}
is DetailsItemUM.UnderSectionText -> { /* Handled above */ }
is DetailsItemUM.UnderSectionText -> { /* Handled above */
}
}
}
}
@Composable
private fun WalletConnectAddressBookBlockItems(
items: List<DetailsItemUM.WalletConnectAddressBookBlock.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.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),
)
}
}
}

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -34,6 +35,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.DetailsScreenTestTags
import com.tangem.features.details.component.UserWalletListComponent
import com.tangem.features.details.component.preview.PreviewUserWalletListComponent
import com.tangem.features.details.entity.UserWalletListUM
@ -69,7 +71,9 @@ internal fun UserWalletListBlock(state: UserWalletListUM, modifier: Modifier = M
model = walletState,
reorderableListState = reorderableListState,
walletReorderUM = state.walletReorderUM,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.testTag(DetailsScreenTestTags.USER_WALLET_ITEM),
)
}
item(key = "add_wallet_button") {

View file

@ -26,6 +26,7 @@ internal class ItemsBuilder @Inject constructor(
@Suppress("LongParameterList")
fun buildAll(
isWalletConnectAvailable: Boolean,
isAddressBookAvailable: Boolean,
isSupportChatAvailable: Boolean,
hasAnyMobileWallet: Boolean,
userWalletId: UserWalletId,
@ -33,7 +34,11 @@ internal class ItemsBuilder @Inject constructor(
onSupportChatClick: () -> Unit,
onBuyClick: () -> Unit,
): ImmutableList<DetailsItemUM> = buildList {
buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
if (isAddressBookAvailable) {
buildWalletConnectAddressBookBlock(isWalletConnectAvailable, userWalletId)
} else {
buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
}
buildUserWalletListBlock().let(::add)
if (hotWalletRestrictionManager.isCreationEnabledSync() && hasAnyMobileWallet) {
@ -86,6 +91,33 @@ internal class ItemsBuilder @Inject constructor(
}
}
private fun MutableList<DetailsItemUM>.buildWalletConnectAddressBookBlock(
isWalletConnectAvailable: Boolean,
userWalletId: UserWalletId,
) {
val walletConnectAddressBookItems = buildList {
if (isWalletConnectAvailable) add(buildWalletConnectButton(userWalletId))
add(buildAddressBookButton())
}
if (walletConnectAddressBookItems.isNotEmpty()) {
add(DetailsItemUM.WalletConnectAddressBookBlock(walletConnectAddressBookItems))
}
}
private fun buildWalletConnectButton(
userWalletId: UserWalletId,
): DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect {
return DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect(
onClick = { router.push(AppRoute.WalletConnectSessions(userWalletId)) },
)
}
private fun buildAddressBookButton(): DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook {
return DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook(
onClick = { router.push(AppRoute.AddressBook()) },
)
}
private fun buildUserWalletListBlock(): DetailsItemUM = DetailsItemUM.UserWalletList
private fun buildShopBlock(onBuyClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic(

View file

@ -119,7 +119,7 @@ internal class UserWalletSaver @Inject constructor(
)
}
private fun Raise<Error>.createUserWallet(response: ScanResponse): UserWallet {
private suspend fun Raise<Error>.createUserWallet(response: ScanResponse): UserWallet {
val userWallet = coldUserWalletBuilderFactory.create(scanResponse = response).build()
return ensureNotNull(userWallet) { Error.Unknown }

View file

@ -0,0 +1,191 @@
package com.tangem.features.details.model
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
@Test
fun `GIVEN all wallets hot WHEN support email clicked THEN DirectUserRequest sent`() = runTest {
// Arrange
val wallet = hotWallet(wallet1)
val meta = metaInfo(wallet1, isVisa = false)
every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
every { getTangemPayCustomerIdUseCase(wallet1) } returns "".right()
// Act
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
advanceUntilIdle()
// Assert
verify { analyticsEventHandler.send(any<Basic.ButtonSupport>()) }
coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(meta)) }
model.onDestroy()
}
@Test
fun `GIVEN all wallets cold visa with customerId WHEN support email clicked THEN Visa request sent`() = runTest {
val wallet = coldWallet(wallet1, isVisa = true)
val meta = metaInfo(wallet1, isVisa = true)
every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
advanceUntilIdle()
verify { analyticsEventHandler.send(any<Basic.ButtonSupport>()) }
coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.Visa.DirectUserRequest(meta, customerId)) }
model.onDestroy()
}
@Test
fun `GIVEN mixed wallets WHEN support email clicked THEN bottom sheet shown and no email sent`() = runTest {
val selected = hotWallet(wallet1)
val meta = metaInfo(wallet1, isVisa = false)
every { getWalletsUseCase.invokeSync() } returns listOf(selected, coldWallet(wallet2, isVisa = true))
every { getSelectedWalletSyncUseCase() } returns selected.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
advanceUntilIdle()
val bsConfig = model.state.value.selectFeedbackEmailTypeBSConfig
assertThat(bsConfig.isShown).isTrue()
assertThat(bsConfig.content).isInstanceOf(SelectEmailFeedbackTypeBS::class.java)
coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) }
model.onDestroy()
}
@Test
fun `GIVEN meta info missing WHEN support email clicked THEN no email sent`() = runTest {
val wallet = hotWallet(wallet1)
every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
every { getSelectedWalletSyncUseCase() } returns wallet.right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns Throwable().left()
every { getTangemPayCustomerIdUseCase(wallet1) } returns "".right()
val model = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
advanceUntilIdle()
coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) }
model.onDestroy()
}
@Test
fun `GIVEN General option AND selected meta not visa WHEN selected THEN DirectUserRequest with selected meta`() =
runTest {
val selectedMeta = metaInfo(wallet1, isVisa = false)
val content = openBottomSheet(selectedMeta = selectedMeta)
content.onOptionClick(SelectEmailFeedbackTypeBS.Option.General)
advanceUntilIdle()
coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(selectedMeta)) }
assertThat(currentModel.state.value.selectFeedbackEmailTypeBSConfig.isShown).isFalse()
verify { analyticsEventHandler.send(any<Basic.ButtonSupport>()) }
currentModel.onDestroy()
}
@Test
fun `GIVEN General option AND selected meta visa WHEN selected THEN picks non-visa wallet meta`() = runTest {
val selectedMeta = metaInfo(wallet1, isVisa = true)
val nonVisaMeta = metaInfo(wallet2, isVisa = false)
val content = openBottomSheet(
selectedMeta = selectedMeta,
wallets = listOf(coldWallet(wallet1, isVisa = true), hotWallet(wallet2)),
)
coEvery { getWalletMetaInfoUseCase(wallet2) } returns nonVisaMeta.right()
content.onOptionClick(SelectEmailFeedbackTypeBS.Option.General)
advanceUntilIdle()
coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(nonVisaMeta)) }
currentModel.onDestroy()
}
@Test
fun `GIVEN Visa option AND selected meta visa with customerId WHEN selected THEN Visa request with selected`() =
runTest {
val selectedMeta = metaInfo(wallet1, isVisa = true)
val content = openBottomSheet(selectedMeta = selectedMeta, customerId = customerId)
content.onOptionClick(SelectEmailFeedbackTypeBS.Option.Visa)
advanceUntilIdle()
coVerify {
sendFeedbackEmailUseCase(FeedbackEmailType.Visa.DirectUserRequest(selectedMeta, customerId))
}
currentModel.onDestroy()
}
@Test
fun `GIVEN Visa option AND selected meta not visa WHEN selected THEN picks cold visa wallet meta`() = runTest {
val selectedMeta = metaInfo(wallet1, isVisa = false)
val visaMeta = metaInfo(wallet2, isVisa = true)
val content = openBottomSheet(
selectedMeta = selectedMeta,
wallets = listOf(hotWallet(wallet1), coldWallet(wallet2, isVisa = true)),
)
coEvery { getWalletMetaInfoUseCase(wallet2) } returns visaMeta.right()
every { getTangemPayCustomerIdUseCase(wallet2) } returns customerId.right()
content.onOptionClick(SelectEmailFeedbackTypeBS.Option.Visa)
advanceUntilIdle()
coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.Visa.DirectUserRequest(visaMeta, customerId)) }
currentModel.onDestroy()
}
private lateinit var currentModel: DetailsModel
/**
* Drives the model into the "mixed wallets" state so the bottom sheet is shown, then returns its content.
* [selectedMeta] is what [getWalletMetaInfoUseCase] returns for the selected wallet ([wallet1]).
*/
private fun TestScope.openBottomSheet(
selectedMeta: WalletMetaInfo,
wallets: List<UserWallet> = listOf(hotWallet(wallet1), coldWallet(wallet2, isVisa = true)),
customerId: String = "",
): SelectEmailFeedbackTypeBS {
every { getWalletsUseCase.invokeSync() } returns wallets
every { getSelectedWalletSyncUseCase() } returns wallets.first().right()
coEvery { getWalletMetaInfoUseCase(wallet1) } returns selectedMeta.right()
every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
currentModel = createModel(this)
advanceUntilIdle()
onEmailSlot.captured.invoke()
advanceUntilIdle()
return currentModel.state.value.selectFeedbackEmailTypeBSConfig.content as SelectEmailFeedbackTypeBS
}
}

Some files were not shown because too many files have changed in this diff Show more