Updated on 2026-08-14
This commit is contained in:
commit
d0b35bc331
680 changed files with 26556 additions and 2709 deletions
|
|
@ -0,0 +1,40 @@
|
|||
package com.tangem.features.approval.api
|
||||
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
|
||||
/**
|
||||
* Entry component that wraps the two approval-flow variants:
|
||||
*
|
||||
* - [Mode.FullApproval] — original [GiveApprovalComponent] which renders the approval-type
|
||||
* selector together with the fee selector and submits the approval transaction.
|
||||
* - [Mode.SelectOnly] — [SelectApprovalTypeComponent] which only collects the approval-type
|
||||
* choice and returns it to the caller via its own [SelectApprovalTypeComponent.Callback].
|
||||
*
|
||||
* Callers depend only on this single factory and pass the appropriate [Mode]; the entry
|
||||
* component internally creates the corresponding child and delegates the bottom sheet
|
||||
* rendering and dismissal to it.
|
||||
*/
|
||||
interface GiveApprovalEntryComponent : ComposableBottomSheetComponent {
|
||||
|
||||
data class Params(
|
||||
val mode: Mode,
|
||||
)
|
||||
|
||||
sealed interface Mode {
|
||||
|
||||
/** Full flow: approval-type selector + fee selector + transaction submission. */
|
||||
data class FullApproval(
|
||||
val params: GiveApprovalComponent.Params,
|
||||
) : Mode
|
||||
|
||||
/** Selection-only flow: returns the chosen approval type without sending anything. */
|
||||
data class SelectOnly(
|
||||
val params: SelectApprovalTypeComponent.Params,
|
||||
) : Mode
|
||||
}
|
||||
|
||||
interface Factory {
|
||||
fun create(context: AppComponentContext, params: Params): GiveApprovalEntryComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.tangem.features.approval.api
|
||||
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Selection-only variant of [GiveApprovalComponent].
|
||||
*
|
||||
* Shows the same approval-type selector UI (LIMITED vs UNLIMITED) but does NOT submit the
|
||||
* approval transaction. Instead, the chosen [ApproveType] is returned to the caller via
|
||||
* [Callback.onApproveTypeSelected] when the user confirms. The caller is responsible for any
|
||||
* downstream action (e.g. building the transaction, sending it, navigation).
|
||||
*
|
||||
* Intended for flows where the approval-type choice has to be collected separately from the
|
||||
* actual fee selection / transaction submission step.
|
||||
*/
|
||||
interface SelectApprovalTypeComponent : ComposableBottomSheetComponent {
|
||||
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
val amountFooter: TextReference,
|
||||
val initialApproveType: ApproveType = ApproveType.LIMITED,
|
||||
val spenderAddress: String,
|
||||
val callback: Callback,
|
||||
)
|
||||
|
||||
interface Callback {
|
||||
fun onApproveTypeSelected(spenderAddress: String, approveType: ApproveType)
|
||||
fun onCancelClick()
|
||||
}
|
||||
|
||||
interface Factory {
|
||||
fun create(context: AppComponentContext, params: Params): SelectApprovalTypeComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package com.tangem.features.approval.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.child
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||
import com.tangem.features.approval.api.GiveApprovalEntryComponent
|
||||
import com.tangem.features.approval.api.SelectApprovalTypeComponent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
/**
|
||||
* Default implementation of [GiveApprovalEntryComponent].
|
||||
*
|
||||
* Picks the concrete child component (full [GiveApprovalComponent] or selection-only
|
||||
* [SelectApprovalTypeComponent]) at construction time based on
|
||||
* [GiveApprovalEntryComponent.Params.mode] and delegates [BottomSheet] and [dismiss] to it.
|
||||
*
|
||||
* Callers only need to depend on [GiveApprovalEntryComponent.Factory] regardless of the
|
||||
* underlying mode.
|
||||
*/
|
||||
internal class DefaultGiveApprovalEntryComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: GiveApprovalEntryComponent.Params,
|
||||
giveApprovalComponentFactory: GiveApprovalComponent.Factory,
|
||||
selectApprovalTypeComponentFactory: SelectApprovalTypeComponent.Factory,
|
||||
) : GiveApprovalEntryComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val delegate: ComposableBottomSheetComponent = when (val mode = params.mode) {
|
||||
is GiveApprovalEntryComponent.Mode.FullApproval -> giveApprovalComponentFactory.create(
|
||||
context = child("giveApprovalEntry_full"),
|
||||
params = mode.params,
|
||||
)
|
||||
is GiveApprovalEntryComponent.Mode.SelectOnly -> selectApprovalTypeComponentFactory.create(
|
||||
context = child("giveApprovalEntry_select"),
|
||||
params = mode.params,
|
||||
)
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
delegate.dismiss()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
delegate.BottomSheet()
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : GiveApprovalEntryComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: GiveApprovalEntryComponent.Params,
|
||||
): DefaultGiveApprovalEntryComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package com.tangem.features.approval.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.approval.api.SelectApprovalTypeComponent
|
||||
import com.tangem.features.approval.impl.model.SelectApprovalTypeModel
|
||||
import com.tangem.features.approval.impl.ui.SelectApprovalTypeContent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
/**
|
||||
* Default implementation of [SelectApprovalTypeComponent].
|
||||
*
|
||||
* Renders the same selection UI as the full [com.tangem.features.approval.api.GiveApprovalComponent]
|
||||
* but without the fee selector block and without dispatching the on-chain approval transaction.
|
||||
* Dismissing the bottom sheet (close button or external dismiss) is treated as a cancel.
|
||||
*/
|
||||
internal class DefaultSelectApprovalTypeComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: SelectApprovalTypeComponent.Params,
|
||||
) : SelectApprovalTypeComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: SelectApprovalTypeModel = getOrCreateModel(params = params)
|
||||
|
||||
private val currency: String = params.cryptoCurrencyStatus.currency.symbol
|
||||
|
||||
override fun dismiss() {
|
||||
params.callback.onCancelClick()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
val uiState by model.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
val config = remember {
|
||||
TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = ::dismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
)
|
||||
}
|
||||
|
||||
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
config = config,
|
||||
containerColor = TangemTheme.colors.background.tertiary,
|
||||
titleText = resourceReference(R.string.give_permission_title),
|
||||
titleAction = TopAppBarButtonUM.Icon(
|
||||
iconRes = R.drawable.ic_close_new_20,
|
||||
onClicked = model::onCancelClick,
|
||||
),
|
||||
) {
|
||||
SelectApprovalTypeContent(
|
||||
currency = currency,
|
||||
uiState = uiState,
|
||||
onChangeApproveType = model::onChangeApproveType,
|
||||
onConfirmClick = model::onConfirmClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : SelectApprovalTypeComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: SelectApprovalTypeComponent.Params,
|
||||
): DefaultSelectApprovalTypeComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,15 @@ package com.tangem.features.approval.impl.di
|
|||
import com.tangem.core.decompose.di.ModelComponent
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||
import com.tangem.features.approval.api.GiveApprovalEntryComponent
|
||||
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
|
||||
import com.tangem.features.approval.api.SelectApprovalTypeComponent
|
||||
import com.tangem.features.approval.impl.DefaultGiveApprovalComponent
|
||||
import com.tangem.features.approval.impl.DefaultGiveApprovalEntryComponent
|
||||
import com.tangem.features.approval.impl.DefaultGiveApprovalFeatureToggles
|
||||
import com.tangem.features.approval.impl.DefaultSelectApprovalTypeComponent
|
||||
import com.tangem.features.approval.impl.model.GiveApprovalModel
|
||||
import com.tangem.features.approval.impl.model.SelectApprovalTypeModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -26,6 +31,18 @@ internal interface GiveApprovalFeatureModule {
|
|||
@Binds
|
||||
@Singleton
|
||||
fun bindComponentFactory(factory: DefaultGiveApprovalComponent.Factory): GiveApprovalComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindSelectApprovalTypeComponentFactory(
|
||||
factory: DefaultSelectApprovalTypeComponent.Factory,
|
||||
): SelectApprovalTypeComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindGiveApprovalEntryComponentFactory(
|
||||
factory: DefaultGiveApprovalEntryComponent.Factory,
|
||||
): GiveApprovalEntryComponent.Factory
|
||||
}
|
||||
|
||||
@Module
|
||||
|
|
@ -36,4 +53,9 @@ internal interface GiveApprovalModelModule {
|
|||
@IntoMap
|
||||
@ClassKey(GiveApprovalModel::class)
|
||||
fun bindModel(model: GiveApprovalModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(SelectApprovalTypeModel::class)
|
||||
fun bindSelectApprovalTypeModel(model: SelectApprovalTypeModel): Model
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.tangem.features.approval.impl.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.features.approval.api.SelectApprovalTypeComponent
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Model for [SelectApprovalTypeComponent].
|
||||
*
|
||||
* Keeps the currently selected [ApproveType] and exposes intents to change it, open the
|
||||
* learn-more URL, confirm the selection, and cancel. Unlike [GiveApprovalModel] this model
|
||||
* does NOT load fees or submit any transaction — confirmation simply notifies the caller
|
||||
* via the params callback with the selected [ApproveType].
|
||||
*/
|
||||
@Stable
|
||||
@ModelScoped
|
||||
internal class SelectApprovalTypeModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
paramsContainer: ParamsContainer,
|
||||
) : Model() {
|
||||
|
||||
private val params: SelectApprovalTypeComponent.Params = paramsContainer.require()
|
||||
|
||||
val uiState: StateFlow<SelectApprovalTypeUM>
|
||||
field = MutableStateFlow(
|
||||
SelectApprovalTypeUM(
|
||||
approveType = params.initialApproveType,
|
||||
subtitle = params.amountFooter,
|
||||
),
|
||||
)
|
||||
|
||||
fun onChangeApproveType(approveType: ApproveType) {
|
||||
if (uiState.value.approveType == approveType) return
|
||||
uiState.update { it.copy(approveType = approveType) }
|
||||
}
|
||||
|
||||
fun onConfirmClick() {
|
||||
params.callback.onApproveTypeSelected(params.spenderAddress, uiState.value.approveType)
|
||||
}
|
||||
|
||||
fun onCancelClick() {
|
||||
params.callback.onCancelClick()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.features.approval.impl.model
|
||||
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal data class SelectApprovalTypeUM(
|
||||
val subtitle: TextReference,
|
||||
val approveType: ApproveType,
|
||||
val approveItems: ImmutableList<ApproveType> = ApproveType.entries.toImmutableList(),
|
||||
)
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package com.tangem.features.approval.impl.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.compose.material3.Text as M3Text
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerWMax
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Reusable row that shows "Amount for {currency}" on the left and the currently selected
|
||||
* [ApproveType] on the right, with a dropdown to switch between the available types.
|
||||
*
|
||||
* Used by both [GiveApprovalContent] (full approval flow) and [SelectApprovalTypeContent]
|
||||
* (selection-only flow).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ApprovalTypeSelectorRow(
|
||||
currency: String,
|
||||
approveType: ApproveType,
|
||||
approveItems: ImmutableList<ApproveType>,
|
||||
onChangeApproveType: (ApproveType) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isExpandSelector by remember { mutableStateOf(false) }
|
||||
var amountSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(TangemTheme.colors.background.action)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple(),
|
||||
onClick = { isExpandSelector = true },
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { amountSize = it }
|
||||
.padding(vertical = 12.dp, horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
M3Text(
|
||||
text = stringResourceSafe(id = R.string.give_permission_rows_amount, currency),
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
maxLines = 1,
|
||||
)
|
||||
SpacerWMax()
|
||||
M3Text(
|
||||
text = approveType.text.resolveReference(),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body1,
|
||||
maxLines = 1,
|
||||
)
|
||||
Icon(
|
||||
painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.ic_chevron_24)),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
|
||||
)
|
||||
}
|
||||
ApprovalTypeDropdown(
|
||||
isExpanded = isExpandSelector,
|
||||
onDismiss = { isExpandSelector = false },
|
||||
onItemClick = { type ->
|
||||
isExpandSelector = false
|
||||
onChangeApproveType(type)
|
||||
},
|
||||
items = approveItems,
|
||||
selectedType = approveType,
|
||||
amountSize = amountSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun ApprovalTypeDropdown(
|
||||
isExpanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onItemClick: (ApproveType) -> Unit,
|
||||
items: ImmutableList<ApproveType>,
|
||||
selectedType: ApproveType,
|
||||
amountSize: IntSize,
|
||||
) {
|
||||
var dropDownWidth by remember { mutableStateOf(IntSize.Zero) }
|
||||
val offsetY = amountSize.height.times(-1)
|
||||
val offsetX = amountSize.width - dropDownWidth.width
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(surface = TangemTheme.colors.background.action),
|
||||
shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(TangemTheme.dimens.radius16)),
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = isExpanded,
|
||||
onDismissRequest = onDismiss,
|
||||
properties = PopupProperties(clippingEnabled = false),
|
||||
offset = with(LocalDensity.current) {
|
||||
DpOffset(x = offsetX.toDp(), y = offsetY.toDp())
|
||||
},
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.background(TangemTheme.colors.background.action)
|
||||
.onSizeChanged { dropDownWidth = it },
|
||||
) {
|
||||
items.forEach { item ->
|
||||
val color = if (item == selectedType) TangemTheme.colors.icon.accent else Color.Transparent
|
||||
|
||||
DropdownMenuItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = {
|
||||
Row {
|
||||
M3Text(
|
||||
text = when (item) {
|
||||
ApproveType.LIMITED -> stringResourceSafe(
|
||||
id = R.string.give_permission_current_transaction,
|
||||
)
|
||||
ApproveType.UNLIMITED -> stringResourceSafe(
|
||||
id = R.string.give_permission_unlimited,
|
||||
)
|
||||
},
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.body1,
|
||||
maxLines = 1,
|
||||
)
|
||||
SpacerWMax()
|
||||
Icon(
|
||||
painter = rememberVectorPainter(
|
||||
image = ImageVector.vectorResource(id = R.drawable.ic_check_24),
|
||||
),
|
||||
tint = color,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = TangemTheme.dimens.size20),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onItemClick.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -1,14 +1,29 @@
|
|||
package com.tangem.features.commonfeatures.api.addfunds
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
interface AddFundsComponent : ComposableContentComponent {
|
||||
interface AddFundsComponent : ComposableBottomSheetComponent {
|
||||
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val launchMode: LaunchMode,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
sealed interface LaunchMode {
|
||||
data class ChooseToken(val userWalletId: UserWalletId) : LaunchMode
|
||||
|
||||
data class TokenActionsOnly(
|
||||
val userWalletId: UserWalletId,
|
||||
val currency: CryptoCurrency,
|
||||
) : LaunchMode
|
||||
|
||||
data class FilteredByRawId(
|
||||
val rawCurrencyId: CryptoCurrency.RawID,
|
||||
) : LaunchMode
|
||||
}
|
||||
|
||||
interface Factory : ComponentFactory<Params, AddFundsComponent>
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
|||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
|
||||
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
|
@ -94,7 +95,14 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal {
|
|||
val wallet: UserWallet,
|
||||
val account: AccountStatus.CryptoPortfolio,
|
||||
val addedCurrency: CryptoCurrencyStatus,
|
||||
val meta: FinishMeta = FinishMeta.None,
|
||||
)
|
||||
|
||||
sealed interface FinishMeta {
|
||||
data object None : FinishMeta
|
||||
data object OnQuickAction : FinishMeta
|
||||
data class OnBottomAction(val action: BottomAction) : FinishMeta
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package com.tangem.features.commonfeatures.api.tokenactions
|
||||
|
||||
enum class BottomAction { GoToToken, None }
|
||||
|
|
@ -1,93 +1,232 @@
|
|||
package com.tangem.features.commonfeatures.impl.addfunds
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.child
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBarType
|
||||
import com.tangem.core.ui.extensions.clickableSingle
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemeRedesign
|
||||
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
|
||||
import com.tangem.features.commonfeatures.impl.R
|
||||
import com.tangem.features.commonfeatures.impl.addfunds.model.AddFundsModel
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.addfunds.model.uiSpec
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
|
||||
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import com.tangem.core.ui.R as CoreR
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultAddFundsComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: AddFundsComponent.Params,
|
||||
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
|
||||
tokenActionsComponentFactory: TokenActionsComponent.Factory,
|
||||
userPortfolioComponentFactory: UserPortfolioComponent.Factory,
|
||||
walletFeatureToggles: WalletFeatureToggles,
|
||||
) : AppComponentContext by appComponentContext, AddFundsComponent {
|
||||
|
||||
private val model: AddFundsModel = getOrCreateModel(params)
|
||||
|
||||
private val chooseTokenComponent: ChooseTokenComponent = chooseTokenComponentFactory.create(
|
||||
context = child(key = "addFundsChooseToken"),
|
||||
params = ChooseTokenComponent.Params(bridge = model.chooseTokenBridge),
|
||||
)
|
||||
private val isCompactTokenActions: Boolean = params.launchMode is AddFundsComponent.LaunchMode.TokenActionsOnly
|
||||
|
||||
private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create(
|
||||
context = child(key = "addFundsTokenActions"),
|
||||
params = TokenActionsComponent.Params(
|
||||
data = model.tokenActionsData,
|
||||
callbacks = model,
|
||||
bottomAction = TokenActionsComponent.BottomAction.GoToToken,
|
||||
isRedesignForced = true,
|
||||
),
|
||||
)
|
||||
private val isAddFundsStage1Enabled: Boolean = walletFeatureToggles.isAddFundsStage1Enabled
|
||||
|
||||
private val tokenActionsComponent: TokenActionsComponent by lazy {
|
||||
tokenActionsComponentFactory.create(
|
||||
context = child(key = "addFundsTokenActions"),
|
||||
params = TokenActionsComponent.Params(
|
||||
data = model.tokenActionsData,
|
||||
callbacks = model,
|
||||
bottomAction = model.currentBottomAction,
|
||||
isRedesignForced = true,
|
||||
isCompact = isCompactTokenActions,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private val chooseTokenComponent: ChooseTokenComponent? by lazy {
|
||||
(params.launchMode as? AddFundsComponent.LaunchMode.ChooseToken)?.let {
|
||||
chooseTokenComponentFactory.create(
|
||||
context = child(key = "addFundsChooseToken"),
|
||||
params = ChooseTokenComponent.Params(
|
||||
bridge = model.chooseTokenBridge,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val userPortfolioComponent: UserPortfolioComponent by lazy {
|
||||
userPortfolioComponentFactory.create(
|
||||
context = child(key = "addFundsUserPortfolio"),
|
||||
params = UserPortfolioComponent.Params(
|
||||
uiState = model.userPortfolioStateController.uiState,
|
||||
callbacks = object : UserPortfolioComponent.Callbacks {
|
||||
override fun onContinueFromUserPortfolio() = Unit
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun dismiss() = model.onDismiss()
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
chooseTokenComponent.Content(modifier)
|
||||
val isTokenActionsShown by model.isTokenActionsShown.collectAsStateWithLifecycle()
|
||||
if (isTokenActionsShown) {
|
||||
// force use redesign theme here according to the task requirements, will be reworked in the next release
|
||||
TangemThemeRedesign {
|
||||
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = model::onTokenActionsDismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
),
|
||||
containerColor = TangemTheme.colors2.surface.level2,
|
||||
scrollableContent = true,
|
||||
title = {
|
||||
TangemModalBottomSheetTitle(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
title = resourceReference(R.string.common_get_token),
|
||||
endIconRes = R.drawable.ic_close_24,
|
||||
onEndClick = model::onTokenActionsDismiss,
|
||||
)
|
||||
},
|
||||
content = { _ ->
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
start = TangemTheme.dimens2.x4,
|
||||
top = TangemTheme.dimens2.x2,
|
||||
end = TangemTheme.dimens2.x4,
|
||||
bottom = TangemTheme.dimens2.x4,
|
||||
),
|
||||
) {
|
||||
tokenActionsComponent.Content(Modifier)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
override fun BottomSheet() {
|
||||
val route by model.uiRoute.collectAsStateWithLifecycle()
|
||||
val canGoBack by model.canGoBack.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(route) {
|
||||
if (route != AddFundsModel.UiRoute.UserPortfolio) return@LaunchedEffect
|
||||
val mode = params.launchMode as? AddFundsComponent.LaunchMode.FilteredByRawId ?: return@LaunchedEffect
|
||||
model.userPortfolioStateController.updateAndWaitNotNullState(
|
||||
allAvailableData = model.buildAvailableToAddDataForChooser(),
|
||||
rawCurrencyId = mode.rawCurrencyId,
|
||||
)
|
||||
}
|
||||
|
||||
WithOptionalRedesignTheme(isEnabled = isAddFundsStage1Enabled) {
|
||||
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
onBack = if (canGoBack) model::onBack else ::dismiss,
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = ::dismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
),
|
||||
type = when (params.launchMode) {
|
||||
is AddFundsComponent.LaunchMode.TokenActionsOnly -> TangemBottomSheetType.Modal
|
||||
is AddFundsComponent.LaunchMode.ChooseToken -> TangemBottomSheetType.Default
|
||||
is AddFundsComponent.LaunchMode.FilteredByRawId ->
|
||||
if (route is AddFundsModel.UiRoute.TokenActions) {
|
||||
TangemBottomSheetType.Default
|
||||
} else {
|
||||
TangemBottomSheetType.Modal
|
||||
}
|
||||
},
|
||||
containerColor = TangemTheme.colors2.surface.level2,
|
||||
title = {
|
||||
AddFundsBottomSheetTitle(
|
||||
route = route,
|
||||
canGoBack = canGoBack,
|
||||
onBackClick = model::onBack,
|
||||
onCloseClick = ::dismiss,
|
||||
)
|
||||
},
|
||||
content = {
|
||||
val animatedContentModifier =
|
||||
if (params.launchMode is AddFundsComponent.LaunchMode.ChooseToken) {
|
||||
Modifier.fillMaxSize()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = route,
|
||||
modifier = animatedContentModifier,
|
||||
label = "AddFundsContentAnimation",
|
||||
) { animatedRoute ->
|
||||
AddFundsRouteContent(
|
||||
route = animatedRoute,
|
||||
shouldFillHeight = !isCompactTokenActions && animatedRoute.uiSpec().shouldFillHeight,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddFundsRouteContent(route: AddFundsModel.UiRoute, shouldFillHeight: Boolean) {
|
||||
val spec = route.uiSpec()
|
||||
val horizontalPadding = if (spec.shouldApplyHorizontalPadding) {
|
||||
Modifier.padding(horizontal = TangemTheme.dimens2.x4)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
val sizeModifier = if (shouldFillHeight) Modifier.fillMaxSize() else Modifier.fillMaxWidth()
|
||||
RenderRoute(route, horizontalPadding.then(sizeModifier))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRoute(route: AddFundsModel.UiRoute, modifier: Modifier = Modifier) {
|
||||
when (route) {
|
||||
AddFundsModel.UiRoute.Loading -> Unit
|
||||
AddFundsModel.UiRoute.ChooseToken -> chooseTokenComponent?.Content(modifier)
|
||||
AddFundsModel.UiRoute.UserPortfolio -> CompositionLocalProvider(
|
||||
LocalTangemBottomSheetContentBottomInset provides TangemTheme.dimens2.x4,
|
||||
) {
|
||||
userPortfolioComponent.Content(modifier)
|
||||
}
|
||||
AddFundsModel.UiRoute.TokenActions -> tokenActionsComponent.Content(modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddFundsBottomSheetTitle(
|
||||
route: AddFundsModel.UiRoute,
|
||||
canGoBack: Boolean,
|
||||
onBackClick: () -> Unit,
|
||||
onCloseClick: () -> Unit,
|
||||
) {
|
||||
TangemTopBar(
|
||||
title = route.uiSpec().title,
|
||||
type = TangemTopBarType.BottomSheet,
|
||||
startContent = if (canGoBack) {
|
||||
{ CircleIconButton(iconRes = CoreR.drawable.ic_arrow_back_28, onClick = onBackClick) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
endContent = {
|
||||
CircleIconButton(iconRes = R.drawable.ic_close_24, onClick = onCloseClick)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WithOptionalRedesignTheme(isEnabled: Boolean, content: @Composable () -> Unit) {
|
||||
if (isEnabled) {
|
||||
TangemThemeRedesign(content = content)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CircleIconButton(iconRes: Int, onClick: () -> Unit) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = iconRes),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors2.graphic.neutral.primary,
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens2.x11)
|
||||
.background(
|
||||
color = TangemTheme.colors2.button.backgroundSecondary,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.clickableSingle(onClick = onClick)
|
||||
.padding(TangemTheme.dimens2.x2),
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ internal sealed class AddFundsAnalyticsEvent(
|
|||
|
||||
class ButtonReceive : AddFundsAnalyticsEvent(event = "Button - Receive")
|
||||
|
||||
class ButtonGoToToken : AddFundsAnalyticsEvent(event = "Button - Go to Token")
|
||||
|
||||
companion object {
|
||||
private const val CATEGORY = "Add Funds"
|
||||
const val SOURCE_MAIN_SCREEN = "Main Screen"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.features.commonfeatures.impl.addfunds.model
|
||||
|
||||
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.ui.markets.action.CryptoCurrencyData
|
||||
|
|
@ -8,14 +9,25 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
|
|||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
|
||||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.filterCryptoPortfolio
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
|
||||
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
|
||||
import com.tangem.features.commonfeatures.impl.addfunds.analytics.AddFundsAnalyticsEvent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.state.UserPortfolioStateController
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
|
@ -23,98 +35,257 @@ import kotlinx.coroutines.launch
|
|||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
@Suppress("LongParameterList")
|
||||
internal class AddFundsModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
|
||||
userPortfolioStateControllerFactory: UserPortfolioStateController.Factory,
|
||||
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
|
||||
private val appRouter: AppRouter,
|
||||
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase,
|
||||
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
private val appRouter: AppRouter,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
) : Model(), TokenActionsComponent.Callbacks {
|
||||
|
||||
private val params = paramsContainer.require<AddFundsComponent.Params>()
|
||||
val launchMode: AddFundsComponent.LaunchMode = params.launchMode
|
||||
|
||||
val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create(
|
||||
modelScope = modelScope,
|
||||
settings = ChooseTokenBridge.Settings.AddFunds,
|
||||
analyticsPayload = setOf(
|
||||
ChooseTokenAnalyticsPayload.ScreensSources(SCREEN_SOURCE),
|
||||
),
|
||||
)
|
||||
private val routeStack = MutableStateFlow(listOf<UiRoute>(UiRoute.Loading))
|
||||
|
||||
private val selectedToken = MutableStateFlow<ChooseTokenResult?>(null)
|
||||
val uiRoute: StateFlow<UiRoute> = routeStack
|
||||
.map { it.last() }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = UiRoute.Loading)
|
||||
|
||||
val isTokenActionsShown: StateFlow<Boolean> = selectedToken
|
||||
.map { it != null }
|
||||
val canGoBack: StateFlow<Boolean> = routeStack
|
||||
.map { it.size > 1 }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = false)
|
||||
|
||||
private val tokenActionsTrigger = MutableStateFlow<TokenActionsRequest?>(null)
|
||||
private val filteredEntries = MutableStateFlow<List<FilteredEntry>>(emptyList())
|
||||
|
||||
val currentBottomAction: MutableStateFlow<BottomAction> =
|
||||
MutableStateFlow(BottomAction.None)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val tokenActionsData: Flow<CryptoCurrencyData> = selectedToken
|
||||
val tokenActionsData: Flow<CryptoCurrencyData> = tokenActionsTrigger
|
||||
.filterNotNull()
|
||||
.flatMapLatest { result ->
|
||||
val cryptoPortfolio = result.account as? AccountStatus.CryptoPortfolio
|
||||
?: return@flatMapLatest emptyFlow()
|
||||
getCryptoCurrencyActionsUseCase(
|
||||
accountId = cryptoPortfolio.account.accountId,
|
||||
currency = result.currency.currency,
|
||||
).map { actionsState ->
|
||||
.flatMapLatest { request ->
|
||||
combine(
|
||||
getCryptoCurrencyActionsUseCase(
|
||||
accountId = request.account.account.accountId,
|
||||
currency = request.status.currency,
|
||||
),
|
||||
isAccountsModeEnabledUseCase(),
|
||||
) { actionsState, isAccountMode ->
|
||||
CryptoCurrencyData(
|
||||
userWallet = result.wallet,
|
||||
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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
package com.tangem.features.commonfeatures.impl.addfunds.model
|
||||
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.features.commonfeatures.impl.R
|
||||
import com.tangem.core.ui.R as CoreR
|
||||
|
||||
internal data class AddFundsRouteUiSpec(
|
||||
val title: TextReference,
|
||||
val shouldApplyHorizontalPadding: Boolean,
|
||||
val shouldFillHeight: Boolean,
|
||||
)
|
||||
|
||||
internal fun AddFundsModel.UiRoute.uiSpec(): AddFundsRouteUiSpec = when (this) {
|
||||
AddFundsModel.UiRoute.Loading -> AddFundsRouteUiSpec(
|
||||
title = resourceReference(R.string.common_add_funds),
|
||||
shouldApplyHorizontalPadding = false,
|
||||
shouldFillHeight = false,
|
||||
)
|
||||
AddFundsModel.UiRoute.ChooseToken -> AddFundsRouteUiSpec(
|
||||
title = resourceReference(R.string.common_add_funds),
|
||||
shouldApplyHorizontalPadding = false,
|
||||
shouldFillHeight = true,
|
||||
)
|
||||
AddFundsModel.UiRoute.UserPortfolio -> AddFundsRouteUiSpec(
|
||||
title = resourceReference(R.string.common_add_funds),
|
||||
shouldApplyHorizontalPadding = false,
|
||||
shouldFillHeight = false,
|
||||
)
|
||||
AddFundsModel.UiRoute.TokenActions -> AddFundsRouteUiSpec(
|
||||
title = resourceReference(CoreR.string.common_get_token),
|
||||
shouldApplyHorizontalPadding = true,
|
||||
shouldFillHeight = true,
|
||||
)
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ import com.tangem.features.commonfeatures.impl.R
|
|||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioFooterKind
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.uiSpec
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import com.arkivanov.decompose.router.stack.ChildStack
|
|||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
|
||||
|
||||
@Composable
|
||||
internal fun AddToPortfolioBottomSheetSwitch(
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import com.tangem.core.ui.res.TangemTheme
|
|||
import com.tangem.features.commonfeatures.impl.R
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.uiSpec
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
|
||||
|
||||
@Composable
|
||||
internal fun AddToPortfolioBottomSheetV2(
|
||||
|
|
@ -39,6 +39,11 @@ internal fun AddToPortfolioBottomSheetV2(
|
|||
contentStack.value = stack
|
||||
}
|
||||
|
||||
val type = if (stack.active.configuration is AddToPortfolioRoutes.TokenActions) {
|
||||
TangemBottomSheetType.Default
|
||||
} else {
|
||||
TangemBottomSheetType.Modal
|
||||
}
|
||||
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
|
||||
onBack = onBack,
|
||||
config = TangemBottomSheetConfig(
|
||||
|
|
@ -46,7 +51,7 @@ internal fun AddToPortfolioBottomSheetV2(
|
|||
onDismissRequest = onDismiss,
|
||||
content = TangemBottomSheetConfigContent.Empty,
|
||||
),
|
||||
type = TangemBottomSheetType.Modal,
|
||||
type = type,
|
||||
containerColor = TangemTheme.colors2.surface.level2,
|
||||
title = {
|
||||
AddToPortfolioBottomSheetTitle(
|
||||
|
|
@ -86,7 +91,9 @@ private fun AddToPortfolioRouteContent(animatedStack: ChildStack<AddToPortfolioR
|
|||
Spacer(modifier = Modifier.height(scrollBottomReserve))
|
||||
}
|
||||
} else {
|
||||
animatedStack.active.instance.Content(modifier = baseModifier)
|
||||
val isFullScreenRoute = animatedStack.active.configuration is AddToPortfolioRoutes.TokenActions
|
||||
val sizeModifier = if (isFullScreenRoute) Modifier.fillMaxSize() else Modifier
|
||||
animatedStack.active.instance.Content(modifier = baseModifier.then(sizeModifier))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,14 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
|
|||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel
|
||||
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
|
||||
|
|
@ -60,6 +63,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
|
|||
params = TokenActionsComponent.Params(
|
||||
callbacks = model,
|
||||
data = model.tokenActionsData,
|
||||
bottomAction = flowOf(BottomAction.GoToToken),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,13 +92,5 @@ internal class PortfolioAnalyticsEvent(
|
|||
if (source != null) put("Source", source)
|
||||
},
|
||||
)
|
||||
|
||||
fun getTokenLater() = PortfolioAnalyticsEvent(
|
||||
event = "Popup Get token - Button Later",
|
||||
category = category,
|
||||
params = buildMap {
|
||||
if (source != null) put("Source", source)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@ import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioCompo
|
|||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.DefaultAddToPortfolioManager
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.DefaultUserPortfolioComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.DefaultUserPortfolioComponent
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import com.tangem.core.decompose.model.Model
|
|||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.model.TokenActionsModel
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
|
|||
|
|
@ -29,18 +29,20 @@ import com.tangem.domain.models.wallet.isMultiCurrency
|
|||
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.*
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
|
||||
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
|
||||
import com.tangem.features.commonfeatures.impl.R
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state.UserPortfolioStateController
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.state.UserPortfolioStateController
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -107,10 +109,6 @@ internal class AddToPortfolioModel @Inject constructor(
|
|||
startRedesignAddToPortfolioFlow()
|
||||
}
|
||||
|
||||
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action) {
|
||||
analyticsEventHandler.send(eventBuilder.getTokenActionClick(actionUM = action))
|
||||
}
|
||||
|
||||
private fun <T> replayMutableSharedFlow() = MutableSharedFlow<T>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
|
|
@ -155,7 +153,7 @@ internal class AddToPortfolioModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
private fun startRedesignAddToPortfolioFlow() {
|
||||
channelFlow<Unit> {
|
||||
fun finishSuccessFlow(result: AddToPortfolioManager.Result) {
|
||||
|
|
@ -312,9 +310,15 @@ internal class AddToPortfolioModel @Inject constructor(
|
|||
.onEmpty { finishSuccessFlow(result) }
|
||||
.launchIn(this)
|
||||
|
||||
callbackDelegate.onChooseTokenBottomActionClick.receiveAsFlow().first()
|
||||
analyticsEventHandler.send(eventBuilder.getTokenLater())
|
||||
finishSuccessFlow(result)
|
||||
when (val meta = terminalTokenActionsFlow().first()) {
|
||||
is AddToPortfolioManager.FinishMeta.OnBottomAction -> {
|
||||
finishSuccessFlow(result.copy(meta = meta))
|
||||
}
|
||||
AddToPortfolioManager.FinishMeta.OnQuickAction -> {
|
||||
finishSuccessFlow(result.copy(meta = meta))
|
||||
}
|
||||
AddToPortfolioManager.FinishMeta.None -> Unit
|
||||
}
|
||||
}
|
||||
.catch { throwable ->
|
||||
TangemLogger.e("Error", throwable)
|
||||
|
|
@ -323,6 +327,21 @@ internal class AddToPortfolioModel @Inject constructor(
|
|||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun terminalTokenActionsFlow() = channelFlow {
|
||||
callbackDelegate.onChooseTokenBottomActionClick.receiveAsFlow()
|
||||
.onEach { bottomAction ->
|
||||
channel.send(AddToPortfolioManager.FinishMeta.OnBottomAction(bottomAction))
|
||||
}
|
||||
.launchIn(this)
|
||||
callbackDelegate.onQuickActionClick.receiveAsFlow()
|
||||
.onEach { (action, shouldDismiss) ->
|
||||
analyticsEventHandler.send(eventBuilder.getTokenActionClick(actionUM = action))
|
||||
if (shouldDismiss) channel.send(AddToPortfolioManager.FinishMeta.OnQuickAction)
|
||||
}
|
||||
.launchIn(this)
|
||||
awaitClose()
|
||||
}
|
||||
|
||||
private suspend fun getInitialSelection(
|
||||
initialData: AvailableToAddData,
|
||||
): AddToPortfolioInitialSelectionResolver.InitialSelection? {
|
||||
|
|
@ -529,7 +548,8 @@ internal class AddToPortfolioCallbackDelegate @Inject constructor() :
|
|||
UserPortfolioComponent.Callbacks {
|
||||
|
||||
val onNetworkSelected = Channel<TokenMarketInfo.Network>()
|
||||
val onChooseTokenBottomActionClick = Channel<Unit>()
|
||||
val onChooseTokenBottomActionClick = Channel<BottomAction>()
|
||||
val onQuickActionClick = Channel<Pair<TokenActionsBSContentUM.Action, Boolean>>()
|
||||
val onChangeNetworkClick = Channel<Unit>()
|
||||
val onChangePortfolioClick = Channel<Unit>()
|
||||
val onTokenAdded = Channel<CryptoCurrencyStatus>()
|
||||
|
|
@ -539,8 +559,12 @@ internal class AddToPortfolioCallbackDelegate @Inject constructor() :
|
|||
onNetworkSelected.trySend(network)
|
||||
}
|
||||
|
||||
override fun onBottomActionClick() {
|
||||
onChooseTokenBottomActionClick.trySend(Unit)
|
||||
override fun onBottomActionClick(bottomAction: BottomAction) {
|
||||
onChooseTokenBottomActionClick.trySend(bottomAction)
|
||||
}
|
||||
|
||||
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {
|
||||
onQuickActionClick.trySend(action to shouldDismiss)
|
||||
}
|
||||
|
||||
override fun onChangeNetworkClick() {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ internal fun AddToPortfolioRoutes.uiSpec(): AddToPortfolioRouteUiSpec = when (th
|
|||
)
|
||||
AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec(
|
||||
title = resourceReference(R.string.common_get_token),
|
||||
isScrollable = true,
|
||||
isScrollable = false,
|
||||
shouldApplyHorizontalPadding = true,
|
||||
footer = AddToPortfolioFooterKind.None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ internal data class ChooseTokenFullUM(
|
|||
|
||||
internal data class ChooseTokenInitialUM(
|
||||
val screenTitle: TextReference,
|
||||
val isAppBarShown: Boolean,
|
||||
val onCloseClick: () -> Unit,
|
||||
val searchBar: SearchBarUM,
|
||||
)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio
|
||||
package com.tangem.features.commonfeatures.impl.tokenactions
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -18,14 +18,16 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
|||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||
import com.tangem.domain.models.TokenReceiveConfig
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContentV2
|
||||
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.model.TokenActionsModel
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.ui.TokenActionsContent
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.ui.TokenActionsContentV2
|
||||
import com.tangem.features.tokenreceive.TokenReceiveComponent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
internal class TokenActionsComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
|
|
@ -74,15 +76,14 @@ internal class TokenActionsComponent @AssistedInject constructor(
|
|||
data class Params(
|
||||
val data: Flow<CryptoCurrencyData>,
|
||||
val callbacks: Callbacks,
|
||||
val bottomAction: BottomAction = BottomAction.Later,
|
||||
val bottomAction: Flow<BottomAction> = flowOf(BottomAction.None),
|
||||
val isRedesignForced: Boolean = false,
|
||||
val isCompact: Boolean = false,
|
||||
)
|
||||
|
||||
enum class BottomAction { Later, GoToToken }
|
||||
|
||||
interface Callbacks {
|
||||
fun onBottomActionClick()
|
||||
fun onQuickActionClick(action: TokenActionsBSContentUM.Action) {}
|
||||
fun onBottomActionClick(bottomAction: BottomAction)
|
||||
fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
|
||||
package com.tangem.features.commonfeatures.impl.tokenactions.model
|
||||
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
|
|
@ -12,8 +12,8 @@ import com.tangem.domain.appcurrency.model.AppCurrency
|
|||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
import com.tangem.domain.models.TokenReceiveConfig
|
||||
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -45,7 +45,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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
|
||||
package com.tangem.features.commonfeatures.impl.tokenactions.model
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import com.tangem.common.getTotalCryptoAmount
|
||||
|
|
@ -11,6 +11,7 @@ import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToI
|
|||
import com.tangem.common.ui.markets.action.CryptoCurrencyData
|
||||
import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions
|
||||
import com.tangem.common.ui.markets.action.TokenActionsHandler
|
||||
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
|
||||
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
|
|
@ -30,9 +31,9 @@ import com.tangem.features.commonfeatures.impl.R
|
|||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.PortfolioBadgeUM
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.PortfolioBadgeUM
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
|
||||
import java.math.BigDecimal
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -50,6 +51,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
tokenActionsHandler: TokenActionsHandler,
|
||||
appCurrency: AppCurrency,
|
||||
isBalanceHidden: Boolean,
|
||||
bottomAction: BottomAction,
|
||||
): TokenActionsUM {
|
||||
return if (designFeatureToggles.isRedesignEnabled || params.isRedesignForced) {
|
||||
buildV2(
|
||||
|
|
@ -57,11 +59,13 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
tokenActionsHandler = tokenActionsHandler,
|
||||
appCurrency = appCurrency,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
bottomAction = bottomAction,
|
||||
)
|
||||
} else {
|
||||
buildV1(
|
||||
cryptoCurrencyData = cryptoCurrencyData,
|
||||
tokenActionsHandler = tokenActionsHandler,
|
||||
bottomAction = bottomAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +73,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
private fun buildV1(
|
||||
cryptoCurrencyData: CryptoCurrencyData,
|
||||
tokenActionsHandler: TokenActionsHandler,
|
||||
bottomAction: BottomAction,
|
||||
): TokenActionsUM {
|
||||
val status = cryptoCurrencyData.status
|
||||
val tokenUM = TokenItemState.Content(
|
||||
|
|
@ -88,9 +93,9 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
tokenActionsHandler = tokenActionsHandler,
|
||||
isRedesignEnabled = false,
|
||||
),
|
||||
bottomActionText = bottomActionText(params.bottomAction),
|
||||
bottomActionText = bottomActionText(bottomAction),
|
||||
onBottomActionClick = {
|
||||
params.callbacks.onBottomActionClick()
|
||||
params.callbacks.onBottomActionClick(bottomAction)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -100,6 +105,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
tokenActionsHandler: TokenActionsHandler,
|
||||
appCurrency: AppCurrency,
|
||||
isBalanceHidden: Boolean,
|
||||
bottomAction: BottomAction,
|
||||
): TokenActionsUM {
|
||||
val status = cryptoCurrencyData.status
|
||||
val tokenUM = TokenItemState.Content(
|
||||
|
|
@ -119,19 +125,20 @@ internal class TokenActionsUiBuilder @Inject constructor(
|
|||
tokenActionsHandler = tokenActionsHandler,
|
||||
isRedesignEnabled = true,
|
||||
),
|
||||
bottomActionText = bottomActionText(params.bottomAction),
|
||||
bottomActionText = bottomActionText(bottomAction),
|
||||
onBottomActionClick = {
|
||||
params.callbacks.onBottomActionClick()
|
||||
params.callbacks.onBottomActionClick(bottomAction)
|
||||
},
|
||||
isBalancesHidden = isBalanceHidden,
|
||||
portfolioBadge = createPortfolioBadge(cryptoCurrencyData = cryptoCurrencyData),
|
||||
isCompact = params.isCompact,
|
||||
)
|
||||
}
|
||||
|
||||
private fun bottomActionText(action: TokenActionsComponent.BottomAction): TextReference {
|
||||
private fun bottomActionText(action: BottomAction): TextReference? {
|
||||
return when (action) {
|
||||
TokenActionsComponent.BottomAction.Later -> resourceReference(R.string.common_later)
|
||||
TokenActionsComponent.BottomAction.GoToToken -> resourceReference(R.string.common_go_to_token)
|
||||
BottomAction.GoToToken -> resourceReference(R.string.common_go_to_token)
|
||||
BottomAction.None -> null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
|
||||
package com.tangem.features.commonfeatures.impl.tokenactions.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
|
|
@ -39,7 +39,7 @@ import com.tangem.core.ui.res.TangemColorPalette
|
|||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.features.commonfeatures.impl.R
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -73,13 +73,15 @@ internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Mod
|
|||
}
|
||||
}
|
||||
|
||||
SpacerH16()
|
||||
if (state.bottomActionText != null) {
|
||||
SpacerH16()
|
||||
|
||||
SecondaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = state.bottomActionText.resolveReference(),
|
||||
onClick = state.onBottomActionClick,
|
||||
)
|
||||
SecondaryButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = state.bottomActionText.resolveReference(),
|
||||
onClick = state.onBottomActionClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
|
||||
package com.tangem.features.commonfeatures.impl.tokenactions.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
|
|
@ -43,8 +43,8 @@ import com.tangem.core.ui.format.bigdecimal.formatStyled
|
|||
import com.tangem.core.ui.format.bigdecimal.price
|
||||
import com.tangem.core.ui.res.*
|
||||
import com.tangem.features.commonfeatures.impl.R
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.PortfolioBadgeUM
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.PortfolioBadgeUM
|
||||
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import java.math.BigDecimal
|
||||
|
|
@ -52,53 +52,84 @@ import java.util.UUID
|
|||
|
||||
@Composable
|
||||
internal fun TokenActionsContentV2(state: TokenActionsUM, modifier: Modifier = Modifier) {
|
||||
if (state.isCompact) {
|
||||
CompactLayout(state, modifier)
|
||||
} else {
|
||||
FullLayout(state, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactLayout(state: TokenActionsUM, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
QuickActionsList(state)
|
||||
SpacerH(TangemTheme.dimens2.x4)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FullLayout(state: TokenActionsUM, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
TokenHeader(
|
||||
addedToken = state.token,
|
||||
portfolioBadge = state.portfolioBadge,
|
||||
isBalanceHidden = state.isBalancesHidden,
|
||||
)
|
||||
|
||||
SpacerH(TangemTheme.dimens2.x2)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
state.quickActions.actions.fastForEach { actionUM ->
|
||||
key(actionUM.title) {
|
||||
val transitionState = remember {
|
||||
MutableTransitionState(initialState = false).apply { targetState = true }
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visibleState = transitionState,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
TokenActionRow(
|
||||
iconRes = actionUM.icon,
|
||||
title = actionUM.title,
|
||||
description = actionUM.description,
|
||||
onClick = { state.quickActions.onQuickActionClick(actionUM) },
|
||||
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }
|
||||
.takeIf { actionUM.isLongClickAvailable },
|
||||
)
|
||||
}
|
||||
}
|
||||
TokenHeader(
|
||||
addedToken = state.token,
|
||||
portfolioBadge = state.portfolioBadge,
|
||||
isBalanceHidden = state.isBalancesHidden,
|
||||
)
|
||||
}
|
||||
QuickActionsList(state)
|
||||
val bottomText = state.bottomActionText
|
||||
if (bottomText != null) {
|
||||
SpacerH(TangemTheme.dimens2.x6)
|
||||
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
|
||||
SecondaryTangemButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = state.onBottomActionClick,
|
||||
text = bottomText,
|
||||
size = TangemButtonSize.X12,
|
||||
shape = TangemButtonShape.Rounded,
|
||||
)
|
||||
}
|
||||
}
|
||||
SpacerH(TangemTheme.dimens2.x4)
|
||||
}
|
||||
}
|
||||
|
||||
SpacerH(TangemTheme.dimens2.x6)
|
||||
|
||||
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
|
||||
SecondaryTangemButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = state.onBottomActionClick,
|
||||
text = state.bottomActionText,
|
||||
size = TangemButtonSize.X12,
|
||||
shape = TangemButtonShape.Rounded,
|
||||
)
|
||||
@Composable
|
||||
private fun QuickActionsList(state: TokenActionsUM, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
) {
|
||||
state.quickActions.actions.fastForEach { actionUM ->
|
||||
key(actionUM.title) {
|
||||
val transitionState = remember {
|
||||
MutableTransitionState(initialState = false).apply { targetState = true }
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visibleState = transitionState,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
TokenActionRow(
|
||||
iconRes = actionUM.icon,
|
||||
title = actionUM.title,
|
||||
description = actionUM.description,
|
||||
onClick = { state.quickActions.onQuickActionClick(actionUM) },
|
||||
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }
|
||||
.takeIf { actionUM.isLongClickAvailable },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state
|
||||
package com.tangem.features.commonfeatures.impl.tokenactions.ui.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.common.ui.markets.action.QuickActions
|
||||
|
|
@ -10,10 +10,11 @@ import com.tangem.core.ui.extensions.TextReference
|
|||
internal data class TokenActionsUM(
|
||||
val token: TokenItemState,
|
||||
val quickActions: QuickActions,
|
||||
val bottomActionText: TextReference,
|
||||
val bottomActionText: TextReference?,
|
||||
val onBottomActionClick: () -> Unit,
|
||||
val isBalancesHidden: Boolean = false,
|
||||
val portfolioBadge: PortfolioBadgeUM = PortfolioBadgeUM.None,
|
||||
val isCompact: Boolean = false,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio
|
||||
package com.tangem.features.commonfeatures.impl.userportfolio
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -7,7 +7,7 @@ import com.tangem.common.ui.markets.tokenselector.TokenSelectorEmbeddedContent
|
|||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioModel
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio
|
||||
package com.tangem.features.commonfeatures.impl.userportfolio
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
internal interface UserPortfolioComponent : ComposableContentComponent {
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model
|
||||
package com.tangem.features.commonfeatures.impl.userportfolio.model
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model
|
||||
package com.tangem.features.commonfeatures.impl.userportfolio.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state
|
||||
package com.tangem.features.commonfeatures.impl.userportfolio.state
|
||||
|
||||
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
|
|
@ -7,8 +7,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer.UserPortfolioSectionsTransformer
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.transformer.UserPortfolioSectionsTransformer
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer
|
||||
package com.tangem.features.commonfeatures.impl.userportfolio.transformer
|
||||
|
||||
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
|
||||
import com.tangem.common.ui.account.toUM
|
||||
|
|
@ -21,7 +21,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
|||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
|
||||
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
|
||||
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
|
||||
import com.tangem.utils.StringsSigns
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -171,7 +171,7 @@ internal class PortfolioBlockModel @Inject constructor(
|
|||
tokenSymbol = firstCurrency.symbol,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
onRowClick = { parentRouter?.openAddToPortfolioViaUserPortfolio(currencyRawId) },
|
||||
onAddFundsClick = {},
|
||||
onAddFundsClick = { parentRouter?.openAddFunds(currencyRawId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
|
|||
),
|
||||
)
|
||||
},
|
||||
initialFiatAmount = params.initialFiatAmount,
|
||||
),
|
||||
)
|
||||
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
14
features/survey/api/build.gradle.kts
Normal file
14
features/survey/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.features.survey
|
||||
|
||||
interface SurveyFeatureToggles {
|
||||
|
||||
val areSurveysEnabled: Boolean
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.features.survey.deeplink
|
||||
|
||||
interface SurveyDeepLinkHandler {
|
||||
|
||||
interface Factory {
|
||||
fun create(queryParams: Map<String, String>): SurveyDeepLinkHandler
|
||||
}
|
||||
}
|
||||
61
features/survey/impl/build.gradle.kts
Normal file
61
features/survey/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.features.swap
|
||||
|
||||
interface SwapFeatureToggles {
|
||||
val isYieldSwapEnabled: Boolean
|
||||
val isSwapSwitchToTransferEnabled: Boolean
|
||||
val isSwapIntegratedApproveEnabled: Boolean
|
||||
val isSwapAbEnabled: Boolean
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue