Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-01 20:05:51 +03:00
commit d0b35bc331
680 changed files with 26556 additions and 2709 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal {
val title: TextReference,
val isShowMarketBlock: Boolean,
val isShowPaymentAccount: Boolean,
val isAppBarShown: Boolean = true,
) {
companion object {
val SwapFrom = Settings(
@ -45,6 +46,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal {
title = resourceReference(R.string.swapping_to_title),
isShowMarketBlock = true,
isShowPaymentAccount = false,
isAppBarShown = false,
)
}
}
@ -90,6 +92,11 @@ data class ChooseTokenResult(
val analyticsPayload: Set<ChooseTokenAnalyticsPayload> = emptySet(),
) {
val walletId get() = wallet.walletId
val wasJustAdded: Boolean
get() = analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.IsMarketTokenSelected>()
.any { it.value }
}
sealed interface ChooseTokenAnalyticsPayload {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -78,18 +79,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 +90,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 +119,7 @@ internal class ChooseTokenModel @Inject constructor(
private fun getInitState() = ChooseTokenInitialUM(
screenTitle = bridge.settings.title,
isAppBarShown = bridge.settings.isAppBarShown,
onCloseClick = ::onBackClicked,
searchBar = getInitialSearchBar(),
)

View file

@ -82,12 +82,14 @@ private val ChooseTokenFullUM.isEmptyState: Boolean
internal fun ChooseTokenScreen(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.secondary)
.background(color = TangemTheme.colors2.surface.level2)
.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 +467,7 @@ private val wallets
private val initialUM = ChooseTokenInitialUM(
screenTitle = stringReference("Choose token"),
isAppBarShown = true,
onCloseClick = {},
searchBar = searchBar,
)

View file

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

View file

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

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
package com.tangem.features.commonfeatures.impl.tokenactions.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
@ -12,8 +12,8 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -45,7 +45,9 @@ 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)
},
)
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
@ -55,15 +57,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 +77,18 @@ internal class TokenActionsModel @Inject constructor(
initialValue = null,
)
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) = modelScope.launch {
params.callbacks.onQuickActionClick(handledAction.action)
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (!isReceive) return@launch
val tokenConfig = withContext(dispatchers.default) {
receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
)
} ?: return@launch
bottomSheetNavigation.activate(tokenConfig)
}
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction, shouldDismiss: Boolean) =
modelScope.launch {
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (isReceive) {
val tokenConfig = withContext(dispatchers.default) {
receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
)
}
if (tokenConfig != null) bottomSheetNavigation.activate(tokenConfig)
}
params.callbacks.onQuickActionClick(handledAction.action, shouldDismiss)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model
package com.tangem.features.commonfeatures.impl.userportfolio.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject

View file

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

View file

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

View file

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

View file

@ -190,11 +190,18 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
bottomSheetState = bottomSheetState,
stackState = stackStack,
onHeaderSizeChange = onHeaderSizeChange,
onExpandSheet = onExpandSheet,
onExpandSheet = { onCollapsedSheetClick(onExpandSheet) },
isOpenedInBottomSheet = true,
)
}
private fun onCollapsedSheetClick(onExpandSheet: () -> Unit) {
if (stack.value.active.configuration is FeedEntryChildFactory.Child.Feed) {
clickIntents.openSearch(AnalyticsParam.ScreensSources.Markets.value)
}
onExpandSheet()
}
@Composable
override fun Content(modifier: Modifier) {
val bottomSheetState = remember {

View file

@ -22,11 +22,13 @@ import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import kotlinx.serialization.Serializable
import javax.inject.Inject
@Suppress("LongParameterList")
internal class FeedEntryChildFactory @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val portfolioComponentFactory: MarketsPortfolioComponent.Factory,
private val portfolioBlockComponentFactory: PortfolioBlockComponent.Factory,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
private val addFundsComponentFactory: com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent.Factory,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val designFeatureToggles: DesignFeatureToggles,
) {
@ -81,6 +83,7 @@ internal class FeedEntryChildFactory @Inject constructor(
portfolioBlockComponentFactory = portfolioBlockComponentFactory,
designFeatureToggles = designFeatureToggles,
addToPortfolioComponentFactory = addToPortfolioComponentFactory,
addFundsComponentFactory = addFundsComponentFactory,
)
}
is Child.TokenList -> {

View file

@ -0,0 +1,10 @@
package com.tangem.features.feed.components.market.details
import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.models.currency.CryptoCurrency
import kotlinx.serialization.Serializable
@Serializable
internal data class AddFundsSlotRoute(
val rawCurrencyId: CryptoCurrency.RawID,
) : Route

View file

@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
@ -41,6 +42,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
@ -64,6 +66,7 @@ internal class DefaultMarketsTokenDetailsComponent(
portfolioBlockComponentFactory: PortfolioBlockComponent.Factory,
val params: Params,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
private val addFundsComponentFactory: AddFundsComponent.Factory,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
// applying l2 compatibility
@ -101,6 +104,10 @@ internal class DefaultMarketsTokenDetailsComponent(
override fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID) {
model.openAddToPortfolioViaUserPortfolio()
}
override fun openAddFunds(rawCurrencyId: CryptoCurrency.RawID) {
model.openAddFunds(rawCurrencyId)
}
},
)
} else {
@ -114,6 +121,14 @@ internal class DefaultMarketsTokenDetailsComponent(
childFactory = ::addToPortfolioChild,
)
private val addFundsSlot = childSlot(
source = model.addFundsSheetNavigation,
serializer = AddFundsSlotRoute.serializer(),
key = "addFundsSlot",
handleBackButton = false,
childFactory = ::addFundsChild,
)
init {
componentScope.launch(dispatchers.default) {
model.networksState.collectLatest { state ->
@ -155,6 +170,20 @@ internal class DefaultMarketsTokenDetailsComponent(
)
}
private fun addFundsChild(
config: AddFundsSlotRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent {
val launchMode = AddFundsComponent.LaunchMode.FilteredByRawId(rawCurrencyId = config.rawCurrencyId)
return addFundsComponentFactory.create(
context = childByContext(componentContext),
params = AddFundsComponent.Params(
launchMode = launchMode,
onDismiss = { model.addFundsSheetNavigation.dismiss() },
),
)
}
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
@ -226,6 +255,7 @@ internal class DefaultMarketsTokenDetailsComponent(
}
val state by model.state.collectAsStateWithLifecycle()
val bottomSheet by addToPortfolioSlot.subscribeAsState()
val addFundsBs by addFundsSlot.subscribeAsState()
val bsState by bottomSheetState
LaunchedEffect(bsState) {
model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED
@ -248,6 +278,7 @@ internal class DefaultMarketsTokenDetailsComponent(
},
)
bottomSheet.child?.instance?.BottomSheet()
addFundsBs.child?.instance?.BottomSheet()
}
@Serializable

View file

@ -5,6 +5,8 @@ import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.markets.action.TokenActionsBSContentUM
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -36,6 +38,7 @@ internal class MarketsPortfolioModel @Inject constructor(
private val tokenActionsHandlerFactory: TokenActionsHandler.Factory,
private val receiveAddressesFactory: ReceiveAddressesFactory,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
@ -68,6 +71,17 @@ internal class MarketsPortfolioModel @Inject constructor(
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
.onEach { bottomSheetNavigation.dismiss() }
.launchIn(modelScope)
addToPortfolioManager.onAddedTokenClick.receiveAsFlow()
.onEach { result ->
bottomSheetNavigation.dismiss()
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = result.wallet.walletId,
currency = result.addedCurrency.currency,
),
)
}
.launchIn(modelScope)
}
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
@ -127,7 +141,7 @@ internal class MarketsPortfolioModel @Inject constructor(
private fun createTokenActionsHandler(): TokenActionsHandler {
return tokenActionsHandlerFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
onHandleQuickAction = { handledAction ->
onHandleQuickAction = { handledAction, _ ->
val currency = handledAction.cryptoCurrencyData.status.currency
analyticsEventHandler.send(
analyticsEventBuilder.quickActionClick(

View file

@ -5,4 +5,5 @@ import com.tangem.domain.models.currency.CryptoCurrency
internal interface PortfolioBlockParentClickIntents {
fun openAddToPortfolioDirect()
fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID)
fun openAddFunds(rawCurrencyId: CryptoCurrency.RawID)
}

View file

@ -171,7 +171,7 @@ internal class PortfolioBlockModel @Inject constructor(
tokenSymbol = firstCurrency.symbol,
isBalanceHidden = isBalanceHidden,
onRowClick = { parentRouter?.openAddToPortfolioViaUserPortfolio(currencyRawId) },
onAddFundsClick = {},
onAddFundsClick = { parentRouter?.openAddFunds(currencyRawId) },
)
}
}

View file

@ -48,6 +48,8 @@ import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import com.tangem.features.feed.components.market.details.AddFundsSlotRoute
import com.tangem.features.feed.components.market.details.AddToPortfolioSlotRoute
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.analytics.MarketTokenAnalyticsEvent
@ -233,6 +235,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
val networksState = MutableStateFlow<TokenNetworksState>(TokenNetworksState.Loading)
val addToPortfolioSheetNavigation = SlotNavigation<AddToPortfolioSlotRoute>()
val addFundsSheetNavigation = SlotNavigation<AddFundsSlotRoute>()
private val isAddToPortfolioAvailable: Boolean =
params.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled
@ -345,7 +348,15 @@ internal class MarketsTokenDetailsModel @Inject constructor(
.onEach { addToPortfolioSheetNavigation.dismiss() }
.launchIn(modelScope)
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
.onEach { addToPortfolioSheetNavigation.dismiss() }
.onEach { result ->
addToPortfolioSheetNavigation.dismiss()
val meta = result.meta
if (meta is AddToPortfolioManager.FinishMeta.OnBottomAction &&
meta.action == BottomAction.GoToToken
) {
openTokenDetails(result)
}
}
.launchIn(modelScope)
addToPortfolioManager.onAddedTokenClick.receiveAsFlow()
.onEach { result ->
@ -367,6 +378,10 @@ internal class MarketsTokenDetailsModel @Inject constructor(
addToPortfolioSheetNavigation.activate(AddToPortfolioSlotRoute)
}
fun openAddFunds(rawCurrencyId: com.tangem.domain.models.currency.CryptoCurrency.RawID) {
addFundsSheetNavigation.activate(AddFundsSlotRoute(rawCurrencyId = rawCurrencyId))
}
private fun openTokenDetails(result: AddToPortfolioManager.Result) {
appRouter.push(
AppRoute.CurrencyDetails(

View file

@ -17,8 +17,6 @@ import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.opportunities.OpportunitiesBG
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.*
@ -44,53 +42,52 @@ internal fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier:
@Composable
private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
OpportunitiesBG(
Column(
modifier = modifier
.width(178.dp)
.clip(RoundedCornerShape(TangemTheme.dimens2.x6))
.clickable(onClick = onClick),
icon = TangemIconUM.Currency(item.currencyIconState),
.background(TangemTheme.colors2.surface.level3)
.clickable(onClick = onClick)
.padding(12.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
CurrencyIcon(
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = TangemTheme.dimens2.x4,
iconSize = TangemTheme.dimens2.x10,
networkBadgeBackground = TangemTheme.colors.background.action,
)
CurrencyIcon(
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = TangemTheme.dimens2.x4,
iconSize = TangemTheme.dimens2.x10,
networkBadgeBackground = TangemTheme.colors.background.action,
)
SpacerH(22.dp)
Row(
verticalAlignment = Alignment.Bottom,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography2.bodySemibold16,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography2.captionMedium12,
maxLines = 1,
)
}
SpacerH(2.dp)
SpacerH(22.dp)
Row(
verticalAlignment = Alignment.Bottom,
) {
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors2.text.status.positive,
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography2.bodySemibold16,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography2.captionMedium12,
maxLines = 1,
)
}
SpacerH(2.dp)
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors2.text.status.positive,
style = TangemTheme.typography2.captionMedium12,
maxLines = 1,
)
}
}

View file

@ -17,6 +17,8 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import com.tangem.core.ui.test.MarketsTestTags
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -116,6 +118,7 @@ private fun Content(
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()),
modifier = Modifier.testTag(MarketsTestTags.TOKEN_DETAILS_CONTENT),
) {
item("header") {
Header(state = state)

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -48,6 +49,7 @@ import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -314,13 +316,15 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
tangemIconUM = exchangeItemUM.icon,
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.HEAD)
.size(TangemTheme.dimens2.x10),
.size(TangemTheme.dimens2.x10)
.testTag(TokenElementsTestTags.TOKEN_ICON),
)
Text(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x2)
.layoutId(TangemRowLayoutId.START_TOP),
.layoutId(TangemRowLayoutId.START_TOP)
.testTag(TokenElementsTestTags.TOKEN_TITLE),
text = exchangeItemUM.title.resolveReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
@ -329,7 +333,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
Text(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x2)
.layoutId(TangemRowLayoutId.START_BOTTOM),
.layoutId(TangemRowLayoutId.START_BOTTOM)
.testTag(TokenElementsTestTags.TOKEN_PRICE),
text = exchangeItemUM.subTitle.resolveReference(),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.secondary,
@ -338,7 +343,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
Text(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x2)
.layoutId(TangemRowLayoutId.END_TOP),
.layoutId(TangemRowLayoutId.END_TOP)
.testTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT_TEXT),
text = exchangeItemUM.volumeInUsd.resolveReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
@ -351,7 +357,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
shape = CircleShape,
)
.padding(vertical = 2.dp, horizontal = 6.dp)
.layoutId(TangemRowLayoutId.END_BOTTOM),
.layoutId(TangemRowLayoutId.END_BOTTOM)
.testTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT),
text = exchangeItemUM.auditLabel.text.resolveReference(),
style = TangemTheme.typography2.captionSemibold11,
color = getColorByTrustValue(exchangeItemUM.auditLabel.type),

View file

@ -10,10 +10,9 @@ 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.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -65,7 +64,8 @@ private fun ListedOnBlockV1(state: ListedOnUM, modifier: Modifier = Modifier) {
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.clickable(enabled = state is ListedOnUM.Content) {
(state as? ListedOnUM.Content)?.onClick?.invoke()
},
}
.testTag(MarketsTestTags.LISTED_ON_BLOCK),
) {
Description(
state = state,
@ -89,9 +89,11 @@ private fun ListedOnBlockV1(state: ListedOnUM, modifier: Modifier = Modifier) {
@Composable
private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) {
TokenMarketInformationBlock(
modifier = modifier.clickable(enabled = state is ListedOnUM.Content) {
(state as? ListedOnUM.Content)?.onClick?.invoke()
},
modifier = modifier
.clickable(enabled = state is ListedOnUM.Content) {
(state as? ListedOnUM.Content)?.onClick?.invoke()
}
.testTag(MarketsTestTags.LISTED_ON_BLOCK),
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) {
@ -103,6 +105,7 @@ private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) {
overflow = TextOverflow.Ellipsis,
)
Text(
modifier = Modifier.testTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT),
text = state.description.resolveReference(),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
@ -178,7 +181,7 @@ internal fun ListedOnBlockPlaceholderV2(modifier: Modifier = Modifier) {
private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) {
Text(
text = state.description.resolveReference(),
modifier = modifier.semantics { testTag = MarketsTestTags.LISTED_ON_EXCHANGES_COUNT },
modifier = modifier.testTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT),
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
interface OnrampComponent : ComposableContentComponent {
@ -12,6 +13,7 @@ interface OnrampComponent : ComposableContentComponent {
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val source: OnrampSource,
val initialFiatAmount: BigDecimal? = null,
)
interface Factory : ComponentFactory<Params, OnrampComponent>

View file

@ -6,6 +6,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampSource
import java.math.BigDecimal
internal interface OnrampMainComponent : ComposableContentComponent {
@ -15,6 +16,7 @@ internal interface OnrampMainComponent : ComposableContentComponent {
val source: OnrampSource,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val initialFiatAmount: BigDecimal? = null,
)
interface Factory : ComponentFactory<Params, OnrampMainComponent>

View file

@ -51,7 +51,7 @@ internal class OnrampStateFactory(
)
}
fun getReadyState(currency: OnrampCurrency): OnrampMainComponentUM.Content {
fun getReadyState(currency: OnrampCurrency, initialFiatAmount: BigDecimal? = null): OnrampMainComponentUM.Content {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
@ -59,7 +59,7 @@ internal class OnrampStateFactory(
is TopAppBarButtonUM.Text -> button.copy(isEnabled = true)
}
val initialAmountBlockState = getInitialAmountBlockState(currency)
val initialAmountBlockState = getInitialAmountBlockState(currency, initialFiatAmount)
return OnrampMainComponentUM.Content(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
@ -136,7 +136,10 @@ internal class OnrampStateFactory(
)
}
private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampAmountBlockUM {
private fun getInitialAmountBlockState(
currency: OnrampCurrency,
initialFiatAmount: BigDecimal? = null,
): OnrampAmountBlockUM {
return OnrampAmountBlockUM(
currencyUM = OnrampCurrencyUM(
code = currency.code,
@ -146,8 +149,8 @@ internal class OnrampStateFactory(
unit = currency.unit,
),
amountFieldModel = AmountFieldModel(
value = "",
fiatValue = "",
value = initialFiatAmount?.toPlainString().orEmpty(),
fiatValue = initialFiatAmount?.toPlainString().orEmpty(),
onValueChange = onrampIntents::onAmountValueChanged,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
@ -156,7 +159,7 @@ internal class OnrampStateFactory(
keyboardActions = KeyboardActions(),
isFiatValue = true,
cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrency),
fiatAmount = BigDecimal.ZERO.convertToFiatAmount(currency),
fiatAmount = (initialFiatAmount ?: BigDecimal.ZERO).convertToFiatAmount(currency),
isError = false,
isWarning = false,
error = TextReference.EMPTY,

View file

@ -274,7 +274,7 @@ internal class OnrampMainComponentModel @Inject constructor(
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
is OnrampMainComponentUM.InitialLoading -> {
stateFactory.getReadyState(country.defaultCurrency)
stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount)
}
}
}

View file

@ -82,6 +82,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
),
)
},
initialFiatAmount = params.initialFiatAmount,
),
)
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(

View file

@ -1,5 +1,6 @@
package com.tangem.features.promobanners.impl.model
import androidx.core.net.toUri
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
@ -118,7 +119,18 @@ internal class PromoBannersBlockModel @Inject constructor(
private fun onButtonClick(displayId: Int, deeplink: String?) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName))
deeplink?.let { deeplinkLauncher.launch(it) }
deeplink?.let { deeplinkLauncher.launch(appendSurveyDisplayId(it, displayId)) }
}
private fun appendSurveyDisplayId(deeplink: String, displayId: Int): String {
val uri = deeplink.toUri()
val isSurveyDeeplink = uri.scheme == DEEPLINK_SCHEME_TANGEM && uri.host == DEEPLINK_HOST_SURVEY
if (!isSurveyDeeplink || uri.getQueryParameter(QUERY_DISPLAY_ID) != null) return deeplink
return uri.buildUpon()
.appendQueryParameter(QUERY_DISPLAY_ID, displayId.toString())
.build()
.toString()
}
private fun getInitialState() = PromoBannersBlockUM(
@ -152,4 +164,10 @@ internal class PromoBannersBlockModel @Inject constructor(
}
}
}
private companion object {
const val DEEPLINK_SCHEME_TANGEM = "tangem"
const val DEEPLINK_HOST_SURVEY = "survey"
const val QUERY_DISPLAY_ID = "display_id"
}
}

View file

@ -0,0 +1,14 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.survey.api"
}
dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
}

View file

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

View file

@ -0,0 +1,6 @@
package com.tangem.features.survey
interface SurveyFeatureToggles {
val areSurveysEnabled: Boolean
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.survey
import android.app.Activity
interface SurveySparrowLauncher {
fun present(activity: Activity, data: SurveyLaunchData)
}
data class SurveyLaunchData(
val domain: String,
val token: String,
val customParams: Map<String, String>,
)

View file

@ -0,0 +1,8 @@
package com.tangem.features.survey.deeplink
interface SurveyDeepLinkHandler {
interface Factory {
fun create(queryParams: Map<String, String>): SurveyDeepLinkHandler
}
}

View file

@ -0,0 +1,61 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.survey.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/* Project - API */
implementation(projects.features.survey.api)
/* Domain */
implementation(projects.domain.common)
implementation(projects.domain.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/* Core */
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.utils)
/* Common */
implementation(projects.common.routing)
/* DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/* Compose */
implementation(deps.compose.runtime)
implementation(deps.compose.ui)
/* Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
/** Tangem libraries */
implementation(tangemDeps.card.core)
implementation(deps.surveysparrow)
/** Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -0,0 +1,73 @@
package com.tangem.features.survey.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.survey.SurveyComponent
import com.tangem.features.survey.SurveyLaunchData
import com.tangem.features.survey.SurveySparrowLauncher
import com.tangem.features.survey.impl.service.SurveyCustomParamsBuilder
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class DefaultSurveyComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SurveyComponent.Params,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val customParamsBuilder: SurveyCustomParamsBuilder,
private val surveySparrowLauncher: SurveySparrowLauncher,
@Suppress("UnusedPrivateProperty") // TODO([REDACTED_TASK_KEY]): emit [Survey] analytics events
private val analyticsEventHandler: AnalyticsEventHandler,
) : SurveyComponent, AppComponentContext by appComponentContext {
init {
// componentScope runs on mainImmediate, so presenting the SDK is already on the main thread.
componentScope.launch {
val launchData = buildLaunchData()
if (launchData != null) {
surveySparrowLauncher.present(activity, launchData)
// TODO([REDACTED_TASK_KEY]): analyticsEventHandler.send(SurveyAnalyticsEvent.Shown(...))
}
router.pop()
}
}
private suspend fun buildLaunchData(): SurveyLaunchData? {
return getSelectedWalletSyncUseCase().fold(
ifLeft = { error ->
TangemLogger.e("$TAG: survey skipped, no available wallet ($error)")
null
},
ifRight = { userWallet ->
SurveyLaunchData(
domain = SURVEY_DOMAIN,
token = params.token,
customParams = customParamsBuilder.build(
userWallet = userWallet,
token = params.token,
displayId = params.displayId,
),
)
},
)
}
@Composable
override fun Content(modifier: Modifier) = Unit
@AssistedFactory
interface Factory : SurveyComponent.Factory {
override fun create(context: AppComponentContext, params: SurveyComponent.Params): DefaultSurveyComponent
}
private companion object {
const val TAG = "SurveyComponent"
const val SURVEY_DOMAIN = "tangem.surveysparrow.com"
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.survey.impl
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.survey.SurveyFeatureToggles
import javax.inject.Inject
internal class DefaultSurveyFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : SurveyFeatureToggles {
override val areSurveysEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15482_SURVEYSPARROW_ENABLED)
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.survey.impl
import android.app.Activity
import com.surveysparrow.ss_android_sdk.SsSurvey
import com.surveysparrow.ss_android_sdk.SurveySparrow
import com.tangem.features.survey.SurveyLaunchData
import com.tangem.features.survey.SurveySparrowLauncher
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
internal class DefaultSurveySparrowLauncher @Inject constructor() : SurveySparrowLauncher {
override fun present(activity: Activity, data: SurveyLaunchData) {
if (activity.isFinishing || activity.isDestroyed) {
TangemLogger.e("$TAG: cannot present survey, activity is finishing/destroyed")
return
}
val survey = try {
SsSurvey(data.domain, data.token).apply {
setSurveyType(SurveySparrow.CLASSIC)
data.customParams.forEach { (key, value) -> addCustomParam(key, value) }
}
} catch (e: Exception) {
TangemLogger.e("$TAG: failed to create SurveySparrow survey", e)
return
}
// Result handling (onActivityResult -> [Survey] Completed/Dismissed) is planned in [REDACTED_TASK_KEY]
SurveySparrow(activity, survey).startSurveyForResult(SURVEY_REQUEST_CODE)
TangemLogger.d("$TAG: survey started (requestCode=$SURVEY_REQUEST_CODE)")
}
private companion object {
const val TAG = "SurveySparrowPresenter"
const val SURVEY_REQUEST_CODE = 1001
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.features.survey.impl.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.survey.SurveyFeatureToggles
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultSurveyDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
private val surveyFeatureToggles: SurveyFeatureToggles,
private val appRouter: AppRouter,
) : SurveyDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
if (!surveyFeatureToggles.areSurveysEnabled) {
TangemLogger.i("$TAG: survey deeplink ignored, feature is disabled")
return
}
val token = queryParams[QUERY_TOKEN]?.takeIf { it.isNotBlank() }
if (token == null) {
TangemLogger.e("$TAG: survey deeplink ignored, missing 'token' query param")
return
}
appRouter.push(AppRoute.Survey(token = token, displayId = queryParams[QUERY_DISPLAY_ID]))
}
@AssistedFactory
interface Factory : SurveyDeepLinkHandler.Factory {
override fun create(queryParams: Map<String, String>): DefaultSurveyDeepLinkHandler
}
private companion object {
const val TAG = "SurveyDeepLink"
const val QUERY_TOKEN = "token"
const val QUERY_DISPLAY_ID = "display_id"
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.survey.impl.di
import com.tangem.features.survey.SurveyComponent
import com.tangem.features.survey.SurveyFeatureToggles
import com.tangem.features.survey.SurveySparrowLauncher
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.survey.impl.DefaultSurveyComponent
import com.tangem.features.survey.impl.DefaultSurveyFeatureToggles
import com.tangem.features.survey.impl.DefaultSurveySparrowLauncher
import com.tangem.features.survey.impl.deeplink.DefaultSurveyDeepLinkHandler
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 SurveyModule {
@Binds
@Singleton
fun bindSurveyFeatureToggles(impl: DefaultSurveyFeatureToggles): SurveyFeatureToggles
@Binds
@Singleton
fun bindSurveySparrowLauncher(impl: DefaultSurveySparrowLauncher): SurveySparrowLauncher
@Binds
@Singleton
fun bindSurveyComponentFactory(impl: DefaultSurveyComponent.Factory): SurveyComponent.Factory
@Binds
@Singleton
fun bindSurveyDeepLinkHandlerFactory(impl: DefaultSurveyDeepLinkHandler.Factory): SurveyDeepLinkHandler.Factory
}

View file

@ -0,0 +1,48 @@
package com.tangem.features.survey.impl.service
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.AppInstanceIdProvider
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.SupportedLanguages
import com.tangem.utils.info.AppInfoProvider
import javax.inject.Inject
internal class SurveyCustomParamsBuilder @Inject constructor(
private val appInstanceIdProvider: AppInstanceIdProvider,
private val appInfoProvider: AppInfoProvider,
) {
suspend fun build(userWallet: UserWallet, token: String, displayId: String?): Map<String, String> {
return buildMap {
put(KEY_SURVEY_KEY, token)
put(KEY_WALLET_ID, hashWalletId(userWallet))
WalletType.from(userWallet)?.let { put(KEY_WALLET_TYPE, it.name.lowercase()) }
displayId?.takeIf { it.isNotBlank() }?.let { put(KEY_DISPLAY_ID, it) }
appInstanceIdProvider.getAppInstanceId()?.let { put(KEY_DEVICE_ID, it) }
put(KEY_PLATFORM, appInfoProvider.platform.lowercase())
put(KEY_APP_VERSION, appInfoProvider.appVersion)
put(KEY_LANGUAGE, SupportedLanguages.getCurrentSupportedLanguageCode())
}
}
private fun hashWalletId(userWallet: UserWallet): String {
return userWallet.walletId.stringValue
.hexToBytes()
.calculateSha256()
.toHexString()
}
private companion object {
const val KEY_SURVEY_KEY = "survey_key"
const val KEY_WALLET_ID = "wallet_id"
const val KEY_WALLET_TYPE = "wallet_type"
const val KEY_DISPLAY_ID = "display_id"
const val KEY_DEVICE_ID = "device_id"
const val KEY_PLATFORM = "platform"
const val KEY_APP_VERSION = "app_version"
const val KEY_LANGUAGE = "language"
}
}

View file

@ -0,0 +1,81 @@
package com.tangem.features.survey.impl.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.survey.SurveyFeatureToggles
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import com.tangem.utils.logging.TangemLogger
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class DefaultSurveyDeepLinkHandlerTest {
private val featureToggles = mockk<SurveyFeatureToggles>()
private val appRouter = mockk<AppRouter>(relaxed = true)
@BeforeEach
fun setup() {
mockkObject(TangemLogger)
every { TangemLogger.i(any()) } just Runs
every { TangemLogger.e(any()) } just Runs
}
@AfterEach
fun tearDown() {
unmockkObject(TangemLogger)
}
@Test
fun `does not navigate when feature is disabled`() {
every { featureToggles.areSurveysEnabled } returns false
createHandler(mapOf("token" to TOKEN, "display_id" to DISPLAY_ID))
verify(exactly = 0) { appRouter.push(any(), any()) }
}
@Test
fun `does not navigate when token is missing`() {
every { featureToggles.areSurveysEnabled } returns true
createHandler(emptyMap())
verify(exactly = 0) { appRouter.push(any(), any()) }
}
@Test
fun `pushes survey route with token and display id on happy path`() {
every { featureToggles.areSurveysEnabled } returns true
createHandler(mapOf("token" to TOKEN, "display_id" to DISPLAY_ID))
verify { appRouter.push(route = AppRoute.Survey(token = TOKEN, displayId = DISPLAY_ID), onComplete = any()) }
}
@Test
fun `pushes survey route with null display id when absent`() {
every { featureToggles.areSurveysEnabled } returns true
createHandler(mapOf("token" to TOKEN))
verify { appRouter.push(route = AppRoute.Survey(token = TOKEN, displayId = null), onComplete = any()) }
}
private fun createHandler(queryParams: Map<String, String>) = DefaultSurveyDeepLinkHandler(
queryParams = queryParams,
surveyFeatureToggles = featureToggles,
appRouter = appRouter,
)
private companion object {
const val TOKEN = "ntt-84iF22PDajmervYneMW4kv"
const val DISPLAY_ID = "42"
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.features.survey.impl.service
import com.google.common.truth.Truth.assertThat
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.AppInstanceIdProvider
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.info.AppInfoProvider
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.Locale
internal class SurveyCustomParamsBuilderTest {
private val appInstanceIdProvider = mockk<AppInstanceIdProvider>()
private val appInfoProvider = mockk<AppInfoProvider>()
private val builder = SurveyCustomParamsBuilder(
appInstanceIdProvider = appInstanceIdProvider,
appInfoProvider = appInfoProvider,
)
@BeforeEach
fun setup() {
Locale.setDefault(Locale.ENGLISH)
every { appInfoProvider.platform } returns "Android"
every { appInfoProvider.appVersion } returns "5.40"
coEvery { appInstanceIdProvider.getAppInstanceId() } returns "device-123"
}
@Test
fun `builds all params for a cold wallet`() = runTest {
val wallet = coldWallet(WALLET_ID_HEX)
val params = builder.build(userWallet = wallet, token = TOKEN, displayId = "42")
assertThat(params).containsExactlyEntriesIn(
mapOf(
"survey_key" to TOKEN,
"wallet_id" to expectedWalletIdHash(WALLET_ID_HEX),
"wallet_type" to "cold",
"display_id" to "42",
"device_id" to "device-123",
"platform" to "android",
"app_version" to "5.40",
"language" to "en",
),
)
}
@Test
fun `wallet_id hash is uppercase hex`() = runTest {
val params = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = null)
val walletId = params.getValue("wallet_id")
assertThat(walletId).isEqualTo(walletId.uppercase())
assertThat(walletId).matches("[0-9A-F]+")
}
@Test
fun `wallet_type is hot for a hot wallet`() = runTest {
val wallet = mockk<UserWallet.Hot> { every { walletId } returns UserWalletId(WALLET_ID_HEX) }
val params = builder.build(userWallet = wallet, token = TOKEN, displayId = null)
assertThat(params["wallet_type"]).isEqualTo("hot")
}
@Test
fun `device_id is omitted when app instance id is null`() = runTest {
coEvery { appInstanceIdProvider.getAppInstanceId() } returns null
val params = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = "42")
assertThat(params).doesNotContainKey("device_id")
}
@Test
fun `display_id is omitted when null or blank`() = runTest {
val nullCase = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = null)
val blankCase = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = " ")
assertThat(nullCase).doesNotContainKey("display_id")
assertThat(blankCase).doesNotContainKey("display_id")
}
private fun coldWallet(walletIdHex: String): UserWallet.Cold = mockk {
every { walletId } returns UserWalletId(walletIdHex)
}
private fun expectedWalletIdHash(walletIdHex: String): String =
walletIdHex.hexToBytes().calculateSha256().toHexString()
private companion object {
const val TOKEN = "ntt-84iF22PDajmervYneMW4kv"
const val WALLET_ID_HEX = "0123456789ABCDEF"
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.swap
interface SwapFeatureToggles {
val isYieldSwapEnabled: Boolean
val isSwapSwitchToTransferEnabled: Boolean
val isSwapIntegratedApproveEnabled: Boolean
val isSwapAbEnabled: Boolean

View file

@ -51,12 +51,18 @@ dependencies {
implementation(projects.domain.visa)
implementation(projects.domain.visa.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.yieldSupply)
/** Common modules */
implementation(projects.common.ui)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.utils)
implementation(projects.core.ui)
implementation(projects.core.datasource)
implementation(projects.core.abTests)
implementation(projects.core.error)
/** Feature Apis */
implementation(projects.features.wallet.api)

View file

@ -16,6 +16,7 @@ import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldS
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
@ -51,6 +52,7 @@ import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
@ -61,6 +63,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger
@ -99,12 +102,17 @@ internal class SwapInteractorImpl @Inject constructor(
private val getSwapPairUseCase: GetSwapPairUseCase,
private val dexSwapFeeCalculator: DexSwapFeeCalculator,
private val cexSwapFeeCalculator: CexSwapFeeCalculator,
private val swapFeatureToggles: SwapFeatureToggles,
private val yieldModuleAddressProvider: YieldModuleAddressProvider,
) : SwapInteractor {
private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) {
GetSelectedAppCurrencyUseCase(appCurrencyRepository)
}
private val SwapCurrencyStatus.isYieldSwapActive: Boolean
get() = swapFeatureToggles.isYieldSwapEnabled && isYieldSupplyActive
override suspend fun getPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -245,7 +253,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
private suspend fun manageDex(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -254,7 +262,9 @@ internal class SwapInteractorImpl @Inject constructor(
reduceBalanceBy: BigDecimal,
expressOperationType: ExpressOperationType,
): Pair<SwapProvider, SwapState> {
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) {
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true &&
!swapFeatureToggles.isYieldSwapEnabled
) {
return provider to produceDexSwapDataError(
error = ExpressDataError.DexActiveSupplyError(),
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -262,7 +272,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
val maybeQuotes = repository.findBestQuote(
val maybeQuote = repository.findBestQuote(
userWallet = fromSwapCurrencyStatus.userWallet,
fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(),
fromNetwork = fromSwapCurrencyStatus.currency.network.rawId,
@ -275,7 +285,7 @@ internal class SwapInteractorImpl @Inject constructor(
rateType = RateType.FLOAT,
)
if (maybeQuotes.getOrNull()?.txType == ExpressTxType.SEND) {
if (maybeQuote.getOrNull()?.txType == ExpressTxType.SEND) {
return manageCex(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -286,21 +296,32 @@ internal class SwapInteractorImpl @Inject constructor(
}
val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency)
val isAllowedToSpend = maybeQuotes.fold(
ifRight = { quotes ->
quotes.allowanceContract?.let { allowanceContract ->
getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrency = fromSwapCurrencyStatus.currency,
spenderAddress = allowanceContract,
requiredAmount = amount.value,
).getOrNull() is AllowanceInfo.Enough
} != false
},
ifLeft = { false },
)
if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) {
// TODO CHECK YIELD APPROVE
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive &&
fromSwapCurrencyStatus.currency is CryptoCurrency.Token
val spenderAddress = if (isYieldSwap) {
yieldModuleAddressProvider.getOrFetch(
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = fromSwapCurrencyStatus.currency.network,
)
} else {
maybeQuote.getOrNull()?.allowanceContract
}
val allowanceInfo = spenderAddress?.let { allowanceContract ->
getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrency = fromSwapCurrencyStatus.currency,
spenderAddress = allowanceContract,
requiredAmount = amount.value,
).getOrNull()
} ?: AllowanceInfo.Enough(allowance = BigDecimal.ZERO)
if (allowanceInfo is AllowanceInfo.Enough &&
allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)
) {
allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress)
cryptoCurrencyBalanceFetcher(
userWalletId = fromSwapCurrencyStatus.userWalletId,
@ -308,6 +329,21 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null)
val isIntegratedApproveActive = swapFeatureToggles.isSwapIntegratedApproveEnabled
val isAllowanceSatisfied = if (isIntegratedApproveActive) {
allowanceInfo !is AllowanceInfo.ResetNeeded
} else {
allowanceInfo is AllowanceInfo.Enough
}
// For yield swaps the on-chain allowance is not sufficient on its own: spending also
// requires the yield-module proxy approval (yieldSupplyStatus.isAllowedToSpend).
// For regular swaps a failed quote must not proceed to exchange-data loading.
val isAllowedToSpend = if (isYieldSwap) {
isAllowanceSatisfied &&
fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isAllowedToSpend == true
} else {
isAllowanceSatisfied && maybeQuote.isRight()
}
return if (isAllowedToSpend && isBalanceWithoutFeeEnough) {
provider to loadDexSwapDataNoFee(
provider = provider,
@ -315,6 +351,8 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
amount = amount,
expressOperationType = expressOperationType,
allowanceInfo = allowanceInfo,
spenderAddress = spenderAddress,
)
} else {
val quoteBalanceStatus = if (isBalanceWithoutFeeEnough) {
@ -324,7 +362,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
provider to getQuotesState(
provider = provider,
quoteDataModel = maybeQuotes,
quoteDataModel = maybeQuote,
amount = amount,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -377,6 +415,8 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
amount = amount,
expressOperationType = expressOperationType,
allowanceInfo = null,
spenderAddress = null,
)
} else {
provider to getQuotesState(
@ -635,28 +675,54 @@ internal class SwapInteractorImpl @Inject constructor(
swapFee: SwapFee,
): SwapTransactionState {
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" }
val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX
val dataToSign = dexTransaction.txData
val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network)
val txData = createTransactionUseCase(
amount = amountToSend,
fee = swapFee.fee,
memo = null,
destination = swapData.transaction.txTo,
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = toSwapCurrencyStatus.currency.network,
txExtras = createDexTxExtras(
dataToSign,
fromSwapCurrencyStatus.currency.network,
swapFee.fee.getGasLimit(),
),
).getOrElse { error ->
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive
val fromCurrency = fromSwapCurrencyStatus.currency
val txDataResult = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
val spenderAddress = dexTransaction.allowanceContract
?: return SwapTransactionState.Error.UnknownError
createYieldSwapDexTransaction(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
swapData = swapData,
dexCallData = dataToSign,
amount = amountDecimal,
fee = swapFee.fee,
spenderAddress = spenderAddress,
)
} else {
val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" }
val amountToSend = createNativeAmountForDex(txValue, fromCurrency.network)
createTransactionUseCase(
amount = amountToSend,
fee = swapFee.fee,
memo = null,
destination = swapData.transaction.txTo,
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = fromCurrency.network,
txExtras = createDexTxExtras(
dataToSign,
fromCurrency.network,
swapFee.fee.getGasLimit(),
),
)
}
val txData = txDataResult.getOrElse { error ->
TangemLogger.e("Failed to create swap dex tx data", error)
return SwapTransactionState.Error.UnknownError
}
val payInAddress = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
swapData.transaction.txTo
} else if (txData is TransactionData.Uncompiled) {
getPayoutAddress(txData)
} else {
swapData.transaction.txTo
}
return handleSwapResult(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -664,7 +730,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = swapData,
amount = amount,
txData = txData,
payInAddress = getPayoutAddress(txData),
payInAddress = payInAddress,
)
}
@ -1003,11 +1069,23 @@ internal class SwapInteractorImpl @Inject constructor(
val transaction = swapData?.transaction as? ExpressTransactionModel.DEX
?: return GetFeeError.UnknownError.left()
return dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
selectedToken = selectedFeeToken,
).fold(
val dexFeeResultEither = if (fromStatus.isYieldSwapActive && fromStatus.currency is CryptoCurrency.Token) {
val network = (fromStatus.currency as CryptoCurrency.Token).network
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromStatus.userWalletId, network)
dexSwapFeeCalculator.calculateYield(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
yieldModuleAddress = yieldModuleAddress,
)
} else {
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
selectedToken = selectedFeeToken,
)
}
return dexFeeResultEither.fold(
ifLeft = { error -> GetFeeError.DataError(error).left() },
ifRight = { dexFeeResult ->
val feeToken = selectedFeeToken
@ -1023,6 +1101,42 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private suspend fun createYieldSwapDexTransaction(
fromSwapCurrencyStatus: SwapCurrencyStatus,
swapData: SwapDataModel,
dexCallData: String,
amount: BigDecimal,
fee: Fee,
spenderAddress: String,
): Either<Throwable, TransactionData> {
val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token
val network = fromCurrency.network
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, network)
?: return Either.Left(IllegalStateException("Yield module address is not available for ${network.id}"))
val wrappedCallData = dexSwapFeeCalculator.buildYieldSwapCallData(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
txTo = swapData.transaction.txTo,
dexCallData = dexCallData,
amount = amount,
spenderAddress = spenderAddress,
)
val txExtras = createTransactionExtrasUseCase(
callData = wrappedCallData,
network = network,
gasLimit = fee.getGasLimit()?.toBigInteger(),
).getOrNull() ?: error("Failed to create yield swap extras")
return createTransactionUseCase(
amount = createNativeAmountForDex("0", network),
fee = fee,
memo = null,
destination = yieldModuleAddress,
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = network,
txExtras = txExtras,
)
}
/**
* [REDACTED_TASK_KEY] CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when
* [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator])
@ -1505,6 +1619,8 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
amount: SwapAmount,
expressOperationType: ExpressOperationType,
allowanceInfo: AllowanceInfo?,
spenderAddress: String?,
): SwapState {
val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress
val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty()
@ -1525,7 +1641,14 @@ internal class SwapInteractorImpl @Inject constructor(
toAddress = dexToAddress,
refundAddress = fromNetworkAddress?.defaultAddress?.value,
expressOperationType = expressOperationType,
).fold(
).map { swapData ->
val dexTx = swapData.transaction as? ExpressTransactionModel.DEX
if (dexTx != null && spenderAddress != null && dexTx.allowanceContract == null) {
swapData.copy(transaction = dexTx.copy(allowanceContract = spenderAddress))
} else {
swapData
}
}.fold(
ifRight = { swapData ->
val preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending,
@ -1539,8 +1662,24 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = swapData,
provider = provider,
)
val isIntegratedApprovalNeeded = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
allowanceInfo is AllowanceInfo.NotEnough
swapState.copy(
permissionState = PermissionDataState.Empty,
permissionState = if (isIntegratedApprovalNeeded) {
PermissionDataState.PermissionSettings(
type = ApproveType.LIMITED,
spenderAddress = spenderAddress.orEmpty(),
)
} else if (allowanceInfo is AllowanceInfo.NotEnough) {
// Integrated estimation failed earlier this session — show the legacy
// separate-approval UI so the user approves before swapping.
PermissionDataState.PermissionRequired(
isResetApproval = false,
spenderAddress = spenderAddress.orEmpty(),
)
} else {
PermissionDataState.Empty
},
currencyCheck = manageWarnings(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
amount = amount,
@ -1644,18 +1783,42 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive && fromToken is CryptoCurrency.Token
val spenderAddress = if (isYieldSwap) {
yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, fromToken.network)
?: run {
TangemLogger.e(
"Yield-swap approval skipped: yield-module address unresolved for " +
"walletId=${fromSwapCurrencyStatus.userWalletId} network=${fromToken.network.rawId}",
)
return quotesLoadedState.copy(permissionState = PermissionDataState.Empty)
}
} else {
requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" }
}
val allowanceInfo = getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrency = fromToken,
spenderAddress = requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" },
spenderAddress = spenderAddress,
requiredAmount = swapAmount.value,
).getOrNull()
).getOrNull() ?: return quotesLoadedState.copy(permissionState = PermissionDataState.Empty)
val isIntegratedApprovalNeeded = swapFeatureToggles.isSwapIntegratedApproveEnabled &&
allowanceInfo is AllowanceInfo.NotEnough
return quotesLoadedState.copy(
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = allowanceInfo is AllowanceInfo.ResetNeeded,
spenderAddress = quoteModel.allowanceContract,
),
permissionState = if (isIntegratedApprovalNeeded) {
PermissionDataState.PermissionSettings(
type = ApproveType.LIMITED,
spenderAddress = spenderAddress,
)
} else {
PermissionDataState.PermissionRequired(
isResetApproval = allowanceInfo is AllowanceInfo.ResetNeeded,
spenderAddress = spenderAddress,
)
},
)
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseC
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.*
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.api.SwapRepository
@ -75,6 +76,7 @@ internal class SwapDomainModule {
createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
walletManagersFacade: WalletManagersFacade,
@SwapDexGasLimit patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase,
): DexSwapFeeCalculator = DexSwapFeeCalculator(
getFeeUseCase = getFeeUseCase,
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
@ -82,6 +84,7 @@ internal class SwapDomainModule {
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
walletManagersFacade = walletManagersFacade,
patchEthGasLimitForSwap = patchEthGasLimitForSwap,
wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase,
)
@Provides

View file

@ -7,8 +7,13 @@ import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySwapCallData
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.extensions.hexToBytes
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -19,12 +24,14 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import java.math.BigInteger
/**
* Calculates the on-chain transaction fee for a DEX swap.
@ -52,6 +59,7 @@ class DexSwapFeeCalculator(
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
private val walletManagersFacade: WalletManagersFacade,
private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase,
) {
suspend fun calculate(
@ -115,6 +123,134 @@ class DexSwapFeeCalculator(
}
}
/**
* Yield-mode DEX fee path: routes the swap through the user's yield module proxy.
*
* Native fee is computed for a [TransactionData.Uncompiled] addressed to [yieldModuleAddress],
* carrying the wrapped call data produced by [buildYieldSwapCallData]. The 12% gas-limit bump
* is applied to match the non-yield DEX flow.
*
* Fallback to [GetEthSpecificFeeUseCase] (with the gas limit carried by the Express transaction
* model) is applied in two cases:
* - [yieldModuleAddress] is `null` yield module address could not be resolved upstream;
* - the fee estimation call throws `IllegalStateException` (e.g. payload too large).
*
* Yield-module errors ([YieldModuleUpgradeUnavailableException],
* [YieldModuleVersionIndeterminateException]) are mapped to [ExpressDataError.UnknownError]
* to keep the unified error surface a single type.
*/
suspend fun calculateYield(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
yieldModuleAddress: String?,
): Either<ExpressDataError, DexFeeResult> = either {
val fromCurrency = fromSwapCurrencyStatus.currency as? CryptoCurrency.Token
?: raise(ExpressDataError.UnknownError())
val network = fromCurrency.network
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
userWalletId = fromSwapCurrencyStatus.userWalletId,
networkId = network.rawId,
derivationPath = network.derivationPath.value,
)
if (nativeBalance.signum() == 0) raise(ExpressDataError.UnknownError())
if (yieldModuleAddress == null) {
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
}
val spenderAddress = transaction.allowanceContract
?: raise(ExpressDataError.UnknownError())
val rawFee = try {
val wrappedCallData = buildYieldSwapCallData(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
txTo = transaction.txTo,
dexCallData = transaction.txData,
amount = transaction.fromAmount.value,
spenderAddress = spenderAddress,
)
val extras = createTransactionExtrasUseCase(
callData = wrappedCallData,
network = network,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
val transactionData = TransactionData.Uncompiled(
amount = createNativeAmountForDex("0", network),
destinationAddress = yieldModuleAddress,
fee = null,
sourceAddress = transaction.txFrom,
extras = extras,
)
getFeeUseCase(
transactionData = transactionData,
network = network,
userWallet = fromSwapCurrencyStatus.userWallet,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
} catch (_: YieldModuleUpgradeUnavailableException) {
raise(ExpressDataError.UnknownError())
} catch (_: YieldModuleVersionIndeterminateException) {
raise(ExpressDataError.UnknownError())
} catch (_: IllegalStateException) {
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
}
val patched = patchEthGasLimitForSwap(rawFee)
DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(patched),
otherNativeFee = BigDecimal.ZERO,
gas = transaction.gas,
)
}
/**
* Wraps a DEX call data into a yield-supply swap call data, ready to be sent through the
* user's yield module. Shared with [SwapInteractorImpl.createYieldSwapDexTransaction], which
* is why this helper is exposed at the calculator level rather than kept private.
*/
suspend fun buildYieldSwapCallData(
fromSwapCurrencyStatus: SwapCurrencyStatus,
txTo: String,
dexCallData: String,
amount: BigDecimal,
spenderAddress: String,
): SmartContractCallData {
val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token
val amountInWei = amount.movePointRight(fromCurrency.decimals).toBigInteger()
val dexCallDataBytes = dexCallData.removePrefix("0x").hexToBytes()
val swapCallData = EthereumYieldSupplySwapCallData(
tokenIn = fromCurrency.contractAddress,
amountIn = amountInWei,
target = txTo,
spender = spenderAddress,
swapData = dexCallDataBytes,
)
return wrapYieldSwapCallDataWithUpgradeUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = fromCurrency.network,
callData = swapCallData,
)
}
private suspend fun ethSpecificFeeFallback(
fromSwapCurrencyStatus: SwapCurrencyStatus,
gasLimit: BigInteger,
): Either<ExpressDataError, DexFeeResult> = either {
val fee = getEthSpecificFeeUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
gasLimit = gasLimit,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
val patched = patchEthGasLimitForSwap(fee)
DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(patched),
otherNativeFee = BigDecimal.ZERO,
gas = gasLimit,
)
}
@Suppress("CyclomaticComplexMethod")
private suspend fun getFeeDataForDexSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -30,7 +30,7 @@ sealed class ExpressTransactionModel {
val txData: String,
val otherNativeFeeWei: BigDecimal?,
val gas: BigInteger?,
val allowanceContract: String?,
val allowanceContract: String? = null,
) : ExpressTransactionModel()
data class CEX(

View file

@ -1,11 +1,13 @@
package com.tangem.feature.swap.domain.models.ui
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
@ -36,6 +38,7 @@ sealed interface SwapState {
val userWallet: UserWallet,
val fromTokenInfo: TokenSwapInfo,
val toTokenInfo: TokenSwapInfo,
val cryptoCurrencyWarning: CryptoCurrencyWarning?,
val isInsufficientBalance: Boolean,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
@ -102,6 +105,11 @@ sealed class PermissionDataState {
val spenderAddress: String,
) : PermissionDataState()
data class PermissionSettings(
val type: ApproveType,
val spenderAddress: String,
) : PermissionDataState()
object PermissionLoading : PermissionDataState()
object Empty : PermissionDataState()

View file

@ -5,6 +5,8 @@ import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
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.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
@ -28,13 +30,14 @@ interface SwapTransferInteractor {
suspend fun loadFee(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
fromTokenAmount: BigDecimal,
): Either<GetFeeError, TransactionFee>
suspend fun loadFeeExtended(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
fromTokenAmount: BigDecimal,
selectedToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended>
suspend fun sendTransfer(
@ -44,4 +47,10 @@ interface SwapTransferInteractor {
fee: Fee,
transactionFeeResult: TransactionFeeResult,
): Either<SendTransactionError, String>
suspend fun withdrawTangemPay(
userWallet: UserWallet,
cryptoAmount: BigDecimal,
toSwapCurrencyStatus: SwapCurrencyStatus,
): Either<SendTransactionError, WithdrawalResult>
}

View file

@ -19,10 +19,14 @@ 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.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -44,7 +48,7 @@ import kotlinx.coroutines.flow.first
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
class SwapTransferInteractorImpl @Inject constructor(
private val swapFeatureToggles: SwapFeatureToggles,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
@ -57,6 +61,8 @@ class SwapTransferInteractorImpl @Inject constructor(
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase,
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
) : SwapTransferInteractor {
override suspend fun updateTransfer(
@ -107,10 +113,19 @@ class SwapTransferInteractorImpl @Inject constructor(
fee = fee,
currencyCheck = currencyCheck,
)
val cryptoCurrencyWarning = feePaidCurrencyStatus?.let { feeStatus ->
getCryptoCurrencyWarning(
feeValue = fee?.amount?.value.orZero(),
userWallet = userWallet,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
feeStatus = feeStatus,
)
}
return SwapState.Transfer(
userWallet = userWallet,
fromTokenInfo = fromTokenInfo,
toTokenInfo = toTokenInfo,
cryptoCurrencyWarning = cryptoCurrencyWarning,
isInsufficientBalance = fromTokenAmountValue > fromTokenBalance,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
@ -121,6 +136,20 @@ class SwapTransferInteractorImpl @Inject constructor(
)
}
private suspend fun getCryptoCurrencyWarning(
feeValue: BigDecimal,
userWallet: UserWallet,
fromSwapCurrencyStatus: SwapCurrencyStatus,
feeStatus: CryptoCurrencyStatus,
): CryptoCurrencyWarning? {
return getBalanceNotEnoughForFeeWarningUseCase(
fee = feeValue,
userWalletId = userWallet.walletId,
tokenStatus = fromSwapCurrencyStatus.status,
feeStatus = feeStatus,
).getOrNull()
}
private suspend fun getCoverageState(
fromTokenInfo: TokenSwapInfo,
userWallet: UserWallet,
@ -202,27 +231,36 @@ class SwapTransferInteractorImpl @Inject constructor(
override suspend fun loadFee(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
fromTokenAmount: BigDecimal,
): Either<GetFeeError, TransactionFee> {
val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError(
message = "Destination address is null",
)
val userWallet = fromSwapCurrencyStatus.userWallet
val currency = fromSwapCurrencyStatus.currency
val transactionData = createTransferTransactionUseCase(
amount = fromTokenAmount.convertToSdkAmount(
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
),
memo = null,
destination = destination,
userWalletId = userWallet.walletId,
network = currency.network,
).getOrNull() ?: return feeDataError("Failed to build transfer transaction")
return getFeeUseCase(
amount = amount,
destination = destination,
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
network = fromSwapCurrencyStatus.currency.network,
transactionData = transactionData,
)
}
override suspend fun loadFeeExtended(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
fromTokenAmount: BigDecimal,
selectedToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended> {
val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError(
message = "Destination address is null",
)
@ -230,7 +268,7 @@ class SwapTransferInteractorImpl @Inject constructor(
val currency = fromSwapCurrencyStatus.currency
val transactionData = createTransferTransactionUseCase(
amount = amount.convertToSdkAmount(
amount = fromTokenAmount.convertToSdkAmount(
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
),
memo = null,
@ -243,7 +281,13 @@ class SwapTransferInteractorImpl @Inject constructor(
userWallet = userWallet,
network = currency.network,
transactionData = transactionData,
)
).map { transactionFeeExtended ->
selectedToken ?: return@map transactionFeeExtended
val selectedTokenId = selectedToken.currency.id
transactionFeeExtended.copy(
feeTokenId = selectedTokenId,
)
}
}
override suspend fun sendTransfer(
@ -278,7 +322,28 @@ class SwapTransferInteractorImpl @Inject constructor(
)
}
private fun getDataError(message: String): Either<SendTransactionError.DataError, String> {
override suspend fun withdrawTangemPay(
userWallet: UserWallet,
cryptoAmount: BigDecimal,
toSwapCurrencyStatus: SwapCurrencyStatus,
): Either<SendTransactionError, WithdrawalResult> {
val destination = toSwapCurrencyStatus.destinationAddress() ?: return getDataError(
message = "Destination address is null",
)
val cryptoCurrencyId = toSwapCurrencyStatus.currency.id.rawCurrencyId ?: return getDataError(
message = "Crypto currency id should be null",
)
return tangemPayWithdrawUseCase(
userWallet = userWallet,
cryptoAmount = cryptoAmount,
cryptoCurrencyId = cryptoCurrencyId,
receiverCexAddress = destination,
).mapLeft { error ->
SendTransactionError.DataError("Tangem Pay withdrawal error code is ${error.errorCode}")
}
}
private fun getDataError(message: String): Either<SendTransactionError.DataError, Nothing> {
return SendTransactionError.DataError(message).left()
}
@ -288,9 +353,9 @@ class SwapTransferInteractorImpl @Inject constructor(
transactionFeeResult: TransactionFeeResult,
txData: TransactionData,
): Either<SendTransactionError, String> {
val isToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token
val isGaslessToken = isToken && transactionFeeResult is TransactionFeeResult.LoadedExtended
return if (isGaslessToken) {
val isFeeInTokenCurrency = transactionFeeResult is TransactionFeeResult.LoadedExtended &&
transactionFeeResult.fee.transactionFee.normal is Fee.Ethereum.TokenCurrency
return if (isFeeInTokenCurrency) {
createAndSendGaslessTransactionUseCase(
transactionData = txData,
userWallet = userWallet,

View file

@ -21,12 +21,14 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.Ignore
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
@ -872,6 +874,211 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
}
}
@Nested
inner class YieldSwapApprovalPath {
private val yieldProxyAddress = "0xYieldModuleProxy"
private val yieldTokenContract = "0xTokenContract"
@BeforeEach
fun enableYieldSwap() {
every { swapFeatureToggles.isYieldSwapEnabled } returns true
coEvery {
yieldModuleAddressProvider.getOrFetch(any(), any())
} returns yieldProxyAddress
}
@Test
fun `should proceed to QuotesLoadedState when yield-supply is active and isAllowedToSpend is true`() = runTest {
// Given — yield active, approve to proxy in place → swap proceeds via loadDexSwapDataNoFee
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = true,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val swapData = buildSwapDataModelDex()
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
} returns swapData.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — proceeds (no PermissionRequired), permissionState is Empty
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
}
@Test
fun `should request approval to yield-module proxy when isAllowedToSpend is false`() = runTest {
// Given — yield active, approve to proxy revoked → flow must surface PermissionRequired
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouterShouldNotBeUsed")
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — PermissionRequired with spender = yield-module proxy (not DEX router)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionRequired::class.java)
val required = loaded.permissionState as PermissionDataState.PermissionRequired
assertThat(required.spenderAddress).isEqualTo(yieldProxyAddress)
}
@Test
fun `should set isResetApproval=true when yield-token allowance requires reset before re-approval`() = runTest {
// Given — Tether-like token: any non-zero allowance must be reset to zero before re-approve.
// Yield approve to proxy was revoked → onchain allowance is partial → ResetNeeded.
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouterIgnoredForYield")
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
// Override default Enough stub: simulate partial-allowance state for yield-proxy spender.
coEvery {
getAllowanceInfoUseCase.invoke(
userWalletId = any(),
cryptoCurrency = any(),
spenderAddress = yieldProxyAddress,
requiredAmount = any(),
)
} returns (
AllowanceInfo.ResetNeeded(
allowance = BigDecimal("0.5"),
requiredAmount = BigDecimal("1"),
) as AllowanceInfo
).right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — PermissionRequired with isResetApproval=true and spender = yield-module proxy
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionRequired::class.java)
val required = loaded.permissionState as PermissionDataState.PermissionRequired
assertThat(required.spenderAddress).isEqualTo(yieldProxyAddress)
assertThat(required.isResetApproval).isTrue()
}
@Ignore("Check in final integrated approve test")
@Test
fun `should fallback to no-permission state when yield-module proxy address is unresolvable`() = runTest {
// Given — yield store returns null (e.g. network unreachable on first resolve)
coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns null
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouter")
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — falls back to PermissionDataState.Empty (no approval UI shown to avoid bogus DEX-router approve)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
}
}
}
// region — test-local helpers

View file

@ -33,6 +33,7 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
@ -41,6 +42,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.AmountFormatter
import com.tangem.feature.swap.domain.models.ui.SwapFee
import com.tangem.features.swap.SwapFeatureToggles
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
@ -84,6 +86,8 @@ internal open class SwapInteractorImplTestBase {
protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true)
protected val dexSwapFeeCalculator: DexSwapFeeCalculator = mockk(relaxed = true)
protected val cexSwapFeeCalculator: CexSwapFeeCalculator = mockk(relaxed = true)
protected val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
protected val yieldModuleAddressProvider: YieldModuleAddressProvider = mockk(relaxed = true)
// endregion
@ -115,6 +119,8 @@ internal open class SwapInteractorImplTestBase {
getSwapPairUseCase = getSwapPairUseCase,
dexSwapFeeCalculator = dexSwapFeeCalculator,
cexSwapFeeCalculator = cexSwapFeeCalculator,
swapFeatureToggles = swapFeatureToggles,
yieldModuleAddressProvider = yieldModuleAddressProvider,
)
}
@ -158,6 +164,7 @@ internal fun buildSwapCurrencyStatus(
decimals: Int = 18,
userWalletId: UserWalletId = UserWalletId(stringValue = "deadbeef"),
yieldSupplyActive: Boolean = false,
yieldSupplyAllowedToSpend: Boolean = true,
): SwapCurrencyStatus {
val networkId = mockk<Network.ID>(relaxed = true) {
every { rawId } returns Network.RawID(networkRawId)
@ -196,6 +203,7 @@ internal fun buildSwapCurrencyStatus(
val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) {
mockk<YieldSupplyStatus>(relaxed = true) {
every { isActive } returns true
every { isAllowedToSpend } returns yieldSupplyAllowedToSpend
}
} else {
null

View file

@ -20,6 +20,7 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
@ -59,6 +60,7 @@ internal class DexSwapFeeCalculatorTest {
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase = mockk(relaxed = true)
private val dexBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE)
@ -70,6 +72,7 @@ internal class DexSwapFeeCalculatorTest {
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
walletManagersFacade = walletManagersFacade,
patchEthGasLimitForSwap = dexBump,
wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase,
)
}

View file

@ -16,7 +16,10 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
@ -56,6 +59,8 @@ internal class SwapTransferInteractorImplTest {
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk()
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk()
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk()
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase = mockk()
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk(relaxed = true)
private val sut = SwapTransferInteractorImpl(
swapFeatureToggles = swapFeatureToggles,
@ -69,6 +74,8 @@ internal class SwapTransferInteractorImplTest {
createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase,
getCurrencyCheckUseCase = getCurrencyCheckUseCase,
isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase,
tangemPayWithdrawUseCase = tangemPayWithdrawUseCase,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
)
@AfterEach
@ -129,7 +136,17 @@ internal class SwapTransferInteractorImplTest {
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true
val currencyCheck = buildCurrencyCheck()
coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck
coEvery {
getCurrencyCheckUseCase(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns currencyCheck
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
} returns false.right()
@ -156,6 +173,7 @@ internal class SwapTransferInteractorImplTest {
swapCurrencyStatus = toCurrencyStatus,
amountFiat = expectedFiat,
),
cryptoCurrencyWarning = null,
isInsufficientBalance = false,
appCurrency = appCurrency,
isBalanceHidden = true,
@ -189,7 +207,17 @@ internal class SwapTransferInteractorImplTest {
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true
val currencyCheck = buildCurrencyCheck()
coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck
coEvery {
getCurrencyCheckUseCase(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns currencyCheck
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
} returns false.right()
@ -216,6 +244,7 @@ internal class SwapTransferInteractorImplTest {
swapCurrencyStatus = toCurrencyStatus,
amountFiat = expectedFiat,
),
cryptoCurrencyWarning = null,
isInsufficientBalance = true,
appCurrency = appCurrency,
isBalanceHidden = true,
@ -255,7 +284,15 @@ internal class SwapTransferInteractorImplTest {
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
coEvery {
getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any())
getCurrencyCheckUseCase(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns buildCurrencyCheck()
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
@ -281,40 +318,51 @@ internal class SwapTransferInteractorImplTest {
@Test
fun `GIVEN valid amount and destination WHEN loadFee THEN return TransactionFee from use case`() = runTest {
val userWallet: UserWallet = mockk()
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val transactionData: TransactionData.Uncompiled = mockk()
val transactionFee: TransactionFee = mockk()
coEvery {
getFeeUseCase(
amount = BigDecimal("1.5"),
createTransferTransactionUseCase(
amount = any(),
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns transactionData.right()
coEvery {
getFeeUseCase(
userWallet = userWallet,
cryptoCurrency = fromCurrencyStatus.currency,
network = network,
transactionData = transactionData,
)
} returns transactionFee.right()
val result = sut.loadFee(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.5",
fromTokenAmount = BigDecimal("1.5"),
)
assertThat(result).isEqualTo(transactionFee.right())
coVerify {
getFeeUseCase(
amount = BigDecimal("1.5"),
destination = DESTINATION_ADDRESS,
userWallet = userWallet,
cryptoCurrency = fromCurrencyStatus.currency,
network = network,
transactionData = transactionData,
)
}
}
@ -361,7 +409,8 @@ internal class SwapTransferInteractorImplTest {
val result = sut.loadFeeExtended(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "2.0",
fromTokenAmount = BigDecimal("2.0"),
selectedToken = null,
)
assertThat(result).isEqualTo(feeExtended.right())
@ -461,7 +510,7 @@ internal class SwapTransferInteractorImplTest {
}
@Test
fun `GIVEN token and LoadedExtended fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() =
fun `GIVEN LoadedExtended fee with TokenCurrency normal fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() =
runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
@ -479,7 +528,11 @@ internal class SwapTransferInteractorImplTest {
)
val fee: Fee = mockk()
val txData: TransactionData.Uncompiled = mockk()
val transactionFeeExtended: TransactionFeeExtended = mockk()
val transactionFeeExtended: TransactionFeeExtended = mockk {
every { transactionFee } returns mockk {
every { normal } returns mockk<Fee.Ethereum.TokenCurrency>()
}
}
val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended)
coEvery {
createTransferTransactionUseCase(
@ -570,6 +623,62 @@ internal class SwapTransferInteractorImplTest {
}
}
@Test
fun `GIVEN LoadedExtended fee with non-TokenCurrency normal fee WHEN sendTransfer THEN fall back to sendTransactionUseCase`() =
runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildTokenCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildTokenCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val fee: Fee = mockk()
val txData: TransactionData.Uncompiled = mockk()
val transactionFeeExtended: TransactionFeeExtended = mockk {
every { transactionFee } returns mockk {
every { normal } returns mockk<Fee.Common>()
}
}
val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended)
coEvery {
createTransferTransactionUseCase(
amount = any(),
fee = fee,
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns txData.right()
coEvery {
sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network)
} returns TX_HASH.right()
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
sendingAmount = BigDecimal("1.0"),
fee = fee,
transactionFeeResult = transactionFeeResult,
)
assertThat(result).isEqualTo(TX_HASH.right())
coVerify {
sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network)
}
coVerify(exactly = 0) {
createAndSendGaslessTransactionUseCase(any(), any(), any())
}
}
@Test
fun `GIVEN createTransferTransactionUseCase fails WHEN sendTransfer THEN return DataError`() = runTest {
val userWalletId: UserWalletId = mockk()
@ -613,6 +722,46 @@ internal class SwapTransferInteractorImplTest {
// endregion
// region withdrawTangemPay
@Test
fun `GIVEN valid destination and currency id WHEN withdrawTangemPay THEN return WithdrawalResult from use case`() =
runTest {
val userWallet: UserWallet = mockk()
val cryptoAmount = BigDecimal("1.5")
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
coEvery {
tangemPayWithdrawUseCase(
userWallet = userWallet,
cryptoAmount = cryptoAmount,
cryptoCurrencyId = TO_RAW_CURRENCY_ID,
receiverCexAddress = DESTINATION_ADDRESS,
)
} returns WithdrawalResult.Success.right()
val result = sut.withdrawTangemPay(
userWallet = userWallet,
cryptoAmount = cryptoAmount,
toSwapCurrencyStatus = toCurrencyStatus,
)
assertThat(result).isEqualTo(WithdrawalResult.Success.right())
coVerify {
tangemPayWithdrawUseCase(
userWallet = userWallet,
cryptoAmount = cryptoAmount,
cryptoCurrencyId = TO_RAW_CURRENCY_ID,
receiverCexAddress = DESTINATION_ADDRESS,
)
}
}
// endregion
// region shouldTransferInsteadOfSwap
@Test

View file

@ -20,22 +20,16 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.R
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.model.SwapModel
import com.tangem.feature.swap.models.SwapPermissionUM
import com.tangem.feature.swap.router.SwapRoute
import com.tangem.feature.swap.ui.SwapScreen
import com.tangem.feature.swap.ui.SwapSuccessScreen
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.SwapComponent
@ -50,7 +44,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SwapComponent.Params,
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
private val giveApprovalComponentFactory: GiveApprovalComponent.Factory,
private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory,
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
) : SwapComponent, AppComponentContext by appComponentContext {
@ -78,12 +72,10 @@ internal class DefaultSwapComponent @AssistedInject constructor(
source = model.approvalSlotNavigation,
serializer = null,
handleBackButton = true,
childFactory = { _, factoryContext ->
val approvalParams = getApprovalParams()
?: error("Approval params are not available")
giveApprovalComponentFactory.create(
childFactory = { params, factoryContext ->
giveApprovalEntryComponentFactory.create(
context = childByContext(factoryContext),
params = approvalParams,
params = GiveApprovalEntryComponent.Params(params),
)
},
)
@ -123,6 +115,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
analyticsCategoryName = CommonSendAnalyticEvents.SWAP_CATEGORY,
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap,
),
isTransferMode = config.isTransferMode,
),
)
}
@ -143,6 +136,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
data class FeeSelectorConfig(
val sendingCurrencyStatus: CryptoCurrencyStatus,
val feeCurrencyStatus: CryptoCurrencyStatus,
val isTransferMode: Boolean,
)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -151,22 +145,21 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle()
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } }
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
val isInTransferMode by remember { derivedStateOf { dataState.currentTransferState != null } }
val shouldHideBlock by remember {
derivedStateOf {
val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero()
val isInsufficientFunds = model.uiState.isInsufficientFunds
val isProviderMissing = dataState.selectedProvider == null
val loadedState = dataState.getCurrentLoadedSwapState()
val isPermissionNotReady = loadedState?.permissionState !is PermissionDataState.Empty
val isInTransferMode = dataState.currentTransferState != null
val isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady)
val isPermissionNotNeeded = model.isPermissionNotNeeded
val isSwapNotReady = !isInTransferMode && (isProviderMissing || !isPermissionNotNeeded)
val isTangemPayWithdrawal = model.isTangemPayWithdrawal()
isAmountEmptyOrZero || isInsufficientFunds || isSwapNotReady || isTangemPayWithdrawal
}
}
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) {
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock, isInTransferMode) {
if (shouldHideBlock) {
TangemLogger.e(
messageString = "Dismissing fee selector: " +
@ -194,6 +187,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
FeeSelectorConfig(
sendingCurrencyStatus = sendingCryptoCurrencyStatus,
feeCurrencyStatus = feeCurrencyStatus,
isTransferMode = isInTransferMode,
),
)
}
@ -246,34 +240,6 @@ internal class DefaultSwapComponent @AssistedInject constructor(
}
}
private fun getApprovalParams(): GiveApprovalComponent.Params? {
val permissionState = model.uiState.permissionUM as? SwapPermissionUM.PermissionRequired ?: return null
val fromSwapCurrencyStatus = model.dataState.fromSwapCurrencyStatus ?: return null
val feeCryptoCurrency = model.dataState.feePaidCryptoCurrency ?: return null
val providerName = model.dataState.selectedProvider?.name.orEmpty()
val isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet
return GiveApprovalComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
feeCryptoCurrencyStatus = feeCryptoCurrency,
amount = model.dataState.amount.orEmpty(),
spenderAddress = permissionState.spenderAddress,
amountFooter = if (permissionState.isResetApproval) {
resourceReference(R.string.update_approval_permission_subtitle)
} else {
resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol),
)
},
feeFooter = resourceReference(R.string.swap_give_permission_fee_footer),
isResetApproval = permissionState.isResetApproval,
isHoldToConfirm = isHoldToConfirm,
callback = model.approvalCallback,
)
}
private fun onChildBack() {
val isEmptyStack = childStack.value.backStack.isEmpty()
val isSuccess = model.uiState.successState != null

View file

@ -9,12 +9,16 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : SwapFeatureToggles {
override val isYieldSwapEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED,
)
override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
)
override val isSwapIntegratedApproveEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.SWAP_INTEGRATED_APPROVE,
toggle = FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE,
)
override val isSwapAbEnabled: Boolean = featureTogglesManager.isFeatureEnabled(

View file

@ -1,11 +1,11 @@
package com.tangem.feature.swap.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_BLOCKCHAIN
@ -15,6 +15,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.getReferralParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
@ -297,6 +298,48 @@ sealed class SwapEvents(
event = "Fast amount input",
params = mapOf("Percentage" to percent.toAnalyticsValue()),
)
class TransferModeSwitched(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
) : SwapEvents(
event = "Transfer Mode Switched",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
),
)
class ButtonTransferClicked(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
) : SwapEvents(
event = "Button - Transfer",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
),
)
@Suppress("NullableToStringCall", "LongParameterList")
class TransferInProgressScreen(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
feeNetwork: Network,
) : SwapEvents(
event = "Transfer in Progress Screen Opened",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
"Network fee" to feeNetwork.name,
),
), AppsFlyerIncludedEvent
}
private fun PredefinedPercentAmount.toAnalyticsValue(): String = when (this) {

View file

@ -19,11 +19,7 @@ import com.tangem.features.send.v2.api.params.FeeSelectorParams
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.*
class SwapFeeSelectorBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@ -44,7 +40,11 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor(
null
},
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow,
feeStateConfiguration = if (params.isTransferMode) {
FeeSelectorParams.FeeStateConfiguration.None
} else {
FeeSelectorParams.FeeStateConfiguration.ExcludeLow
},
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus,
analyticsCategoryName = params.analyticsParams.analyticsCategoryName,
@ -100,6 +100,7 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor(
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
val analyticsParams: AnalyticsParams,
val repository: ModelRepository,
val isTransferMode: Boolean,
)
@AssistedFactory

View file

@ -59,6 +59,7 @@ internal object SwapProviderStateBuilder {
),
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = onProviderClick,
)
}
@ -78,6 +79,7 @@ internal object SwapProviderStateBuilder {
isNeedBestRateBadge: Boolean = false,
needApplyFCARestrictions: Boolean,
onProviderClick: (String) -> Unit,
onApprovalSelectClick: (SwapProvider) -> Unit = {},
): ProviderState.Content {
return provider.toContent(
subtitle = buildSelectableSubtitle(toTokenInfo),
@ -92,6 +94,12 @@ internal object SwapProviderStateBuilder {
percentLowerThenBest = pricesLowerBest[provider.providerId]
?.let(PercentDifference::Value)
?: PercentDifference.Value(0f),
approvalSettings = when (permissionState) {
is PermissionDataState.PermissionSettings -> ProviderState.ApprovalSettings.Content(
onApprovalSelectClick = { onApprovalSelectClick(provider) },
)
else -> ProviderState.ApprovalSettings.Empty
},
onProviderClick = onProviderClick,
)
}
@ -115,6 +123,7 @@ internal object SwapProviderStateBuilder {
),
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = onProviderClick,
)
}
@ -151,11 +160,13 @@ internal object SwapProviderStateBuilder {
}
}
@Suppress("LongParameterList")
private fun SwapProvider.toContent(
subtitle: TextReference,
additionalBadge: ProviderState.AdditionalBadge,
selectionType: ProviderState.SelectionType,
percentLowerThenBest: PercentDifference,
approvalSettings: ProviderState.ApprovalSettings,
onProviderClick: (String) -> Unit,
): ProviderState.Content {
return ProviderState.Content(
@ -169,6 +180,7 @@ internal object SwapProviderStateBuilder {
percentLowerThenBest = percentLowerThenBest,
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick,
approvalSettings = approvalSettings,
)
}

View file

@ -15,6 +15,7 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -57,6 +58,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
@ -68,7 +70,7 @@ import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.usecase.CalculateAmountUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.transaction.error.GetFeeError
@ -89,10 +91,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor
import com.tangem.feature.swap.models.SwapAlertUM
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.TokenSelectionDirection
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.router.SwapRoute
import com.tangem.feature.swap.ui.StateBuilder
@ -100,6 +99,8 @@ import com.tangem.feature.swap.ui.transfer.SwapTransferStateBuilder
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.feature.swap.utils.getContractAddress
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
@ -155,7 +156,7 @@ internal class SwapModel @Inject constructor(
private val urlOpener: UrlOpener,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val tangemPayWithdrawWithSwapUseCase: TangemPayWithdrawWithSwapUseCase,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
@ -163,7 +164,7 @@ internal class SwapModel @Inject constructor(
private val messageSender: UiMessageSender,
private val initialCurrenciesResolver: InitialCurrenciesResolver,
private val allowPermissionsHandler: AllowPermissionsHandler,
swapFeatureToggles: SwapFeatureToggles,
private val swapFeatureToggles: SwapFeatureToggles,
private val getSwapUiModeUseCase: GetSwapUiModeUseCase,
private val setSwapUiModeUseCase: SetSwapUiModeUseCase,
private val calculateAmountUseCase: CalculateAmountUseCase,
@ -223,7 +224,7 @@ internal class SwapModel @Inject constructor(
}
var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState())
private set
internal set
val feeSelectorRepository = FeeSelectorRepository()
@ -249,9 +250,17 @@ internal class SwapModel @Inject constructor(
private var preselectedFromCurrency: CryptoCurrency? = null
private var preselectedToCurrency: CryptoCurrency? = null
val approvalSlotNavigation = SlotNavigation<Unit>()
val isPermissionNotNeeded: Boolean
get() {
val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState
return permissionState == PermissionDataState.Empty ||
swapFeatureToggles.isSwapIntegratedApproveEnabled &&
permissionState is PermissionDataState.PermissionSettings
}
val approvalCallback = object : GiveApprovalComponent.Callback {
val approvalSlotNavigation = SlotNavigation<GiveApprovalEntryComponent.Mode>()
internal val approvalFullCallback = object : GiveApprovalComponent.Callback {
override fun onApproveClick() {}
override fun onApproveDone() {
@ -276,6 +285,46 @@ internal class SwapModel @Inject constructor(
}
}
internal val approvalSelectorCallback = object : SelectApprovalTypeComponent.Callback {
override fun onApproveTypeSelected(spenderAddress: String, approveType: ApproveType) {
val (swapState, permission) = dataState.lastLoadedSwapStates.firstNotNullOfOrNull { (provider, state) ->
if (state !is SwapState.QuotesLoadedState) return@firstNotNullOfOrNull null
val permissionState = state.permissionState
if (permissionState is PermissionDataState.PermissionSettings &&
permissionState.spenderAddress == spenderAddress
) {
state to permissionState
} else {
null
}
} ?: return
if (permission.type == approveType) {
approvalSlotNavigation.dismiss()
return
}
dataState = dataState.copy(
lastLoadedSwapStates = dataState.lastLoadedSwapStates.toMutableMap().apply {
put(
swapState.swapProvider,
swapState.copy(permissionState = permission.copy(type = approveType)),
)
},
)
approvalSlotNavigation.dismiss()
modelScope.launch {
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerLoadingState()
feeSelectorReloadTrigger.triggerUpdate()
}
}
override fun onCancelClick() {
approvalSlotNavigation.dismiss()
}
}
init {
subscribeToTokenSelection()
@ -603,7 +652,15 @@ internal class SwapModel @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
)
if (isUpdatedToTransferMode) return
if (isUpdatedToTransferMode) {
analyticsEventHandler.send(
event = SwapEvents.TransferModeSwitched(
fromCurrency = fromSwapCurrencyStatus.currency,
toCurrency = toSwapCurrencyStatus.currency,
),
)
return
}
dataState = dataState.copy(currentTransferState = null)
modelScope.launch {
uiState = stateBuilder.createInitialLoadingState(
@ -740,14 +797,18 @@ internal class SwapModel @Inject constructor(
feePaidCryptoCurrencyStatus = feePaidCryptoCurrency,
fee = selectedFee,
)
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
if (isTangemPayWithdrawal()) {
refreshTransferUIStateIfNeeded()
} else {
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
}
}
is SwapState.QuotesLoadedState, is SwapState.SwapError -> Unit
}
}
private fun refreshTransferUIStateAfterFeeUpdateIfNeeded(
private fun refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus? = null,
fee: Fee? = null,
) {
@ -768,7 +829,10 @@ internal class SwapModel @Inject constructor(
feePaidCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
) as? SwapState.Transfer ?: currentTransferState
dataState = dataState.copy(currentTransferState = refreshed)
dataState = dataState.copy(
currentTransferState = refreshed,
feePaidCryptoCurrency = feePaidCryptoCurrencyStatus ?: dataState.feePaidCryptoCurrency,
)
uiState = swapTransferStateBuilder.updateTransferButtonEnableState(
dataState = dataState,
transferState = refreshed,
@ -776,6 +840,7 @@ internal class SwapModel @Inject constructor(
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
isTangemPayWithdrawal = isTangemPayWithdrawal(),
)
}
}
@ -960,8 +1025,6 @@ internal class SwapModel @Inject constructor(
tokenSwapInfoForProviders = successStates.entries
.associate { it.key.providerId to it.value.toTokenInfo },
)
val isPermissionNotNeeded =
dataState.getCurrentLoadedSwapState()?.permissionState == PermissionDataState.Empty
if (shouldUpdateFeeBlock && isPermissionNotNeeded) {
modelScope.launch { feeSelectorReloadTrigger.triggerUpdate() }
} else {
@ -1288,62 +1351,144 @@ internal class SwapModel @Inject constructor(
private fun onTransferClick() {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
analyticsEventHandler.send(
event = SwapEvents.ButtonTransferClicked(
fromCurrency = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
),
)
val fee = (feeSelectorRepository.state.value as? FeeSelectorUM.Content)?.selectedFeeItem?.fee
if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null || fee == null) {
TangemLogger.e("onTransferClick: missing currency status or fee, aborting")
if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null) {
TangemLogger.e("onTransferClick: missing currency status, aborting")
showAlert()
return
}
val transferState = dataState.currentTransferState ?: return
uiState = swapTransferStateBuilder.createTransferInProgressState(uiState)
modelScope.launch(dispatchers.main) {
swapTransferInteractor.sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
sendingAmount = transferState.sendingAmount,
fee = fee,
transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) {
"It should be not null at this stage"
},
).fold(
ifLeft = { error ->
TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}")
when {
isTangemPayWithdrawal() -> withdrawTangemPay(
transferState = transferState,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
fee != null -> sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
transferState = transferState,
fee = fee,
)
else -> {
TangemLogger.e("onTransferClick: Illegal state, aborting")
showAlert()
},
ifRight = { txHash ->
val txUrl = getExplorerTransactionUrlUseCase(
txHash = txHash,
currency = fromSwapCurrencyStatus.currency,
).getOrElse {
TangemLogger.i("onTransferClick: tx hash explore not supported")
""
}
updateWalletBalance()
uiState = swapTransferStateBuilder.createSuccessState(
uiState = uiState,
dataState = dataState,
appCurrency = selectedAppCurrencyFlow.value,
isAccountsMode = isAccountsMode,
txUrl = txUrl,
timestamp = System.currentTimeMillis(),
fee = null,
onExplorerClick = {
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
}
},
)
router.replaceAll(SwapRoute.Success)
},
)
}
}
}
}
private suspend fun withdrawTangemPay(
transferState: SwapState.Transfer,
toSwapCurrencyStatus: SwapCurrencyStatus,
) {
swapTransferInteractor.withdrawTangemPay(
userWallet = transferState.userWallet,
cryptoAmount = transferState.sendingAmount,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
.onLeft { error ->
TangemLogger.e(
messageString = "onTransferClick: withdrawTangemPay failed: ${error.getAnalyticsDescription()}",
)
startLoadingQuotesFromLastState()
showAlert()
}
.onRight { result ->
when (result) {
WithdrawalResult.Cancelled -> startLoadingQuotesFromLastState()
WithdrawalResult.Success -> updateTransferModeTangemPayState()
}
}
}
private fun updateTransferModeTangemPayState() {
sendTransferInProgressEvent()
uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState(
uiState = uiState,
dataState = dataState,
fee = getSelectedSwapFee()?.fee,
onExploreClick = {
val txUrl = uiState.successState?.txUrl.orEmpty()
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
}
},
)
router.replaceAll(SwapRoute.Success)
}
private suspend fun sendTransfer(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
transferState: SwapState.Transfer,
fee: Fee,
) {
swapTransferInteractor.sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
sendingAmount = transferState.sendingAmount,
fee = fee,
transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) {
"It should be not null at this stage"
},
).fold(
ifLeft = { error ->
TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}")
startLoadingQuotesFromLastState()
showAlert()
},
ifRight = { txHash ->
val txUrl = getExplorerTransactionUrlUseCase(
txHash = txHash,
currency = fromSwapCurrencyStatus.currency,
).getOrElse {
TangemLogger.i("onTransferClick: tx hash explore not supported")
""
}
updateWalletBalance()
sendTransferInProgressEvent()
uiState = swapTransferStateBuilder.createSuccessState(
uiState = uiState,
dataState = dataState,
txUrl = txUrl,
timestamp = System.currentTimeMillis(),
fee = getSelectedSwapFee()?.fee,
onExplorerClick = {
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
}
},
)
router.replaceAll(SwapRoute.Success)
},
)
}
private fun sendTransferInProgressEvent() {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
analyticsEventHandler.send(
event = SwapEvents.TransferInProgressScreen(
fromCurrency = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
feeNetwork = getFeeToken().network,
),
)
}
private suspend fun processTangemPayWithdrawal(
fromSwapCurrencyStatus: SwapCurrencyStatus,
swapTransactionState: SwapTransactionState.TangemPayWithdrawalData,
) {
tangemPayWithdrawUseCase(
tangemPayWithdrawWithSwapUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoAmount = swapTransactionState.cryptoAmount,
cryptoCurrencyId = swapTransactionState.cryptoCurrencyId,
@ -1689,13 +1834,27 @@ internal class SwapModel @Inject constructor(
onPredefinedPercentSelected = ::onPredefinedPercentSelected,
onReduceToAmount = ::onReduceAmountClicked,
onReduceByAmount = ::onReduceAmountClicked,
openPermissionBottomSheet = {
onApproveClick = {
singleTaskScheduler.cancelTask()
sendGivePermissionClickedEvent()
approvalSlotNavigation.activate(Unit)
val approval = getApprovalParams()
if (approval != null) {
approvalSlotNavigation.activate(
GiveApprovalEntryComponent.Mode.FullApproval(approval),
)
}
},
onApproveTypeSelect = { provider ->
val approval = getSelectApprovalTypeParams(provider)
if (approval != null) {
approvalSlotNavigation.activate(
GiveApprovalEntryComponent.Mode.SelectOnly(approval),
)
}
},
onAmountSelected = { onAmountSelected(it) },
onProviderClick = { providerId ->
singleTaskScheduler.cancelTask()
analyticsEventHandler.send(SwapEvents.ProviderClicked())
val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
val pricesLowerBest = getPricesLowerBest(providerId, states)
@ -2140,6 +2299,7 @@ internal class SwapModel @Inject constructor(
dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val toSwapCurrencyStatus =
dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
@ -2148,7 +2308,7 @@ internal class SwapModel @Inject constructor(
return swapTransferInteractor.loadFee(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
fromTokenAmount = amount,
).onLeft {
TangemLogger.e("loadFee[transfer]: Failed to load fee with error $it")
}.onRight {
@ -2162,9 +2322,7 @@ internal class SwapModel @Inject constructor(
return Either.Left(GetFeeError.UnknownError)
}
val amountDecimal = lastAmount.value.replace(",", ".").toBigDecimalOrNull()
?: return Either.Left(GetFeeError.UnknownError)
val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
val swapDataForCall = when (quoteState.swapProvider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
quoteState.swapDataModel ?: return Either.Left(GetFeeError.UnknownError)
@ -2196,6 +2354,8 @@ internal class SwapModel @Inject constructor(
dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val toSwapCurrencyStatus =
dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
@ -2204,7 +2364,8 @@ internal class SwapModel @Inject constructor(
return swapTransferInteractor.loadFeeExtended(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
fromTokenAmount = amount,
selectedToken = selectedToken,
)
}
val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError)
@ -2213,8 +2374,7 @@ internal class SwapModel @Inject constructor(
return Either.Left(GetFeeError.UnknownError)
}
val amountDecimal = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
// DEX path requires a SwapDataModel.
val swapDataForCall = when (quoteState.swapProvider.type) {
@ -2252,7 +2412,7 @@ internal class SwapModel @Inject constructor(
if (newState is FeeSelectorUM.Error) {
TangemLogger.e("loadFee: ${newState.error}, isHidden = true")
refreshTransferUIStateAfterFeeUpdateIfNeeded()
refreshTransferUIStateIfNeeded()
uiState = stateBuilder.createFeeErrorState(
uiStateHolder = uiState,
quoteModel = dataState.getCurrentLoadedSwapState() ?: return,
@ -2262,10 +2422,6 @@ internal class SwapModel @Inject constructor(
modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) }
return
}
refreshTransferUIStateAfterFeeUpdateIfNeeded(
feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
@ -2274,7 +2430,13 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus?.currency,
toSwapCurrencyStatus?.currency,
)
if (shouldTransferInsteadOfSwap) return
if (shouldTransferInsteadOfSwap) {
refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus = getSelectedSwapFee()?.selectedFeeToken,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
return
}
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
val swapFee = getSelectedSwapFee() ?: return
@ -2321,6 +2483,54 @@ internal class SwapModel @Inject constructor(
}
}
internal fun getSelectApprovalTypeParams(provider: SwapProvider): SelectApprovalTypeComponent.Params? {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return null
val swapState = dataState.lastLoadedSwapStates[provider] as? SwapState.QuotesLoadedState ?: return null
val permissionState = swapState.permissionState as? PermissionDataState.PermissionSettings ?: return null
val providerName = swapState.swapProvider.name
val approvalType = permissionState.type
return SelectApprovalTypeComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
initialApproveType = approvalType,
amountFooter = resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol),
),
spenderAddress = permissionState.spenderAddress,
callback = approvalSelectorCallback,
)
}
internal fun getApprovalParams(): GiveApprovalComponent.Params? {
val permissionState = uiState.permissionUM as? SwapPermissionUM.PermissionRequired ?: return null
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return null
val feeCryptoCurrency = dataState.feePaidCryptoCurrency ?: return null
val providerName = dataState.selectedProvider?.name.orEmpty()
val isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet
return GiveApprovalComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
feeCryptoCurrencyStatus = feeCryptoCurrency,
amount = dataState.amount.orEmpty(),
spenderAddress = permissionState.spenderAddress,
amountFooter = if (permissionState.isResetApproval) {
resourceReference(R.string.update_approval_permission_subtitle)
} else {
resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol),
)
},
feeFooter = resourceReference(R.string.swap_give_permission_fee_footer),
isResetApproval = permissionState.isResetApproval,
isHoldToConfirm = isHoldToConfirm,
callback = approvalFullCallback,
)
}
private companion object {
const val INITIAL_AMOUNT = ""
const val UPDATE_DELAY = 10000L

View file

@ -249,7 +249,7 @@ internal class SwapNotificationsFactory(
if (quoteModel.permissionState is PermissionDataState.PermissionRequired) {
add(
SwapNotificationUM.Info.PermissionNeeded(
onApproveClick = actions.openPermissionBottomSheet,
onApproveClick = actions.onApproveClick,
onLearnMoreClick = { actions.onLinkClick(TangemSiteUrlBuilder.HELP_CENTER_SWAP_URL) },
),
)

View file

@ -4,6 +4,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.express.models.ProviderFilterType
import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import java.math.BigDecimal
@ -18,7 +19,8 @@ internal data class UiActions(
val onPredefinedPercentSelected: (PredefinedPercentAmount) -> Unit,
val onReduceToAmount: (SwapAmount) -> Unit,
val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit,
val openPermissionBottomSheet: () -> Unit,
val onApproveClick: () -> Unit,
val onApproveTypeSelect: (SwapProvider) -> Unit,
// region new actions
val onRetryClick: () -> Unit,
val onProviderClick: (String) -> Unit,

View file

@ -29,6 +29,7 @@ sealed class ProviderState {
val additionalBadge: AdditionalBadge,
val percentLowerThenBest: PercentDifference = PercentDifference.Empty,
val namePrefix: PrefixType,
val approvalSettings: ApprovalSettings = ApprovalSettings.Empty,
override val onProviderClick: (String) -> Unit,
) : ProviderState()
@ -61,6 +62,14 @@ sealed class ProviderState {
enum class PrefixType {
NONE, PROVIDED_BY
}
@Immutable
sealed class ApprovalSettings {
data object Empty : ApprovalSettings()
data class Content(
val onApprovalSelectClick: () -> Unit,
) : ApprovalSettings()
}
}
@Immutable

View file

@ -178,6 +178,7 @@ private fun Preview_ChooseProviderBottomSheet() {
percentLowerThenBest = PercentDifference.Value(-1.0f),
selectionType = ProviderState.SelectionType.SELECT,
namePrefix = ProviderState.PrefixType.NONE,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = {},
),
ProviderState.Unavailable(

View file

@ -4,26 +4,32 @@ import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
@ -194,6 +200,23 @@ private fun ProviderContentState(
}
}
}
if (state.approvalSettings is ProviderState.ApprovalSettings.Content) {
SpacerWMax()
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_filter_default_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.padding(end = 14.dp)
.size(20.dp)
.clickable(
indication = ripple(false),
interactionSource = remember { MutableInteractionSource() },
onClick = state.approvalSettings.onApprovalSelectClick,
),
)
}
}
ProviderChevron(selectionType = state.selectionType, isSelected = isSelected)
@ -442,10 +465,9 @@ private fun ProviderItemPreview(
@PreviewParameter(ProviderItemParameterProvider::class) state: Pair<ProviderState, Boolean>,
) {
TangemThemePreview {
ProviderItem(
ProviderItemBlock(
modifier = Modifier.background(TangemTheme.colors.background.action),
state = state.first,
isSelected = state.second,
)
}
}
@ -460,22 +482,28 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"),
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentDifference.Value(value = 12.0f),
selectionType = ProviderState.SelectionType.SELECT,
selectionType = ProviderState.SelectionType.NONE,
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = {},
)
val contentState2 = contentState.copy(
val contentStatePermissionRequired = contentState.copy(
subtitle = stringReference(value = "1 132,46 MATIC"),
additionalBadge = ProviderState.AdditionalBadge.PermissionRequired,
percentLowerThenBest = PercentDifference.Value(value = 5f),
)
val contentStatePermissionIntegrated = contentState.copy(
subtitle = stringReference(value = "1 132,46 MATIC"),
percentLowerThenBest = PercentDifference.Value(value = 5f),
approvalSettings = ProviderState.ApprovalSettings.Content({}),
)
val unavailableState = ProviderState.Unavailable(
id = "1",
name = "1inch",
type = "DEX",
iconUrl = "",
alertText = stringReference(value = "Not available"),
selectionType = ProviderState.SelectionType.SELECT,
selectionType = ProviderState.SelectionType.NONE,
onProviderClick = {},
)
val loadingState = ProviderState.Loading()
@ -483,8 +511,11 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
add(contentState to true)
add(contentState to false)
add(contentState2 to true)
add(contentState2 to false)
add(contentStatePermissionRequired to true)
add(contentStatePermissionRequired to false)
add(contentStatePermissionIntegrated to true)
add(contentStatePermissionIntegrated to false)
add(unavailableState to true)
add(unavailableState to false)

View file

@ -173,6 +173,7 @@ private class SimpleProviderPreview : PreviewParameterProvider<ProviderState> {
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = ProviderState.PrefixType.NONE,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = {},
),
ProviderState.Content(

View file

@ -100,7 +100,7 @@ internal class StateBuilder(
onChangeCardsClicked = actions.onChangeCardsClicked,
onMaxAmountSelected = actions.onMaxAmountSelected,
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
onShowPermissionBottomSheet = actions.openPermissionBottomSheet,
onShowPermissionBottomSheet = actions.onApproveClick,
onSelectTokenClick = actions.onSelectTokenClick,
onSuccess = actions.onSuccess,
providerState = ProviderState.Empty(),
@ -1078,6 +1078,7 @@ internal class StateBuilder(
pricesLowerBest = pricesLowerBest,
onProviderSelect = actions.onProviderSelect,
needApplyFCARestrictions = needApplyFCARestrictions,
onApprovalSelectClick = actions.onApproveTypeSelect,
bestRatedProviderId = bestRatedProviderId,
isNeedBestRateBadge = isNeedBestRateBadge,
)
@ -1183,6 +1184,7 @@ internal class StateBuilder(
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderBottomSheetState(
pricesLowerBest: Map<String, Float>,
onProviderSelect: (String) -> Unit,
onApprovalSelectClick: (SwapProvider) -> Unit,
needApplyFCARestrictions: Boolean,
bestRatedProviderId: String,
isNeedBestRateBadge: Boolean,
@ -1201,6 +1203,7 @@ internal class StateBuilder(
isBestRate = bestRatedProviderId == provider.providerId && !state.priceImpact.shouldShowWarning(),
isNeedBestRateBadge = isNeedBestRateBadge,
onProviderClick = onProviderSelect,
onApprovalSelectClick = onApprovalSelectClick,
)
}
is SwapState.SwapError -> getProviderStateForError(

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