Updated on 2026-08-14
This commit is contained in:
parent
7ee8825f97
commit
4febcb6c93
9 changed files with 473 additions and 3 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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) {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue