Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-25 12:01:06 +05:00
parent c96e72fac4
commit e271cce581
14 changed files with 464 additions and 395 deletions

View file

@ -98,4 +98,6 @@ dependencies {
testImplementation(deps.test.junit5)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
testImplementation(deps.test.coroutine)
testImplementation(projects.common.test)
}

View file

@ -1,9 +1,13 @@
package com.tangem.features.swap.v2.impl.amount
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
@ -14,10 +18,14 @@ import com.tangem.common.ui.navigationButtons.NavigationModelCallback
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.components.PrimaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.features.swap.v2.impl.R
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.SwapAmountContent
@ -28,7 +36,7 @@ import dagger.assisted.AssistedInject
internal class SwapAmountComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SwapAmountComponentParams.AmountParams,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SwapAmountModel = getOrCreateModel(params = params)
@ -49,6 +57,23 @@ internal class SwapAmountComponent @AssistedInject constructor(
fun updateState(amountUM: SwapAmountUM) = model.updateState(amountUM)
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
text = stringResourceSafe(R.string.send_amount_label),
onBackClick = {
params.callback.onBackClick(params.route)
},
backIconRes = if (params.route.isEditMode) {
R.drawable.ic_back_24
} else {
R.drawable.ic_close_24
},
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@Composable
override fun Content(modifier: Modifier) {
val amountUM by model.uiState.collectAsStateWithLifecycle()
@ -63,6 +88,30 @@ internal class SwapAmountComponent @AssistedInject constructor(
rateInfo.child?.instance?.BottomSheet()
}
@Composable
override fun Footer() {
val state by model.uiState.collectAsStateWithLifecycle()
PrimaryButton(
text = if (params.route.isEditMode) {
stringResourceSafe(R.string.common_continue)
} else {
stringResourceSafe(R.string.common_next)
},
enabled = state.isPrimaryButtonEnabled,
onClick = {
model.onAmountNext()
params.callback.onNextClick(params.route)
},
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
private fun rateInfoChild(
config: ExpressRateType,
componentContext: ComponentContext,

View file

@ -10,7 +10,6 @@ import com.tangem.domain.swap.models.SwapDirection
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
internal sealed class SwapAmountComponentParams {
@ -41,7 +40,7 @@ internal sealed class SwapAmountComponentParams {
override val isAccountModeFlow: StateFlow<Boolean>,
val title: TextReference,
val callback: SwapAmountComponent.ModelCallback,
val currentRoute: Flow<SendWithSwapRoute>,
val route: SendWithSwapRoute,
) : SwapAmountComponentParams()
data class AmountBlockParams(

View file

@ -8,14 +8,11 @@ import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.common.ui.notifications.NotificationId
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.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.datasource.local.swap.SwapBestRateAnimationStore
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -40,7 +37,6 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkListener
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.SwapAmountReduceListener
@ -54,7 +50,6 @@ import com.tangem.features.swap.v2.impl.amount.model.transformers.*
import com.tangem.features.swap.v2.impl.chooseprovider.SwapChooseProviderComponent
import com.tangem.features.swap.v2.impl.common.SwapAlertFactory
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents.NoticeFixedRate.toAnalyticsRateType
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -125,7 +120,6 @@ internal class SwapAmountModel @Inject constructor(
private val amountAnalyticsSender = SwapAmountAnalyticsSender(analyticsEventHandler)
private var autoUpdateSubscriberJob: Job? = null
private var navigationJob: Job? = null
val uiState: StateFlow<SwapAmountUM>
field = MutableStateFlow(params.amountUM)
@ -156,7 +150,6 @@ internal class SwapAmountModel @Inject constructor(
} else {
QUOTES_UPDATE_DELAY
}
configAmountNavigation()
quoteTaskScheduler.scheduleTask(
scope = modelScope,
task = loadQuotesTask(initialDelay = initialDelay),
@ -167,7 +160,6 @@ internal class SwapAmountModel @Inject constructor(
fun onStop() {
quoteTaskScheduler.cancelTask()
autoUpdateSubscriberJob?.cancel()
navigationJob?.cancel()
}
override fun onDestroy() {
@ -325,7 +317,7 @@ internal class SwapAmountModel @Inject constructor(
val isShowSendViaSwapNotification = shouldShowNotificationUseCase(
NotificationId.SendViaSwapTokenSelectorNotification.key,
)
val isEditMode = amountParams.currentRoute.firstOrNull()?.isEditMode == true
val isEditMode = amountParams.route.isEditMode
val selectedCurrency = (uiState.value as? SwapAmountUM.Content)?.secondaryCryptoCurrencyStatus?.currency
appRouter.push(
AppRoute.ChooseManagedTokens(
@ -354,7 +346,7 @@ internal class SwapAmountModel @Inject constructor(
val amountParams = params as? SwapAmountComponentParams.AmountParams ?: return
modelScope.launch {
if (amountParams.currentRoute.firstOrNull()?.isEditMode == true) {
if (amountParams.route.isEditMode) {
swapAmountAlertFactory.showCloseSendWithSwapAlert {
params.callback.resetSendWithSwapNavigation(resetNavigation = true)
confirmSendWithSwapClose()
@ -934,42 +926,6 @@ internal class SwapAmountModel @Inject constructor(
)
}
private fun configAmountNavigation() {
val params = params as? SwapAmountComponentParams.AmountParams ?: return
navigationJob?.cancel()
navigationJob = combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter { (_, route) -> route is SendWithSwapRoute.Amount }.onEach { (state, route) ->
params.callback.onNavigationResult(
NavigationUM.Content(
source = SendWithSwapRoute.Amount::class.java.simpleName,
title = resourceReference(R.string.common_amount),
subtitle = null,
backIconRes = if (route.isEditMode) {
R.drawable.ic_back_24
} else {
R.drawable.ic_close_24
},
backIconClick = params.callback::onBackClick,
primaryButton = NavigationButton(
textReference = if (route.isEditMode) {
resourceReference(R.string.common_continue)
} else {
resourceReference(R.string.common_next)
},
isEnabled = state.isPrimaryButtonEnabled,
onClick = {
onAmountNext()
params.callback.onNextClick()
},
),
),
)
}.launchIn(modelScope)
}
private companion object {
const val DEBOUNCE_AMOUNT_DELAY = 500L
const val QUOTES_UPDATE_DELAY = 10000L

View file

@ -4,21 +4,18 @@ import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.value.ObserveLifecycleMode
import com.arkivanov.decompose.value.subscribe
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.navigation.url.UrlOpener
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.swap.models.R
import com.tangem.domain.swap.models.SwapDirection
@ -41,7 +38,6 @@ import com.tangem.features.swap.v2.impl.sendviaswap.ui.SendWithSwapContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
internal class DefaultSendWithSwapComponent @AssistedInject constructor(
@ -50,7 +46,6 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
private val sendDestinationComponentFactory: SendDestinationComponent.Factory,
private val confirmComponentFactory: SendWithSwapConfirmComponent.Factory,
private val analyticsEventHandler: AnalyticsEventHandler,
private val urlOpener: UrlOpener,
) : SendWithSwapComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<SendWithSwapRoute>()
@ -99,7 +94,7 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
activeComponent.updateState(model.uiState.value.destinationUM)
}
is SendWithSwapConfirmComponent -> {
if (model.currentRoute.value.isEditMode) {
if (stack.active.configuration.isEditMode) {
activeComponent.updateState(model.uiState.value)
}
// Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate ([REDACTED_TASK_KEY]).
@ -120,7 +115,6 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
)
}
}
model.currentRoute.emit(stack.active.configuration)
}
}
}
@ -128,35 +122,33 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val stackState by childStack.subscribeAsState()
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler(
onBack = {
(state.navigationUM as? NavigationUM.Content)?.backIconClick() ?: onChildBack()
},
onBack = ::onChildBack,
)
SendWithSwapContent(
navigationUM = state.navigationUM,
confirmUM = state.confirmUM,
stackState = stackState,
onLinkClick = urlOpener::openUrl,
)
}
private fun createChild(route: SendWithSwapRoute, childContext: AppComponentContext) = when (route) {
is SendWithSwapRoute.Amount -> getAmountComponent(factoryContext = childContext)
is SendWithSwapRoute.Destination -> getDestinationComponent(factoryContext = childContext)
is SendWithSwapRoute.Amount -> getAmountComponent(route, factoryContext = childContext)
is SendWithSwapRoute.Destination -> getDestinationComponent(route = route, factoryContext = childContext)
is SendWithSwapRoute.Confirm -> getConfirmComponent(factoryContext = childContext)
is SendWithSwapRoute.Success -> getSuccessComponent(factoryContext = childContext)
}
private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent {
private fun getAmountComponent(
route: SendWithSwapRoute.Amount,
factoryContext: AppComponentContext,
): ComposableModularContentComponent {
return SwapAmountComponent(
appComponentContext = factoryContext,
params = SwapAmountComponentParams.AmountParams(
amountUM = model.uiState.value.amountUM,
title = resourceReference(R.string.common_send),
currentRoute = model.currentRoute,
route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName,
primaryCryptoCurrencyStatusFlow = model.primaryCryptoCurrencyStatusFlow,
@ -172,17 +164,20 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
)
}
private fun getDestinationComponent(factoryContext: AppComponentContext): ComposableContentComponent {
private fun getDestinationComponent(
route: DestinationRoute,
factoryContext: AppComponentContext,
): ComposableModularContentComponent {
val amountContentUM = model.uiState.value.amountUM as? SwapAmountUM.Content
?: return ComposableContentComponent.EMPTY
?: return ComposableModularContentComponent.EMPTY
val secondaryCryptoCurrency = amountContentUM.secondaryCryptoCurrencyStatus?.currency
?: return ComposableContentComponent.EMPTY
?: return ComposableModularContentComponent.EMPTY
return sendDestinationComponentFactory.create(
context = factoryContext,
params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM,
currentRoute = model.currentRoute.filterIsInstance<DestinationRoute>(),
route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName,
analyticsSendSource = model.analyticsSendSource,
@ -195,12 +190,11 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
)
}
private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableContentComponent {
private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
return confirmComponentFactory.create(
appComponentContext = factoryContext,
params = SendWithSwapConfirmComponent.Params(
sendWithSwapUM = model.uiState.value,
currentRoute = model.currentRoute.filterIsInstance<SendWithSwapRoute.Confirm>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow,
appCurrency = model.appCurrency,
userWallet = model.userWallet,
@ -216,12 +210,11 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
)
}
private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent {
private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
return SendWithSwapSuccessComponent(
appComponentContext = factoryContext,
params = SendWithSwapSuccessComponent.Params(
sendWithSwapUMFlow = model.uiState,
currentRoute = model.currentRoute.filterIsInstance<SendWithSwapRoute.Success>(),
callback = model,
analyticsCategoryName = model.analyticCategoryName,
),

View file

@ -1,37 +1,52 @@
package com.tangem.features.swap.v2.impl.sendviaswap.confirm
import androidx.compose.animation.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.footers.SendingText
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.*
import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.*
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams
import com.tangem.features.swap.v2.impl.common.SwapUtils.SEND_WITH_SWAP_PROVIDER_TYPES
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.model.SendWithSwapConfirmModel
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.ui.SendWithSwapConfirmContent
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -44,7 +59,8 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory,
feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory,
sendNotificationsComponentFactory: SendNotificationsComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val urlOpener: UrlOpener,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SendWithSwapConfirmModel = getOrCreateModel(params = params)
@ -177,6 +193,17 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
}
}
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
text = stringResourceSafe(R.string.send_with_swap_confirm_title),
onBackClick = router::pop,
backIconRes = R.drawable.ic_back_24,
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@Composable
override fun Content(modifier: Modifier) {
val sendWithSwapUM by model.uiState.collectAsStateWithLifecycle()
@ -196,13 +223,120 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
)
}
@Composable
override fun Footer() {
val state by model.uiState.collectAsStateWithLifecycle()
Column {
val confirmUMContent = state.confirmUM as? ConfirmUM.Content
AnimatedVisibility(
visible = confirmUMContent != null,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
val sendFooter = confirmUMContent?.sendingFooter ?: TextReference.EMPTY
val legalFooter = getAnnotatedStringForLegals(
tosUM = confirmUMContent?.tosUM,
sendFooter = sendFooter,
onClick = urlOpener::openUrl,
)
val footerText = remember(sendFooter, legalFooter) {
if (sendFooter != TextReference.EMPTY || legalFooter != TextReference.EMPTY) {
combinedReference(sendFooter, legalFooter)
} else {
TextReference.EMPTY
}
}
SendingText(footerText = footerText)
}
NavigationPrimaryButton(
primaryButton = model.primaryButtonUM(state.confirmUM),
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
@Composable
private fun getAnnotatedStringForLegals(
tosUM: ConfirmUM.Content.TosUM?,
sendFooter: TextReference,
onClick: (String) -> Unit,
): TextReference {
if (tosUM == null) return TextReference.EMPTY
val tos = tosUM.tosLink
val policy = tosUM.policyLink
return if (tos != null && policy != null) {
val tosTitle = tos.title.resolveReference()
val policyTitle = policy.title.resolveReference()
val fullString = stringResourceSafe(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle)
val tosIndex = fullString.indexOf(tosTitle)
val policyIndex = fullString.indexOf(policyTitle)
annotatedReference {
if (!sendFooter.resolveReference().endsWith(StringsSigns.POINT_SIGN)) {
append(StringsSigns.POINT_SIGN)
}
appendSpace()
append(fullString.substring(0, tosIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "TOS_TAG",
linkInteractionListener = { onClick(tos.link) },
),
block = {
appendColored(
text = fullString.substring(tosIndex, tosIndex + tosTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
append(fullString.substring(tosIndex + tosTitle.length, policyIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "POLICY_TAG",
linkInteractionListener = { onClick(policy.link) },
),
block = {
appendColored(
text = fullString.substring(policyIndex, policyIndex + policyTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
}
} else {
val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" }
val legalTitle = legal.title.resolveReference()
val fullString = stringResourceSafe(id = R.string.express_legal_one_placeholder, legalTitle)
val legalIndex = fullString.indexOf(legalTitle)
annotatedReference {
append(fullString.substring(0, legalIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "LEGAL_TAG",
linkInteractionListener = { onClick(legal.link) },
),
block = {
appendColored(
text = fullString.substring(legalIndex, legalIndex + legalTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
}
}
}
data class Params(
val sendWithSwapUM: SendWithSwapUM,
val analyticsCategoryName: String,
val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
val userWallet: UserWallet,
val appCurrency: AppCurrency,
val currentRoute: Flow<SendWithSwapRoute>,
val swapDirection: SwapDirection,
val isBalanceHidingFlow: StateFlow<Boolean>,
val primaryCryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>,
@ -218,6 +352,6 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
}
interface ModelCallback {
fun onResult(route: SendWithSwapRoute, sendWithSwapUM: SendWithSwapUM)
fun onResult(sendWithSwapUM: SendWithSwapUM)
}
}

View file

@ -8,7 +8,6 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -38,14 +37,14 @@ import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.swap.v2.api.SwapFeatureToggles
@ -176,7 +175,6 @@ internal class SendWithSwapConfirmModel @Inject constructor(
init {
updateAmountSubtractAvailability()
configConfirmNavigation()
initialState()
subscribeOnNotificationUpdates()
subscribeOnTapHelpUpdates()
@ -383,6 +381,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
swapDataModel = data,
),
)
params.callback.onResult(uiState.value)
router.replaceAll(SendWithSwapRoute.Success)
},
expressOperationType = ExpressOperationType.SEND_WITH_SWAP,
@ -582,49 +581,29 @@ internal class SendWithSwapConfirmModel @Inject constructor(
}
}
private fun configConfirmNavigation() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter {
it.second is SendWithSwapRoute.Confirm
}.onEach { (state, _) ->
val confirmUM = state.confirmUM
val isContent = confirmUM is ConfirmUM.Content
val isReadyToSend = isContent && !confirmUM.isTransactionInProcess
val isHoldToConfirm = params.userWallet.isHotWallet && isContent
params.callback.onResult(
route = SendWithSwapRoute.Confirm,
sendWithSwapUM = state.copy(
navigationUM = NavigationUM.Content(
source = SendWithSwapRoute.Confirm.javaClass.simpleName,
title = resourceReference(id = R.string.send_with_swap_confirm_title),
subtitle = null,
backIconRes = R.drawable.ic_back_24,
backIconClick = router::pop,
primaryButton = NavigationButton(
textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm),
iconRes = walletInterationIcon(params.userWallet),
isIconVisible = isReadyToSend && !isHoldToConfirm,
isHapticClick = isReadyToSend,
isHoldToConfirm = isHoldToConfirm,
isEnabled = confirmUM.isPrimaryButtonEnabled,
onClick = {
when (confirmUM) {
is ConfirmUM.Content -> if (confirmUM.isTransactionInProcess) {
return@NavigationButton
} else {
onSendClick()
}
else -> return@NavigationButton
}
},
),
),
),
)
}.launchIn(modelScope)
fun primaryButtonUM(confirmUM: ConfirmUM): NavigationButton {
val isContent = confirmUM is ConfirmUM.Content
val isReadyToSend = isContent && !confirmUM.isTransactionInProcess
val isHoldToConfirm = params.userWallet.isHotWallet && isContent
return NavigationButton(
textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm),
iconRes = walletInterationIcon(params.userWallet),
isIconVisible = isReadyToSend && !isHoldToConfirm,
isHapticClick = isReadyToSend,
isHoldToConfirm = isHoldToConfirm,
isEnabled = confirmUM.isPrimaryButtonEnabled,
onClick = {
when (confirmUM) {
is ConfirmUM.Content -> if (confirmUM.isTransactionInProcess) {
return@NavigationButton
} else {
onSendClick()
}
else -> return@NavigationButton
}
},
)
}
private fun getPrimaryButtonText(confirmUM: ConfirmUM, isHoldToConfirm: Boolean): TextReference {

View file

@ -1,8 +1,7 @@
package com.tangem.features.swap.v2.impl.sendviaswap.entity
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
@ -11,5 +10,4 @@ internal data class SendWithSwapUM(
val destinationUM: DestinationUM,
val feeSelectorUM: FeeSelectorUM,
val confirmUM: ConfirmUM,
val navigationUM: NavigationUM,
)

View file

@ -1,10 +1,10 @@
package com.tangem.features.swap.v2.impl.sendviaswap.model
import arrow.core.getOrElse
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
@ -20,9 +20,9 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapDirection
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponent
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
@ -61,7 +61,6 @@ internal class SendWithSwapModel @Inject constructor(
val analyticCategoryName = CommonSendAnalyticEvents.SEND_CATEGORY
val analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.SendWithSwap
val initialRoute = SendWithSwapRoute.Amount(false)
val currentRoute = MutableStateFlow<SendWithSwapRoute>(initialRoute)
var userWallet: UserWallet by Delegates.notNull()
var appCurrency: AppCurrency = AppCurrency.Default
@ -108,14 +107,8 @@ internal class SendWithSwapModel @Inject constructor(
uiState.update { it.copy(destinationUM = destinationUM) }
}
override fun onResult(route: SendWithSwapRoute, sendWithSwapUM: SendWithSwapUM) {
if (currentRoute.value == route) {
uiState.value = sendWithSwapUM
}
}
override fun onNavigationResult(navigationUM: NavigationUM) {
uiState.update { it.copy(navigationUM = navigationUM) }
override fun onResult(sendWithSwapUM: SendWithSwapUM) {
uiState.value = sendWithSwapUM
}
override fun onSeparatorClick(lastAmount: String, isEnterInFiatSelected: Boolean) {
@ -129,7 +122,6 @@ internal class SendWithSwapModel @Inject constructor(
destinationUM = DestinationUM.Empty(),
feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
)
}
if (resetNavigation) {
@ -137,17 +129,17 @@ internal class SendWithSwapModel @Inject constructor(
}
}
override fun onBackClick() = router.pop()
override fun onBackClick(currentRoute: Route) = router.pop()
override fun onNextClick() {
if (currentRoute.value.isEditMode) {
onBackClick()
override fun onNextClick(currentRoute: Route) {
if ((currentRoute as? SendWithSwapRoute)?.isEditMode == true) {
onBackClick(currentRoute)
} else {
when (currentRoute.value) {
when (currentRoute) {
is SendWithSwapRoute.Amount -> router.push(SendWithSwapRoute.Destination(isEditMode = false))
is SendWithSwapRoute.Destination -> router.push(SendWithSwapRoute.Confirm)
SendWithSwapRoute.Confirm -> router.push(SendWithSwapRoute.Success)
SendWithSwapRoute.Success -> onBackClick()
SendWithSwapRoute.Success -> onBackClick(currentRoute)
}
}
}
@ -171,7 +163,7 @@ internal class SendWithSwapModel @Inject constructor(
)
}
},
popBack = ::onBackClick,
popBack = router::pop,
)
},
)
@ -189,7 +181,6 @@ internal class SendWithSwapModel @Inject constructor(
destinationUM = DestinationUM.Empty(),
feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
)
}

View file

@ -1,37 +1,91 @@
package com.tangem.features.swap.v2.impl.sendviaswap.success
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.navigationButtons.DoneButtons
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationModelCallback
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.features.swap.v2.impl.sendviaswap.success.model.SendWithSwapSuccessModel
import com.tangem.features.swap.v2.impl.sendviaswap.success.ui.SendWithSwapSuccessContent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
internal class SendWithSwapSuccessComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val params: Params,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SendWithSwapSuccessModel = getOrCreateModel(params = params)
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
onBackClick = router::pop,
backIconRes = R.drawable.ic_close_24,
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
SendWithSwapSuccessContent(sendWithSwapUM = state)
}
@Composable
override fun Footer() {
val state by model.uiState.collectAsStateWithLifecycle()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
) {
DoneButtons(
(NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = model::onExploreClick,
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = model::onShareClick,
)).takeUnless { (state.confirmUM as? ConfirmUM.Success)?.txUrl.isNullOrBlank() },
)
PrimaryButton(
text = stringResourceSafe(R.string.common_close),
onClick = router::pop,
modifier = Modifier.fillMaxWidth(),
)
}
}
data class Params(
val sendWithSwapUMFlow: StateFlow<SendWithSwapUM>,
val analyticsCategoryName: String,
val currentRoute: Flow<SendWithSwapRoute.Success>,
val callback: NavigationModelCallback,
)
}

View file

@ -1,18 +1,11 @@
package com.tangem.features.swap.v2.impl.sendviaswap.success.model
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.features.swap.v2.impl.sendviaswap.success.SendWithSwapSuccessComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -24,7 +17,6 @@ internal class SendWithSwapSuccessModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
private val appRouter: AppRouter,
paramsContainer: ParamsContainer,
) : Model() {
@ -34,47 +26,14 @@ internal class SendWithSwapSuccessModel @Inject constructor(
val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success
init {
configConfirmSuccessNavigation()
}
private fun configConfirmSuccessNavigation() {
params.callback.onNavigationResult(
NavigationUM.Content(
source = SendWithSwapRoute.Success.javaClass.simpleName,
title = TextReference.EMPTY,
subtitle = null,
backIconRes = R.drawable.ic_close_24,
backIconClick = appRouter::pop,
primaryButton = NavigationButton(
textReference = resourceReference(R.string.common_close),
iconRes = null,
isEnabled = true,
isHapticClick = false,
onClick = appRouter::pop,
),
prevButton = null,
secondaryPairButtonsUM = (NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
onClick = ::onExploreClick,
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
onClick = ::onShareClick,
)).takeUnless { confirmUM?.txUrl.isNullOrBlank() },
),
)
}
private fun onExploreClick() {
fun onExploreClick() {
if (confirmUM == null) return
// analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName))
urlOpener.openUrl(confirmUM.txUrl)
}
private fun onShareClick() {
fun onShareClick() {
if (confirmUM == null) return
// analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName))

View file

@ -21,9 +21,6 @@ import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.utils.getFiatReference
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.ui.components.Fade
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.icons.identicon.IdentIcon
@ -45,27 +42,24 @@ import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.swap.models.SwapDataModel
import com.tangem.domain.swap.models.SwapDataTransactionModel
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
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.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Composable
internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) {
if (sendWithSwapUM.navigationUM !is NavigationUM.Content) return
Column {
Box(
modifier = Modifier
@ -82,14 +76,6 @@ internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) {
backgroundColor = TangemTheme.colors.background.tertiary,
)
}
NavigationButtonsBlockV2(
navigationUM = sendWithSwapUM.navigationUM,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
@ -458,31 +444,6 @@ private fun SendWithSwapSuccessContent_Preview() {
isPrimaryButtonEnabled = false,
),
),
navigationUM = NavigationUM.Content(
source = SendWithSwapRoute.Success.javaClass.simpleName,
title = TextReference.EMPTY,
subtitle = null,
backIconRes = R.drawable.ic_close_24,
backIconClick = {},
additionalIconRes = null,
additionalIconClick = null,
primaryButton = NavigationButton(
textReference = resourceReference(R.string.common_close),
isEnabled = true,
onClick = {},
),
secondaryPairButtonsUM = NavigationButton(
textReference = resourceReference(R.string.common_explore),
iconRes = R.drawable.ic_web_24,
isEnabled = true,
onClick = {},
) to NavigationButton(
textReference = resourceReference(R.string.common_share),
iconRes = R.drawable.ic_share_24,
isEnabled = true,
onClick = {},
),
),
),
)
}

View file

@ -1,45 +1,25 @@
package com.tangem.features.swap.v2.impl.sendviaswap.ui
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
import com.arkivanov.decompose.extensions.compose.stack.animation.plus
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.common.ui.footers.SendingText
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.ui.components.Fade
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.swap.v2.impl.R
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.utils.StringsSigns
@Composable
internal fun SendWithSwapContent(
navigationUM: NavigationUM,
confirmUM: ConfirmUM,
stackState: ChildStack<SendWithSwapRoute, ComposableContentComponent>,
onLinkClick: (String) -> Unit,
) {
val navigationUMContent = navigationUM as? NavigationUM.Content ?: return
internal fun SendWithSwapContent(stackState: ChildStack<SendWithSwapRoute, ComposableModularContentComponent>) {
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary)
@ -48,12 +28,7 @@ internal fun SendWithSwapContent(
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarWithBackButton(
text = navigationUMContent.title.resolveReference(),
onBackClick = navigationUMContent.backIconClick,
iconRes = navigationUMContent.additionalIconRes,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
stackState.active.instance.Title()
Children(
stack = stackState,
animation = stackAnimation { child ->
@ -76,125 +51,6 @@ internal fun SendWithSwapContent(
}
}
}
if (stackState.active.configuration != SendWithSwapRoute.Success) {
SendWithSwapFooter(
confirmUM = confirmUM,
stackState = stackState,
primaryButton = navigationUMContent.primaryButton,
onLinkClick = onLinkClick,
)
}
}
}
@Composable
private fun SendWithSwapFooter(
confirmUM: ConfirmUM,
stackState: ChildStack<SendWithSwapRoute, ComposableContentComponent>,
primaryButton: NavigationButton,
onLinkClick: (String) -> Unit,
) {
Column {
AnimatedVisibility(
visible = stackState.active.configuration == SendWithSwapRoute.Confirm,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
val confirmContentUM = confirmUM as? ConfirmUM.Content
val sendFooter = confirmContentUM?.sendingFooter ?: TextReference.EMPTY
val legalFooter = getAnnotatedStringForLegals(
tosUM = confirmContentUM?.tosUM,
sendFooter = sendFooter,
onClick = onLinkClick,
)
val footerText = remember(sendFooter, legalFooter) {
if (sendFooter != TextReference.EMPTY || legalFooter != TextReference.EMPTY) {
combinedReference(sendFooter, legalFooter)
} else {
TextReference.EMPTY
}
}
SendingText(footerText = footerText)
}
NavigationPrimaryButton(
primaryButton = primaryButton,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
@Composable
private fun getAnnotatedStringForLegals(
tosUM: ConfirmUM.Content.TosUM?,
sendFooter: TextReference,
onClick: (String) -> Unit,
): TextReference {
if (tosUM == null) return TextReference.EMPTY
val tos = tosUM.tosLink
val policy = tosUM.policyLink
return if (tos != null && policy != null) {
val tosTitle = tos.title.resolveReference()
val policyTitle = policy.title.resolveReference()
val fullString = stringResourceSafe(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle)
val tosIndex = fullString.indexOf(tosTitle)
val policyIndex = fullString.indexOf(policyTitle)
annotatedReference {
if (!sendFooter.resolveReference().endsWith(StringsSigns.POINT_SIGN)) {
append(StringsSigns.POINT_SIGN)
}
appendSpace()
append(fullString.substring(0, tosIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "TOS_TAG",
linkInteractionListener = { onClick(tos.link) },
),
block = {
appendColored(
text = fullString.substring(tosIndex, tosIndex + tosTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
append(fullString.substring(tosIndex + tosTitle.length, policyIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "POLICY_TAG",
linkInteractionListener = { onClick(policy.link) },
),
block = {
appendColored(
text = fullString.substring(policyIndex, policyIndex + policyTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
}
} else {
val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" }
val legalTitle = legal.title.resolveReference()
val fullString = stringResourceSafe(id = R.string.express_legal_one_placeholder, legalTitle)
val legalIndex = fullString.indexOf(legalTitle)
annotatedReference {
append(fullString.substring(0, legalIndex))
withLink(
link = LinkAnnotation.Clickable(
tag = "LEGAL_TAG",
linkInteractionListener = { onClick(legal.link) },
),
block = {
appendColored(
text = fullString.substring(legalIndex, legalIndex + legalTitle.length),
color = TangemTheme.colors.text.accent,
)
},
)
}
stackState.active.instance.Footer()
}
}

View file

@ -0,0 +1,138 @@
package com.tangem.features.swap.v2.impl.sendviaswap.model
import arrow.core.left
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.models.errors.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.api.entry.SendEntryRoute
import com.tangem.features.swap.v2.api.SendWithSwapComponent
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
/**
* Guards the send-with-swap navigation refactor: [SendWithSwapModel.onBackClick] /
* [SendWithSwapModel.onNextClick] now receive the active [com.tangem.core.decompose.navigation.Route]
* as a parameter instead of reading an internal `currentRoute` StateFlow. Routing is a pure,
* synchronous decision over [SendWithSwapRoute], so each method is asserted before any advance; the
* model is then destroyed inside the test body so its `init {}` collectors (`initAppCurrency`,
* `subscribeOnBalanceHidden`) are cancelled never run before `runTest`'s terminal advance. The
* synchronous `initUserWallet()` is steered to the not-found branch so it never touches the router
* during construction.
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class SendWithSwapModelNavigationTest {
private val router: Router = mockk(relaxed = true)
private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
private val cryptoCurrency = MockCryptoCurrencyFactory().createCoin(Blockchain.Ethereum)
@Test
fun `GIVEN amount step WHEN onNextClick THEN pushes destination`() = runTest {
val model = createModel(this)
model.onNextClick(SendWithSwapRoute.Amount(isEditMode = false))
verify(exactly = 1) { router.push(SendWithSwapRoute.Destination(isEditMode = false)) }
model.onDestroy()
}
@Test
fun `GIVEN destination step WHEN onNextClick THEN pushes confirm`() = runTest {
val model = createModel(this)
model.onNextClick(SendWithSwapRoute.Destination(isEditMode = false))
verify(exactly = 1) { router.push(SendWithSwapRoute.Confirm) }
model.onDestroy()
}
@Test
fun `GIVEN confirm step WHEN onNextClick THEN pushes success`() = runTest {
val model = createModel(this)
model.onNextClick(SendWithSwapRoute.Confirm)
verify(exactly = 1) { router.push(SendWithSwapRoute.Success) }
model.onDestroy()
}
@Test
fun `GIVEN success step WHEN onNextClick THEN pops`() = runTest {
val model = createModel(this)
model.onNextClick(SendWithSwapRoute.Success)
verify(exactly = 1) { router.pop() }
verify(exactly = 0) { router.push(any()) }
model.onDestroy()
}
@Test
fun `GIVEN edit-mode amount WHEN onNextClick THEN pops instead of advancing`() = runTest {
val model = createModel(this)
model.onNextClick(SendWithSwapRoute.Amount(isEditMode = true))
verify(exactly = 1) { router.pop() }
verify(exactly = 0) { router.push(any()) }
model.onDestroy()
}
@Test
fun `GIVEN any route WHEN onBackClick THEN pops`() = runTest {
val model = createModel(this)
model.onBackClick(SendWithSwapRoute.Destination(isEditMode = false))
verify(exactly = 1) { router.pop() }
model.onDestroy()
}
private fun createModel(testScope: TestScope): SendWithSwapModel {
// Synchronous initUserWallet() runs at construction → keep it on the not-found branch (no router calls).
every { getUserWalletUseCase(any()) } returns GetUserWalletError.UserWalletNotFound.left()
val params = SendWithSwapComponent.Params(
userWalletId = UserWalletId(stringValue = "0123456789"),
currency = cryptoCurrency,
callback = null,
currentRoute = MutableStateFlow(SendEntryRoute.SendWithSwap),
)
return SendWithSwapModel(
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
router = router,
getFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true),
getUserWalletUseCase = getUserWalletUseCase,
getSelectedAppCurrencyUseCase = mockk(relaxed = true),
getBalanceHidingSettingsUseCase = mockk(relaxed = true),
getAccountCurrencyStatusUseCase = mockk(relaxed = true),
isAccountsModeEnabledUseCase = mockk(relaxed = true),
swapAlertFactory = mockk(relaxed = true),
paramsContainer = MutableParamsContainer(value = params),
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}