Updated on 2026-08-14

This commit is contained in:
Tangem 2025-06-30 17:51:33 +05:00
parent 7ee8825f97
commit 4febcb6c93
9 changed files with 473 additions and 3 deletions

View file

@ -0,0 +1,104 @@
package com.tangem.features.swap.v2.impl.amount
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
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.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams.AmountBlockParams
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.SwapAmountModel
import com.tangem.features.swap.v2.impl.amount.ui.SwapAmountBlockContent
import com.tangem.features.swap.v2.impl.chooseprovider.SwapChooseProviderComponent
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
internal class SwapAmountBlockComponent(
private val appComponentContext: AppComponentContext,
private val params: AmountBlockParams,
private val onResult: (SwapAmountUM) -> Unit,
val onClick: () -> Unit,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SwapAmountModel = getOrCreateModel(params = params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = true,
childFactory = ::bottomSheetChild,
)
init {
model.uiState.onEach {
onResult(it)
}.launchIn(componentScope)
}
fun updateState(amountUM: SwapAmountUM) = model.updateState(amountUM)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val isClickEnabled by params.blockClickEnableFlow.collectAsStateWithLifecycle()
SwapAmountBlockContent(
amountUM = state,
onInfoClick = model::onInfoClick,
isClickEnabled = isClickEnabled,
onClick = onClick,
onProviderSelectClick = {
val amountUM = model.uiState.value as? SwapAmountUM.Content ?: return@SwapAmountBlockContent
val selectedProvider = amountUM.selectedQuote.provider ?: return@SwapAmountBlockContent
val cryptoCurrency = params.secondaryCryptoCurrency ?: return@SwapAmountBlockContent
model.bottomSheetNavigation.activate(
SwapChooseProviderConfig(
providers = amountUM.swapQuotes,
cryptoCurrency = cryptoCurrency,
selectedProvider = selectedProvider,
),
)
},
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: SwapChooseProviderConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent {
return SwapChooseProviderComponent(
context = childByContext(componentContext),
params = SwapChooseProviderComponent.Params(
providers = config.providers,
cryptoCurrency = config.cryptoCurrency,
selectedProvider = config.selectedProvider,
callback = model,
onDismiss = { model.bottomSheetNavigation.dismiss() },
),
)
}
data class SwapChooseProviderConfig(
val providers: ImmutableList<SwapQuoteUM>,
val cryptoCurrency: CryptoCurrency,
val selectedProvider: ExpressProvider,
)
}

View file

@ -0,0 +1,64 @@
package com.tangem.features.swap.v2.impl.amount.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.features.swap.v2.impl.R
import com.tangem.utils.StringsSigns.PERCENT
import javax.inject.Inject
@ModelScoped
internal class SwapAlertFactory @Inject constructor(
private val uiMessageSender: UiMessageSender,
) {
fun priceImpactAlert(hasPriceImpact: Boolean, currencySymbol: String, provider: ExpressProvider) {
val slippage = provider.slippage?.let { "${it.parseBigDecimal(1)}$PERCENT" }
val combinedMessage = buildList {
when (provider.type) {
ExpressProviderType.CEX -> {
if (slippage != null) {
add(
resourceReference(
id = R.string.swapping_alert_cex_description_with_slippage,
formatArgs = wrappedList(currencySymbol, slippage),
),
)
} else {
add(resourceReference(R.string.swapping_alert_cex_description, wrappedList(currencySymbol)))
}
}
ExpressProviderType.DEX,
ExpressProviderType.DEX_BRIDGE,
-> {
if (hasPriceImpact) {
add(resourceReference(R.string.swapping_high_price_impact_description))
add(stringReference("\n\n"))
}
if (slippage != null) {
add(
resourceReference(
id = R.string.swapping_alert_dex_description_with_slippage,
formatArgs = wrappedList(slippage),
),
)
} else {
add(resourceReference(R.string.swapping_alert_dex_description, wrappedList(currencySymbol)))
}
}
else -> Unit
}
}
uiMessageSender.send(
DialogMessage(
title = resourceReference(R.string.swapping_alert_title),
message = combinedReference(combinedMessage.toWrappedList()),
firstActionBuilder = { okAction() },
),
)
}
}

View file

@ -6,5 +6,6 @@ import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType
internal interface SwapAmountClickIntents : AmountScreenClickIntents {
fun onExpandEditField(selectedAmountType: SwapAmountType)
fun onInfoClick()
fun onSelectTokenClick()
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.swap.v2.impl.amount.model
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter
@ -29,6 +30,7 @@ import com.tangem.domain.tokens.GetMultiCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.transaction.usecase.GetAllowanceUseCase
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent.SwapChooseProviderConfig
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountType
@ -64,6 +66,7 @@ internal class SwapAmountModel @Inject constructor(
private val getAllowanceUseCase: GetAllowanceUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val appRouter: AppRouter,
private val swapAlertFactory: SwapAlertFactory,
) : Model(), SwapAmountClickIntents, SwapChooseProviderComponent.ModelCallback {
private val params: SwapAmountComponentParams = paramsContainer.require()
@ -85,6 +88,8 @@ internal class SwapAmountModel @Inject constructor(
private var primaryMinimumAmountBoundary: EnterAmountBoundary by Delegates.notNull()
private var secondaryMinimumAmountBoundary: EnterAmountBoundary by Delegates.notNull()
val bottomSheetNavigation: SlotNavigation<SwapChooseProviderConfig> = SlotNavigation()
val uiState: StateFlow<SwapAmountUM>
field = MutableStateFlow(params.amountUM)
@ -121,6 +126,18 @@ internal class SwapAmountModel @Inject constructor(
}
}
override fun onInfoClick() {
val amountUM = uiState.value as? SwapAmountUM.Content ?: return
val selectedProvider = amountUM.selectedQuote.provider ?: return
val cryptoCurrency = secondaryCryptoCurrency ?: return
swapAlertFactory.priceImpactAlert(
hasPriceImpact = (amountUM.secondaryAmount as? SwapAmountFieldUM.Content)?.priceImpact != null,
currencySymbol = cryptoCurrency.symbol,
provider = selectedProvider,
)
}
override fun onAmountValueChange(value: String) {
uiState.transformerUpdate(
SwapAmountValueChangeTransformer(

View file

@ -0,0 +1,201 @@
package com.tangem.features.swap.v2.impl.amount.ui
import android.content.res.Configuration
import androidx.compose.foundation.Canvas
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.shape.RoundedCornerShape
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.geometry.Offset
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.ui.AmountBlockV2
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview
import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderContent
@Suppress("DestructuringDeclarationWithTooManyEntries")
@Composable
internal fun SwapAmountBlockContent(
amountUM: SwapAmountUM,
isClickEnabled: Boolean,
onProviderSelectClick: () -> Unit,
onInfoClick: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (amountUM !is SwapAmountUM.Content) return
ConstraintLayout(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.action)
.clickable(
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
enabled = isClickEnabled,
onClick = onClick,
),
) {
val (from, to, separator, provider) = createRefs()
AmountBlockV2(
amountState = amountUM.primaryAmount.amountField,
isClickDisabled = true,
isEditingDisabled = false,
modifier = Modifier.constrainAs(from) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
},
extraContent = {
SwapPriceImpact(amountFieldUM = amountUM.primaryAmount, onInfoClick = onInfoClick)
},
)
AmountBlockV2(
amountState = (amountUM.secondaryAmount.amountField as? AmountState.Data)?.copy(
title = resourceReference(R.string.send_with_swap_recipient_amount_title),
availableBalance = TextReference.EMPTY,
) ?: amountUM.secondaryAmount.amountField,
isClickDisabled = true,
isEditingDisabled = false,
modifier = Modifier.constrainAs(to) {
top.linkTo(from.bottom, 8.dp)
start.linkTo(parent.start)
end.linkTo(parent.end)
},
extraContent = {
SwapPriceImpact(amountFieldUM = amountUM.secondaryAmount, onInfoClick = onInfoClick)
},
)
SwapAmountDivider(
modifier = Modifier.constrainAs(separator) {
top.linkTo(from.bottom)
bottom.linkTo(to.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
},
)
SwapChooseProviderContent(
expressProvider = amountUM.selectedQuote.provider,
onClick = onProviderSelectClick,
modifier = Modifier.constrainAs(provider) {
top.linkTo(to.bottom)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
},
)
}
}
@Composable
private fun SwapPriceImpact(amountFieldUM: SwapAmountFieldUM, onInfoClick: () -> Unit) {
val priceImpact = (amountFieldUM as? SwapAmountFieldUM.Content)?.priceImpact
if (priceImpact != null) {
Text(
text = priceImpact.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.attention,
)
Icon(
painter = rememberVectorPainter(
ImageVector.vectorResource(R.drawable.ic_information_24),
),
tint = TangemTheme.colors.icon.attention,
contentDescription = null,
modifier = Modifier
.size(20.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = onInfoClick,
),
)
}
}
@Composable
private fun SwapAmountDivider(modifier: Modifier = Modifier) {
Box(modifier = modifier) {
SwapDivider()
Text(
text = stringResourceSafe(R.string.swap_via_provider),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.background(TangemTheme.colors.stroke.primary, RoundedCornerShape(32.dp))
.padding(1.dp)
.background(TangemTheme.colors.text.primary2, RoundedCornerShape(32.dp)) // workaround
.background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f), RoundedCornerShape(32.dp))
.padding(horizontal = 11.dp, vertical = 5.dp)
.align(Alignment.Center),
)
}
}
@Suppress("MagicNumber")
@Composable
private fun BoxScope.SwapDivider() {
val color = TangemTheme.colors.background.tertiary
Canvas(
Modifier
.fillMaxWidth()
.align(Alignment.Center),
) {
val gapWidth = 2.dp.toPx()
val radius = 2.dp.toPx()
val pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(gapWidth, gapWidth * 3),
phase = 0f,
)
drawLine(
color = color,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
pathEffect = pathEffect,
cap = StrokeCap.Round,
strokeWidth = radius,
)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SwapAmountBlockContent_Preview() {
TangemThemePreview {
SwapAmountBlockContent(
amountUM = SwapAmountContentPreview.defaultState,
isClickEnabled = true,
onProviderSelectClick = {},
onInfoClick = {},
onClick = {},
)
}
}
// endregion

View file

@ -6,6 +6,8 @@ import com.tangem.features.swap.v2.impl.amount.model.SwapAmountClickIntents
internal object SwapAmountClickIntentsStub : SwapAmountClickIntents {
override fun onExpandEditField(selectedAmountType: SwapAmountType) {}
override fun onInfoClick() {}
override fun onSelectTokenClick() {}
override fun onAmountValueChange(value: String) {}