Updated on 2026-08-14
This commit is contained in:
commit
d6f9f59866
1729 changed files with 67614 additions and 9361 deletions
|
|
@ -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
1
features/address-book/api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
build/
|
||||
22
features/address-book/api/build.gradle.kts
Normal file
22
features/address-book/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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?)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.features.addressbook
|
||||
|
||||
interface AddressBookFeatureToggles {
|
||||
val isAddressBookEnabled: Boolean
|
||||
}
|
||||
1
features/address-book/impl/.gitignore
vendored
Normal file
1
features/address-book/impl/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
build/
|
||||
57
features/address-book/impl/build.gradle.kts
Normal file
57
features/address-book/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package com.tangem.features.commonfeatures.api.tokenactions
|
||||
|
||||
enum class BottomAction { GoToToken, None }
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ internal data class ChooseTokenFullUM(
|
|||
|
||||
internal data class ChooseTokenInitialUM(
|
||||
val screenTitle: TextReference,
|
||||
val isAppBarShown: Boolean,
|
||||
val onCloseClick: () -> Unit,
|
||||
val searchBar: SearchBarUM,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 -> 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>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ internal class PreviewDetailsComponent : DetailsComponent {
|
|||
},
|
||||
).buildAll(
|
||||
isWalletConnectAvailable = true,
|
||||
isAddressBookAvailable = true,
|
||||
isSupportChatAvailable = true,
|
||||
hasAnyMobileWallet = true,
|
||||
userWalletId = UserWalletId(""),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue