Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 15:36:54 +03:00
commit b603ef3a22
61 changed files with 2076 additions and 1452 deletions

View file

@ -1,7 +1,8 @@
package com.tangem.common.ui.navigationButtons package com.tangem.common.ui.navigationButtons
import com.tangem.core.decompose.navigation.Route
interface NavigationModelCallback { interface NavigationModelCallback {
fun onNavigationResult(navigationUM: NavigationUM) fun onBackClick(currentRoute: Route)
fun onBackClick() fun onNextClick(currentRoute: Route)
fun onNextClick()
} }

View file

@ -1,8 +1,10 @@
package com.tangem.features.send.api.subcomponents.amount package com.tangem.features.send.api.subcomponents.amount
import com.tangem.core.decompose.navigation.Route
/** /**
* Common route for amount * Common route for amount
*/ */
interface AmountRoute { interface AmountRoute : Route {
val isEditMode: Boolean val isEditMode: Boolean
} }

View file

@ -3,10 +3,10 @@ package com.tangem.features.send.api.subcomponents.amount
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.common.ui.navigationButtons.NavigationModelCallback
import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.domain.wallets.models.errors.GetUserWalletError import com.tangem.domain.wallets.models.errors.GetUserWalletError
interface SendAmountComponent : ComposableContentComponent { interface SendAmountComponent : ComposableModularContentComponent {
fun updateState(amountUM: AmountState) fun updateState(amountUM: AmountState)

View file

@ -9,7 +9,6 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.PredefinedValues
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
sealed class SendAmountComponentParams { sealed class SendAmountComponentParams {
@ -39,7 +38,7 @@ sealed class SendAmountComponentParams {
override val accountFlow: StateFlow<Account?>, override val accountFlow: StateFlow<Account?>,
override val isAccountModeFlow: StateFlow<Boolean>, override val isAccountModeFlow: StateFlow<Boolean>,
val callback: SendAmountComponent.ModelCallback, val callback: SendAmountComponent.ModelCallback,
val currentRoute: Flow<AmountRoute>, val route: AmountRoute,
) : SendAmountComponentParams() ) : SendAmountComponentParams()
data class AmountBlockParams( data class AmountBlockParams(

View file

@ -1,8 +1,10 @@
package com.tangem.features.send.api.subcomponents.destination package com.tangem.features.send.api.subcomponents.destination
import com.tangem.core.decompose.navigation.Route
/** /**
* Common route for destination * Common route for destination
*/ */
interface DestinationRoute { interface DestinationRoute : Route {
val isEditMode: Boolean val isEditMode: Boolean
} }

View file

@ -2,10 +2,10 @@ package com.tangem.features.send.api.subcomponents.destination
import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.common.ui.navigationButtons.NavigationModelCallback
import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
interface SendDestinationComponent : ComposableContentComponent { interface SendDestinationComponent : ComposableModularContentComponent {
fun updateState(destinationUM: DestinationUM) fun updateState(destinationUM: DestinationUM)

View file

@ -6,7 +6,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
sealed class SendDestinationComponentParams { sealed class SendDestinationComponentParams {
@ -26,7 +25,7 @@ sealed class SendDestinationComponentParams {
override val userWalletId: UserWalletId, override val userWalletId: UserWalletId,
val title: TextReference, val title: TextReference,
val isBalanceHidingFlow: StateFlow<Boolean>, val isBalanceHidingFlow: StateFlow<Boolean>,
val currentRoute: Flow<DestinationRoute>, val route: DestinationRoute,
val callback: SendDestinationComponent.ModelCallback, val callback: SendDestinationComponent.ModelCallback,
override val isAllowSelfSend: Boolean = false, override val isAllowSelfSend: Boolean = false,
) : SendDestinationComponentParams() ) : SendDestinationComponentParams()

View file

@ -16,7 +16,7 @@ internal sealed class CommonSendRoute : Route {
@Serializable @Serializable
data object Confirm : CommonSendRoute() { data object Confirm : CommonSendRoute() {
override val isEditMode: Boolean = true override val isEditMode: Boolean = false
} }
@Serializable @Serializable

View file

@ -1,99 +0,0 @@
package com.tangem.features.send.common.ui
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.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.NavigationButtonsBlockV2
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.ui.components.Fade
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.ui.state.ConfirmUM
@Composable
internal fun SendContent(
navigationUM: NavigationUM,
confirmUM: ConfirmUM,
stackState: ChildStack<CommonSendRoute, ComposableContentComponent>,
) {
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SendAppBar(navigationUM = navigationUM)
Children(
stack = stackState,
animation = stackAnimation { child ->
when (child.configuration) {
is CommonSendRoute.ConfirmSuccess -> fade(minAlpha = 1.0f)
is CommonSendRoute.Confirm -> fade()
else -> slide()
}
},
modifier = Modifier.weight(1f),
) {
Box(modifier = Modifier.fillMaxHeight()) {
it.instance.Content(Modifier.fillMaxSize(1f))
if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) {
Fade(
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) {
Column {
AnimatedVisibility(
visible = stackState.active.configuration == CommonSendRoute.Confirm,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
SendingText(footerText = (confirmUM as? ConfirmUM.Content)?.sendingFooter ?: TextReference.EMPTY)
}
NavigationButtonsBlockV2(
navigationUM = navigationUM,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
}
}
@Composable
private fun SendAppBar(navigationUM: NavigationUM) {
val navigationUMContent = navigationUM as? NavigationUM.Content ?: return
AppBarWithBackButtonAndIcon(
text = navigationUMContent.title.resolveReference(),
subtitle = navigationUMContent.subtitle?.resolveReference(),
onBackClick = navigationUMContent.backIconClick,
onIconClick = navigationUMContent.additionalIconClick,
backIconRes = navigationUMContent.backIconRes,
iconRes = navigationUMContent.additionalIconRes,
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.send.common.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.Fade
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.common.CommonSendRoute
/**
* Shared pull-based host for the regular Send and NFT Send flows (both over [CommonSendRoute]). Renders the
* ACTIVE child's [ComposableModularContentComponent.Title] / [ComposableModularContentComponent.Footer] slots
* in place (matching the previous in-place app-bar/footer behavior), while keeping the per-route slide/fade
* Decompose [Children] animation for the Content region (and the bottom `Fade` gradient, hidden on
* `ConfirmSuccess`, exactly as the previous `SendContent`).
*
* Each step's `Footer()` owns its own bottom block (Confirm reveals `SendingText`; Success/Empty render
* nothing), so the host no longer special-cases routes for the footer.
*/
@Composable
internal fun SendModularContent(stackState: ChildStack<CommonSendRoute, ComposableModularContentComponent>) {
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary)
.fillMaxSize()
.imePadding()
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
stackState.active.instance.Title()
Children(
stack = stackState,
animation = stackAnimation { child ->
when (child.configuration) {
is CommonSendRoute.ConfirmSuccess -> fade(minAlpha = 1.0f)
is CommonSendRoute.Confirm -> fade()
else -> slide()
}
},
modifier = Modifier.weight(1f),
) {
Box(modifier = Modifier.fillMaxHeight()) {
it.instance.Content(Modifier.fillMaxSize(1f))
if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) {
Fade(
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
stackState.active.instance.Footer()
}
}

View file

@ -1,28 +0,0 @@
package com.tangem.features.send.common.utils
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.value.Value
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.send.common.CommonSendRoute
/**
* Workaround to try fix duplicate route crash
*/
internal fun Router.safeNextClick(
currentRoute: CommonSendRoute,
nextRoute: CommonSendRoute,
childStack: Value<ChildStack<CommonSendRoute, ComposableContentComponent>>,
popBack: () -> Unit,
) {
if (currentRoute.isEditMode) {
popBack()
} else {
val isAlreadyInStack = childStack.value.items.any { it.configuration == nextRoute }
if (isAlreadyInStack) {
popTo(nextRoute)
} else {
push(nextRoute)
}
}
}

View file

@ -1,15 +1,9 @@
package com.tangem.features.send.send package com.tangem.features.send.send
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.childStack
@ -17,49 +11,45 @@ import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.value.ObserveLifecycleMode import com.arkivanov.decompose.value.ObserveLifecycleMode
import com.arkivanov.decompose.value.subscribe import com.arkivanov.decompose.value.subscribe
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext 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.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.models.account.derivationIndex import com.tangem.domain.models.account.derivationIndex
import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.subcomponents.amount.AmountRoute
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.api.subcomponents.destination.DestinationRoute
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams 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.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent
import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.ui.SendContent import com.tangem.features.send.common.ui.SendModularContent
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.impl.R import com.tangem.features.send.impl.R
import com.tangem.features.send.send.confirm.SendConfirmComponent import com.tangem.features.send.send.confirm.SendConfirmComponent
import com.tangem.features.send.send.model.SendModel import com.tangem.features.send.send.model.SendModel
import com.tangem.features.send.send.success.SendConfirmSuccessComponent import com.tangem.features.send.send.success.SendConfirmSuccessComponent
import com.tangem.features.send.subcomponents.amount.DefaultSendAmountComponent
import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
@Suppress("LargeClass") @Suppress("LongParameterList")
internal class DefaultSendComponent @AssistedInject constructor( internal class DefaultSendComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext, @Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SendComponent.Params, @Assisted private val params: SendComponent.Params,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val amountComponentFactory: SendAmountComponent.Factory, private val amountComponentFactory: SendAmountComponent.Factory,
private val destinationComponentFactory: SendDestinationComponent.Factory,
private val sendConfirmSuccessComponent: SendConfirmSuccessComponent.Factory,
private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory,
private val sendDestinationComponentFactory: SendDestinationComponent.Factory,
) : SendComponent, AppComponentContext by appComponentContext { ) : SendComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<CommonSendRoute>() private val stackNavigation = StackNavigation<CommonSendRoute>()
@ -93,48 +83,45 @@ internal class DefaultSendComponent @AssistedInject constructor(
lifecycle = lifecycle, lifecycle = lifecycle,
mode = ObserveLifecycleMode.CREATE_DESTROY, mode = ObserveLifecycleMode.CREATE_DESTROY,
) { stack -> ) { stack ->
componentScope.launch { when (val activeComponent = stack.active.instance) {
when (val activeComponent = stack.active.instance) { is SendConfirmComponent -> {
is SendConfirmComponent -> { val fromCurrency = params.currency
val fromCurrency = params.currency val fromDerivationIndex = model.accountFlow.value?.derivationIndex?.value
val fromDerivationIndex = model.accountFlow.value?.derivationIndex?.value .takeIf { model.isAccountModeFlow.value }
.takeIf { model.isAccountModeFlow.value } analyticsEventHandler.send(
analyticsEventHandler.send( CommonSendAnalyticEvents.ConfirmationScreenOpened(
CommonSendAnalyticEvents.ConfirmationScreenOpened( categoryName = model.analyticCategoryName,
categoryName = model.analyticCategoryName, source = model.analyticsSendSource,
source = model.analyticsSendSource, sendBlockchain = fromCurrency.network.name,
sendBlockchain = fromCurrency.network.name, sendToken = fromCurrency.symbol,
sendToken = fromCurrency.symbol, fromDerivationIndex = fromDerivationIndex,
fromDerivationIndex = fromDerivationIndex, toDerivationIndex = null,
toDerivationIndex = null, type = model.consumeEntryType(),
type = model.consumeEntryType(), ),
), )
) if (childStack.value.active.configuration.isEditMode) {
if (model.currentRoute.value.isEditMode) { activeComponent.updateState(model.uiState.value)
activeComponent.updateState(model.uiState.value)
}
}
is DefaultSendAmountComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AmountScreenOpened(
categoryName = model.analyticCategoryName,
source = model.analyticsSendSource,
type = model.consumeEntryType(),
),
)
activeComponent.updateState(model.uiState.value.amountUM)
}
is SendDestinationComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AddressScreenOpened(
categoryName = model.analyticCategoryName,
source = model.analyticsSendSource,
),
)
activeComponent.updateState(model.uiState.value.destinationUM)
} }
} }
model.currentRoute.emit(stack.active.configuration) is SendAmountComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AmountScreenOpened(
categoryName = model.analyticCategoryName,
source = model.analyticsSendSource,
type = model.consumeEntryType(),
),
)
activeComponent.updateState(model.uiState.value.amountUM)
}
is SendDestinationComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AddressScreenOpened(
categoryName = model.analyticCategoryName,
source = model.analyticsSendSource,
),
)
activeComponent.updateState(model.uiState.value.destinationUM)
}
} }
} }
} }
@ -142,34 +129,31 @@ internal class DefaultSendComponent @AssistedInject constructor(
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val stackState by childStack.subscribeAsState() val stackState by childStack.subscribeAsState()
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler( BackHandler(onBack = ::onChildBack)
onBack = { SendModularContent(stackState = stackState)
(state.navigationUM as? NavigationUM.Content)?.backIconClick() ?: onChildBack()
},
)
SendContent(
navigationUM = state.navigationUM,
confirmUM = state.confirmUM,
stackState = stackState,
)
} }
private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { private fun createChild(
CommonSendRoute.Empty -> getStubComponent() route: CommonSendRoute,
is CommonSendRoute.Destination -> getDestinationComponent(factoryContext) factoryContext: AppComponentContext,
is CommonSendRoute.Amount -> getAmountComponent(factoryContext) ): ComposableModularContentComponent = when (route) {
CommonSendRoute.Empty -> ComposableModularContentComponent.EMPTY
is CommonSendRoute.Destination -> getDestinationComponent(route, factoryContext)
is CommonSendRoute.Amount -> getAmountComponent(route, factoryContext)
is CommonSendRoute.Confirm -> getConfirmComponent(factoryContext) is CommonSendRoute.Confirm -> getConfirmComponent(factoryContext)
is CommonSendRoute.ConfirmSuccess -> getConfirmSuccessComponent(factoryContext) is CommonSendRoute.ConfirmSuccess -> getConfirmSuccessComponent(factoryContext)
} }
private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent = private fun getDestinationComponent(
sendDestinationComponentFactory.create( route: DestinationRoute,
factoryContext: AppComponentContext,
): ComposableModularContentComponent {
return destinationComponentFactory.create(
context = factoryContext, context = factoryContext,
params = SendDestinationComponentParams.DestinationParams( params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM, state = model.uiState.value.destinationUM,
currentRoute = model.currentRoute.filterIsInstance<CommonSendRoute.Destination>(), route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow, isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName, analyticsCategoryName = model.analyticCategoryName,
analyticsSendSource = model.analyticsSendSource, analyticsSendSource = model.analyticsSendSource,
@ -179,13 +163,17 @@ internal class DefaultSendComponent @AssistedInject constructor(
callback = model, callback = model,
), ),
) )
}
private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent { private fun getAmountComponent(
route: AmountRoute,
factoryContext: AppComponentContext,
): ComposableModularContentComponent {
return amountComponentFactory.create( return amountComponentFactory.create(
context = factoryContext, context = factoryContext,
params = SendAmountComponentParams.AmountParams( params = SendAmountComponentParams.AmountParams(
state = model.uiState.value.amountUM, state = model.uiState.value.amountUM,
currentRoute = model.currentRoute.filterIsInstance<CommonSendRoute.Amount>(), route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow, isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName, analyticsCategoryName = model.analyticCategoryName,
appCurrency = model.appCurrency, appCurrency = model.appCurrency,
@ -201,7 +189,7 @@ internal class DefaultSendComponent @AssistedInject constructor(
) )
} }
private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableContentComponent { private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
return if (model.isAvailableForSend) { return if (model.isAvailableForSend) {
val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value
val feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatusFlow.value val feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatusFlow.value
@ -210,7 +198,6 @@ internal class DefaultSendComponent @AssistedInject constructor(
params = SendConfirmComponent.Params( params = SendConfirmComponent.Params(
state = model.uiState.value, state = model.uiState.value,
userWallet = model.userWallet, userWallet = model.userWallet,
currentRoute = model.currentRoute,
isBalanceHidingFlow = model.isBalanceHiddenFlow, isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName, analyticsCategoryName = model.analyticCategoryName,
cryptoCurrencyStatus = cryptoCurrencyStatus, cryptoCurrencyStatus = cryptoCurrencyStatus,
@ -233,79 +220,43 @@ internal class DefaultSendComponent @AssistedInject constructor(
) )
} else { } else {
model.showAlertError() model.showAlertError()
getStubComponent() ComposableModularContentComponent.EMPTY
} }
} }
private fun getConfirmSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent { private fun getConfirmSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
val state = model.uiState.value val state = model.uiState.value
val sendAmount = (state.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value val sendAmount = (state.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value
val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value
val txUrl = (state.confirmUM as? ConfirmUM.Success)?.txUrl val txUrl = (state.confirmUM as? ConfirmUM.Success)?.txUrl
val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value
if (sendAmount == null || if (sendAmount == null ||
destinationAddress == null || destinationAddress == null ||
txUrl == null txUrl == null
) { ) {
model.showAlertError() model.showAlertError()
return getStubComponent() return ComposableModularContentComponent.EMPTY
} }
val destinationBlockComponent = return sendConfirmSuccessComponent.create(
DefaultSendDestinationBlockComponent(
appComponentContext = child("sendConfirmDestinationBlock"),
params = SendDestinationComponentParams.DestinationBlockParams(
state = model.uiState.value.destinationUM,
analyticsCategoryName = model.analyticCategoryName,
analyticsSendSource = model.analyticsSendSource,
userWalletId = model.userWallet.walletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
blockClickEnableFlow = MutableStateFlow(true),
predefinedValues = model.predefinedValues,
isAddContactAvailable = true,
),
onResult = { },
onClick = {},
)
return SendConfirmSuccessComponent(
appComponentContext = factoryContext, appComponentContext = factoryContext,
params = SendConfirmSuccessComponent.Params( params = SendConfirmSuccessComponent.Params(
sendUMFlow = model.uiState, sendUMFlow = model.uiState,
destinationBlockComponent = destinationBlockComponent, userWalletId = params.userWalletId,
cryptoCurrency = params.currency,
predefinedValues = model.predefinedValues,
analyticsCategoryName = model.analyticCategoryName, analyticsCategoryName = model.analyticCategoryName,
currentRoute = model.currentRoute,
txUrl = txUrl, txUrl = txUrl,
callback = model, callback = model,
), ),
) )
} }
private fun getStubComponent() = StubComponent()
class StubComponent : ComposableContentComponent {
@Composable
override fun Content(modifier: Modifier) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
color = TangemTheme.colors.icon.primary1,
strokeWidth = TangemTheme.dimens.size2,
)
}
}
}
private fun onChildBack() { private fun onChildBack() {
val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty
val isEmptyStack = childStack.value.backStack.isEmpty() val isEmptyStack = childStack.value.backStack.isEmpty()
val isSuccess = model.uiState.value.confirmUM is ConfirmUM.Success val isSuccess = model.uiState.value.confirmUM is ConfirmUM.Success
val isStubComponent = childStack.value.active.instance is StubComponent val isStubComponent = childStack.value.active.instance == EmptyComposableBottomSheetComponent
val isSendingInProgress = (model.uiState.value.confirmUM as? ConfirmUM.Content)?.isSending == true val isSendingInProgress = (model.uiState.value.confirmUM as? ConfirmUM.Content)?.isSending == true
val isPopSend = isEmptyRoute || isEmptyStack || isSuccess || isStubComponent val isPopSend = isEmptyRoute || isEmptyStack || isSuccess || isStubComponent

View file

@ -1,15 +1,26 @@
package com.tangem.features.send.send.confirm package com.tangem.features.send.send.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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import arrow.core.Either import arrow.core.Either
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
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.AppComponentContext
import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -18,17 +29,17 @@ import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent 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.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.impl.R
import com.tangem.features.send.send.confirm.model.SendConfirmModel import com.tangem.features.send.send.confirm.model.SendConfirmModel
import com.tangem.features.send.send.confirm.ui.SendConfirmContent import com.tangem.features.send.send.confirm.ui.SendConfirmContent
import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.subcomponents.amount.DefaultSendAmountBlockComponent import com.tangem.features.send.subcomponents.amount.DefaultSendAmountBlockComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent
import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent
import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.orZero
@ -38,7 +49,7 @@ internal class SendConfirmComponent(
appComponentContext: AppComponentContext, appComponentContext: AppComponentContext,
params: Params, params: Params,
feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext { ) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SendConfirmModel = getOrCreateModel(params = params) private val model: SendConfirmModel = getOrCreateModel(params = params)
@ -133,6 +144,20 @@ internal class SendConfirmComponent(
model.updateState(state) model.updateState(state)
} }
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
text = stringResourceSafe(R.string.common_send),
onBackClick = {
model.onBackClick()
router.pop()
},
backIconRes = R.drawable.ic_back_24,
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle() val state by model.uiState.collectAsStateWithLifecycle()
@ -148,6 +173,29 @@ internal class SendConfirmComponent(
) )
} }
@Composable
override fun Footer() {
val state by model.uiState.collectAsStateWithLifecycle()
Column {
val sendingFooter = (state.confirmUM as? ConfirmUM.Content)?.sendingFooter
AnimatedVisibility(
visible = sendingFooter != null,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
SendingText(footerText = sendingFooter ?: TextReference.EMPTY)
}
NavigationPrimaryButton(
primaryButton = model.primaryButtonUM(state.confirmUM),
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
data class Params( data class Params(
val state: SendUM, val state: SendUM,
val analyticsCategoryName: String, val analyticsCategoryName: String,
@ -161,7 +209,6 @@ internal class SendConfirmComponent(
val isAccountModeFlow: StateFlow<Boolean>, val isAccountModeFlow: StateFlow<Boolean>,
val appCurrency: AppCurrency, val appCurrency: AppCurrency,
val callback: ModelCallback, val callback: ModelCallback,
val currentRoute: Flow<CommonSendRoute>,
val isBalanceHidingFlow: StateFlow<Boolean>, val isBalanceHidingFlow: StateFlow<Boolean>,
val predefinedValues: PredefinedValues, val predefinedValues: PredefinedValues,
val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>, val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,

View file

@ -10,7 +10,6 @@ import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationButton 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.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam
@ -160,7 +159,6 @@ internal class SendConfirmModel @Inject constructor(
init { init {
updateAmountSubtractAvailability() updateAmountSubtractAvailability()
configConfirmNavigation()
subscribeOnNotificationsUpdateTrigger() subscribeOnNotificationsUpdateTrigger()
subscribeOnCheckFeeResultUpdates() subscribeOnCheckFeeResultUpdates()
initialState() initialState()
@ -303,6 +301,17 @@ internal class SendConfirmModel @Inject constructor(
} }
} }
fun onBackClick() {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = uiState.value.confirmUM.isPrimaryButtonEnabled,
),
)
}
private fun initialState() { private fun initialState() {
val confirmUM = uiState.value.confirmUM val confirmUM = uiState.value.confirmUM
@ -552,58 +561,7 @@ internal class SendConfirmModel @Inject constructor(
return isHighNetworkFeeUseCase(feeCurrency, feeAmount) return isHighNetworkFeeUseCase(feeCurrency, feeAmount)
} }
@Suppress("LongMethod") fun primaryButtonUM(confirmUM: ConfirmUM): NavigationButton {
private fun configConfirmNavigation() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter {
it.second is CommonSendRoute.Confirm
}.onEach { (state, _) ->
val confirmUM = state.confirmUM
params.callback.onResult(
state.copy(
navigationUM = NavigationUM.Content(
source = CommonSendRoute.Confirm.javaClass.simpleName,
title = resourceReference(id = R.string.common_send),
subtitle = null,
backIconRes = when (confirmUM) {
is ConfirmUM.Success -> R.drawable.ic_close_24
else -> R.drawable.ic_back_24
},
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = confirmUM.isPrimaryButtonEnabled,
),
)
router.pop()
},
primaryButton = primaryButtonUM(),
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,
)
).takeIf { confirmUM is ConfirmUM.Success },
),
),
)
}.launchIn(modelScope)
}
private fun primaryButtonUM(): NavigationButton {
val confirmUM = uiState.value.confirmUM
val isContent = confirmUM is ConfirmUM.Content val isContent = confirmUM is ConfirmUM.Content
val isReadyToSend = isContent && !confirmUM.isSending val isReadyToSend = isContent && !confirmUM.isSending
val isHoldToConfirm = userWallet.isHotWallet && isContent val isHoldToConfirm = userWallet.isHotWallet && isContent

View file

@ -7,13 +7,13 @@ import arrow.core.left
import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.core.ui.utils.parseBigDecimalOrNull
@ -123,8 +123,6 @@ internal class SendModel @Inject constructor(
CommonSendRoute.Empty CommonSendRoute.Empty
} }
val currentRoute = MutableStateFlow(initialRoute)
val cryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus> val cryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>
field = MutableStateFlow( field = MutableStateFlow(
CryptoCurrencyStatus( CryptoCurrencyStatus(
@ -149,7 +147,7 @@ internal class SendModel @Inject constructor(
return cryptoCurrencyStatus.isAvailableForSend() && feeCryptoCurrencyStatus.isAvailableForSend() return cryptoCurrencyStatus.isAvailableForSend() && feeCryptoCurrencyStatus.isAvailableForSend()
} }
val isUnavailableForSend: Boolean private val isUnavailableForSend: Boolean
get() { get() {
val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value
val feeCryptoCurrencyStatus = feeCryptoCurrencyStatusFlow.value val feeCryptoCurrencyStatus = feeCryptoCurrencyStatusFlow.value
@ -179,10 +177,6 @@ internal class SendModel @Inject constructor(
initAppCurrency() initAppCurrency()
} }
override fun onNavigationResult(navigationUM: NavigationUM) {
uiState.update { it.copy(navigationUM = navigationUM) }
}
override fun onDestinationResult(destinationUM: DestinationUM) { override fun onDestinationResult(destinationUM: DestinationUM) {
uiState.update { it.copy(destinationUM = destinationUM) } uiState.update { it.copy(destinationUM = destinationUM) }
} }
@ -196,9 +190,9 @@ internal class SendModel @Inject constructor(
uiState.update { sendUM } uiState.update { sendUM }
} }
override fun onBackClick() { override fun onBackClick(currentRoute: Route) {
when (val route = currentRoute.value) { when (currentRoute) {
is CommonSendRoute.Amount -> if (!route.isEditMode) { is CommonSendRoute.Amount -> if (!currentRoute.isEditMode) {
analyticsEventHandler.send( analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked( CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = analyticCategoryName, categoryName = analyticCategoryName,
@ -208,7 +202,7 @@ internal class SendModel @Inject constructor(
), ),
) )
} }
is CommonSendRoute.Destination -> if (!route.isEditMode) { is CommonSendRoute.Destination -> if (!currentRoute.isEditMode) {
analyticsEventHandler.send( analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked( CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = analyticCategoryName, categoryName = analyticCategoryName,
@ -224,11 +218,11 @@ internal class SendModel @Inject constructor(
router.pop() router.pop()
} }
override fun onNextClick() { override fun onNextClick(currentRoute: Route) {
if (currentRoute.value.isEditMode) { if ((currentRoute as? CommonSendRoute)?.isEditMode == true) {
onBackClick() onBackClick(currentRoute)
} else { } else {
when (currentRoute.value) { when (currentRoute) {
is CommonSendRoute.Amount -> { is CommonSendRoute.Amount -> {
val nextRoute = if (predefinedValues.isFromMainScreenQr) { val nextRoute = if (predefinedValues.isFromMainScreenQr) {
CommonSendRoute.Confirm CommonSendRoute.Confirm
@ -239,7 +233,7 @@ internal class SendModel @Inject constructor(
} }
is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm) is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm)
CommonSendRoute.Confirm -> router.push(CommonSendRoute.ConfirmSuccess) CommonSendRoute.Confirm -> router.push(CommonSendRoute.ConfirmSuccess)
else -> onBackClick() else -> router.pop()
} }
} }
} }
@ -263,7 +257,6 @@ internal class SendModel @Inject constructor(
feeSelectorUM = FeeSelectorUM.Loading, feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty, confirmUM = ConfirmUM.Empty,
confirmData = null, confirmData = null,
navigationUM = NavigationUM.Empty,
) )
} }
router.popTo(CommonSendRoute.Amount(isEditMode = false)) router.popTo(CommonSendRoute.Amount(isEditMode = false))
@ -442,7 +435,7 @@ internal class SendModel @Inject constructor(
cryptoCurrencyStatusFlow, cryptoCurrencyStatusFlow,
feeCryptoCurrencyStatusFlow, feeCryptoCurrencyStatusFlow,
) { cryptoCurrencyStatus, _ -> ) { cryptoCurrencyStatus, _ ->
if (!isAvailableForSend || currentRoute.value != initialRoute) { if (!isAvailableForSend) {
if (isUnavailableForSend) showAlertError() if (isUnavailableForSend) showAlertError()
return@combine return@combine
} }
@ -548,7 +541,6 @@ internal class SendModel @Inject constructor(
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
).transform(DestinationUM.Empty()), ).transform(DestinationUM.Empty()),
confirmUM = ConfirmUM.Empty, confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
confirmData = null, confirmData = null,
feeSelectorUM = FeeSelectorUM.Loading, feeSelectorUM = FeeSelectorUM.Loading,
) )

View file

@ -1,27 +1,77 @@
package com.tangem.features.send.send.success package com.tangem.features.send.send.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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.navigationButtons.DoneButtons
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.core.decompose.context.AppComponentContext 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.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent 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.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
import com.tangem.features.send.impl.R
import com.tangem.features.send.send.success.model.SendConfirmSuccessModel import com.tangem.features.send.send.success.model.SendConfirmSuccessModel
import com.tangem.features.send.send.success.ui.SendConfirmSuccessContent import com.tangem.features.send.send.success.ui.SendConfirmSuccessContent
import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.send.ui.state.SendUM
import kotlinx.coroutines.flow.Flow import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
internal class SendConfirmSuccessComponent( internal class SendConfirmSuccessComponent @AssistedInject constructor(
appComponentContext: AppComponentContext, @Assisted appComponentContext: AppComponentContext,
params: Params, @Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext { destinationBlockComponentFactory: SendDestinationBlockComponent.Factory,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SendConfirmSuccessModel = getOrCreateModel(params = params) private val model: SendConfirmSuccessModel = getOrCreateModel(params = params)
private val destinationBlockComponent: SendDestinationBlockComponent = params.destinationBlockComponent private val destinationBlockComponent: SendDestinationBlockComponent = destinationBlockComponentFactory.create(
context = child("sendConfirmDestinationBlock"),
params = SendDestinationComponentParams.DestinationBlockParams(
state = model.uiState.value.destinationUM,
analyticsCategoryName = params.analyticsCategoryName,
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send,
userWalletId = params.userWalletId,
cryptoCurrency = params.cryptoCurrency,
blockClickEnableFlow = MutableStateFlow(true),
predefinedValues = params.predefinedValues,
isAddContactAvailable = true,
),
onResult = {},
onClick = {},
)
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
onBackClick = {
model.onBackClick()
router.pop()
},
backIconRes = R.drawable.ic_close_24,
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
@ -32,11 +82,43 @@ internal class SendConfirmSuccessComponent(
) )
} }
@Composable
override fun Footer() {
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,
)).takeIf { params.txUrl.isNotEmpty() },
)
PrimaryButton(
text = stringResourceSafe(R.string.common_close),
onClick = router::pop,
modifier = Modifier.fillMaxWidth(),
)
}
}
data class Params( data class Params(
val sendUMFlow: StateFlow<SendUM>, val sendUMFlow: StateFlow<SendUM>,
val destinationBlockComponent: SendDestinationBlockComponent, val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val analyticsCategoryName: String, val analyticsCategoryName: String,
val currentRoute: Flow<CommonSendRoute>, val predefinedValues: PredefinedValues,
val txUrl: String, val txUrl: String,
val callback: ModelCallback, val callback: ModelCallback,
) )
@ -44,4 +126,9 @@ internal class SendConfirmSuccessComponent(
interface ModelCallback { interface ModelCallback {
fun onResult(sendUM: SendUM) fun onResult(sendUM: SendUM)
} }
@AssistedFactory
interface Factory {
fun create(appComponentContext: AppComponentContext, params: Params): SendConfirmSuccessComponent
}
} }

View file

@ -1,28 +1,17 @@
package com.tangem.features.send.send.success.model package com.tangem.features.send.send.success.model
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.send.success.SendConfirmSuccessComponent import com.tangem.features.send.send.success.SendConfirmSuccessComponent
import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject import javax.inject.Inject
@Stable @Stable
@ -31,7 +20,6 @@ internal class SendConfirmSuccessModel @Inject constructor(
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
private val urlOpener: UrlOpener, private val urlOpener: UrlOpener,
private val shareManager: ShareManager, private val shareManager: ShareManager,
) : Model() { ) : Model() {
@ -39,73 +27,23 @@ internal class SendConfirmSuccessModel @Inject constructor(
private val _uiState = params.sendUMFlow private val _uiState = params.sendUMFlow
val uiState = _uiState val uiState = _uiState
init { fun onBackClick() {
configConfirmSuccessNavigation() analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = true,
),
)
} }
private fun configConfirmSuccessNavigation() { fun onExploreClick() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter { (state, route) ->
// Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh
// lambdas every time, so the SendUM written back via callback.onResult is never equal to the
// previous one — without this guard the combine re-triggers itself endlessly and the success
// screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY].
route is CommonSendRoute.ConfirmSuccess &&
(state.navigationUM as? NavigationUM.Content)?.source !=
CommonSendRoute.ConfirmSuccess.javaClass.simpleName
}.onEach { (state, _) ->
params.callback.onResult(
state.copy(
navigationUM = NavigationUM.Content(
source = CommonSendRoute.ConfirmSuccess.javaClass.simpleName,
title = stringReference(""),
subtitle = null,
backIconRes = R.drawable.ic_close_24,
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = true,
),
)
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,
)).takeIf { params.txUrl.isNotEmpty() },
),
),
)
}.launchIn(modelScope)
}
private fun onExploreClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName)) analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName))
urlOpener.openUrl(params.txUrl) urlOpener.openUrl(params.txUrl)
} }
private fun onShareClick() { fun onShareClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName)) analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName))
shareManager.shareText(params.txUrl) shareManager.shareText(params.txUrl)
} }

View file

@ -11,7 +11,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tangem.common.ui.amountScreen.ui.AmountBlock import com.tangem.common.ui.amountScreen.ui.AmountBlock
import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2
import com.tangem.core.ui.components.Fade import com.tangem.core.ui.components.Fade
import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.transactions.TransactionDoneTitle import com.tangem.core.ui.components.transactions.TransactionDoneTitle
@ -63,14 +62,6 @@ internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent
backgroundColor = TangemTheme.colors.background.tertiary, backgroundColor = TangemTheme.colors.background.tertiary,
) )
} }
NavigationButtonsBlockV2(
navigationUM = sendUM.navigationUM,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
} }
} }
} }

View file

@ -1,9 +1,8 @@
package com.tangem.features.send.send.ui.state package com.tangem.features.send.send.ui.state
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
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.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.send.confirm.model.ConfirmData import com.tangem.features.send.send.confirm.model.ConfirmData
@ -12,6 +11,5 @@ internal data class SendUM(
val destinationUM: DestinationUM, val destinationUM: DestinationUM,
val feeSelectorUM: FeeSelectorUM, val feeSelectorUM: FeeSelectorUM,
val confirmUM: ConfirmUM, val confirmUM: ConfirmUM,
val navigationUM: NavigationUM,
val confirmData: ConfirmData?, val confirmData: ConfirmData?,
) )

View file

@ -4,7 +4,6 @@ import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.childStack
@ -16,14 +15,15 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.NFTSendComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.subcomponents.destination.DestinationRoute
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.ui.SendContent import com.tangem.features.send.common.ui.SendModularContent
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.impl.R import com.tangem.features.send.impl.R
import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent
@ -32,16 +32,14 @@ import com.tangem.features.send.sendnft.success.NFTSendSuccessComponent
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
internal class DefaultNFTSendComponent @AssistedInject constructor( internal class DefaultNFTSendComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext, @Assisted appComponentContext: AppComponentContext,
@Assisted private val params: NFTSendComponent.Params, @Assisted private val params: NFTSendComponent.Params,
private val destinationComponentFactory: SendDestinationComponent.Factory,
private val nftSendConfirmComponentFactory: NFTSendConfirmComponent.Factory, private val nftSendConfirmComponentFactory: NFTSendConfirmComponent.Factory,
private val nftSendSuccessComponentFactory: NFTSendSuccessComponent.Factory, private val nftSendSuccessComponentFactory: NFTSendSuccessComponent.Factory,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val sendDestinationComponentFactory: SendDestinationComponent.Factory,
) : NFTSendComponent, AppComponentContext by appComponentContext { ) : NFTSendComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<CommonSendRoute>() private val stackNavigation = StackNavigation<CommonSendRoute>()
@ -77,37 +75,35 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
lifecycle = lifecycle, lifecycle = lifecycle,
mode = ObserveLifecycleMode.CREATE_DESTROY, mode = ObserveLifecycleMode.CREATE_DESTROY,
) { stack -> ) { stack ->
componentScope.launch { when (val activeComponent = stack.active.instance) {
when (val activeComponent = stack.active.instance) { is NFTSendConfirmComponent -> {
is NFTSendConfirmComponent -> { val fromCurrency = model.cryptoCurrency
val fromCurrency = model.cryptoCurrency val fromDerivationIndex = model.account?.derivationIndex?.value
val fromDerivationIndex = model.account?.derivationIndex?.value .takeIf { model.isAccountsMode }
.takeIf { model.isAccountsMode } analyticsEventHandler.send(
analyticsEventHandler.send( CommonSendAnalyticEvents.ConfirmationScreenOpened(
CommonSendAnalyticEvents.ConfirmationScreenOpened( categoryName = analyticsCategoryName,
categoryName = analyticsCategoryName, source = analyticsSendSource,
source = analyticsSendSource, sendBlockchain = fromCurrency.network.name,
sendBlockchain = fromCurrency.network.name, sendToken = fromCurrency.symbol,
sendToken = fromCurrency.symbol, fromDerivationIndex = fromDerivationIndex,
fromDerivationIndex = fromDerivationIndex, toDerivationIndex = null,
toDerivationIndex = null, ),
), )
) // Push current state into a reused Confirm on (re)entry. Confirm.isEditMode is `true`
if (model.currentRouteFlow.value.isEditMode) { if (stack.active.configuration.isEditMode) {
activeComponent.updateState(model.uiState.value) activeComponent.updateState(model.uiState.value)
}
}
is SendDestinationComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AddressScreenOpened(
categoryName = analyticsCategoryName,
source = analyticsSendSource,
),
)
activeComponent.updateState(model.uiState.value.destinationUM)
} }
} }
model.currentRouteFlow.emit(stack.active.configuration) is SendDestinationComponent -> {
analyticsEventHandler.send(
CommonSendAnalyticEvents.AddressScreenOpened(
categoryName = analyticsCategoryName,
source = analyticsSendSource,
),
)
activeComponent.updateState(model.uiState.value.destinationUM)
}
} }
} }
} }
@ -115,67 +111,71 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val stackState by childStack.subscribeAsState() val stackState by childStack.subscribeAsState()
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler(onBack = model::onBackClick) BackHandler(onBack = ::onChildBack)
SendContent( SendModularContent(stackState = stackState)
navigationUM = state.navigationUM,
confirmUM = state.confirmUM,
stackState = stackState,
)
} }
private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { private fun createChild(
is CommonSendRoute.Destination -> getDestinationComponent(factoryContext) route: CommonSendRoute,
factoryContext: AppComponentContext,
): ComposableModularContentComponent = when (route) {
is CommonSendRoute.Destination -> getDestinationComponent(route, factoryContext)
CommonSendRoute.Confirm -> getConfirmComponent(factoryContext) CommonSendRoute.Confirm -> getConfirmComponent(factoryContext)
CommonSendRoute.ConfirmSuccess -> getSuccessComponent(factoryContext) CommonSendRoute.ConfirmSuccess -> getSuccessComponent(factoryContext)
else -> getStubComponent() // Empty is the bootstrap placeholder until the currency status resolves; NFT has no Amount step.
CommonSendRoute.Empty,
is CommonSendRoute.Amount,
-> ComposableModularContentComponent.EMPTY
} }
private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent = private fun getDestinationComponent(
sendDestinationComponentFactory.create( route: DestinationRoute,
context = factoryContext, factoryContext: AppComponentContext,
params = SendDestinationComponentParams.DestinationParams( ): ComposableModularContentComponent = destinationComponentFactory.create(
state = model.uiState.value.destinationUM, context = factoryContext,
currentRoute = model.currentRouteFlow.filterIsInstance<CommonSendRoute.Destination>(), params = SendDestinationComponentParams.DestinationParams(
isBalanceHidingFlow = model.isBalanceHiddenFlow, state = model.uiState.value.destinationUM,
title = resourceReference(R.string.nft_send), route = route,
analyticsCategoryName = analyticsCategoryName,
analyticsSendSource = analyticsSendSource,
userWalletId = params.userWalletId,
cryptoCurrency = model.cryptoCurrency,
callback = model,
),
)
private fun getConfirmComponent(factoryContext: AppComponentContext) = nftSendConfirmComponentFactory.create(
appComponentContext = factoryContext,
params = NFTSendConfirmComponent.Params(
state = model.uiState.value,
analyticsCategoryName = analyticsCategoryName,
userWallet = model.userWallet,
nftAsset = params.nftAsset,
nftCollectionName = params.nftCollectionName,
cryptoCurrencyStatus = model.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus,
appCurrency = model.appCurrency,
callback = model,
currentRoute = model.currentRouteFlow.filterIsInstance<CommonSendRoute.Confirm>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow, isBalanceHidingFlow = model.isBalanceHiddenFlow,
onLoadFee = model::loadFee, title = resourceReference(R.string.nft_send),
analyticsCategoryName = analyticsCategoryName,
analyticsSendSource = analyticsSendSource, analyticsSendSource = analyticsSendSource,
account = model.account, userWalletId = params.userWalletId,
isAccountsMode = model.isAccountsMode, cryptoCurrency = model.cryptoCurrency,
onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) }, callback = model,
), ),
) )
private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent { private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
return nftSendConfirmComponentFactory.create(
appComponentContext = factoryContext,
params = NFTSendConfirmComponent.Params(
state = model.uiState.value,
analyticsCategoryName = analyticsCategoryName,
userWallet = model.userWallet,
nftAsset = params.nftAsset,
nftCollectionName = params.nftCollectionName,
cryptoCurrencyStatus = model.cryptoCurrencyStatus,
feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus,
appCurrency = model.appCurrency,
callback = model,
isBalanceHidingFlow = model.isBalanceHiddenFlow,
onLoadFee = model::loadFee,
analyticsSendSource = analyticsSendSource,
account = model.account,
isAccountsMode = model.isAccountsMode,
onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) },
),
)
}
private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent {
val txUrl = (model.uiState.value.confirmUM as? ConfirmUM.Success)?.txUrl val txUrl = (model.uiState.value.confirmUM as? ConfirmUM.Success)?.txUrl
if (txUrl == null) { if (txUrl == null) {
model.showAlertError() model.showAlertError()
return getStubComponent() return ComposableModularContentComponent.EMPTY
} }
return nftSendSuccessComponentFactory.create( return nftSendSuccessComponentFactory.create(
@ -189,7 +189,6 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
nftAsset = params.nftAsset, nftAsset = params.nftAsset,
nftCollectionName = params.nftCollectionName, nftCollectionName = params.nftCollectionName,
callback = model, callback = model,
currentRoute = model.currentRouteFlow.filterIsInstance<CommonSendRoute.ConfirmSuccess>(),
txUrl = txUrl, txUrl = txUrl,
account = model.account, account = model.account,
isAccountsMode = model.isAccountsMode, isAccountsMode = model.isAccountsMode,
@ -197,8 +196,6 @@ internal class DefaultNFTSendComponent @AssistedInject constructor(
) )
} }
private fun getStubComponent() = ComposableContentComponent { }
private fun onChildBack() { private fun onChildBack() {
val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty
val isEmptyStack = childStack.value.backStack.isEmpty() val isEmptyStack = childStack.value.backStack.isEmpty()

View file

@ -1,17 +1,28 @@
package com.tangem.features.send.sendnft.confirm package com.tangem.features.send.sendnft.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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import arrow.core.Either import arrow.core.Either
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
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.AppComponentContext
import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -19,21 +30,20 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.features.nft.component.NFTDetailsBlockComponent import com.tangem.features.nft.component.NFTDetailsBlockComponent
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.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams
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.feeSelector.params.FeeSelectorParams
import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.impl.R
import com.tangem.features.send.sendnft.confirm.model.NFTSendConfirmModel import com.tangem.features.send.sendnft.confirm.model.NFTSendConfirmModel
import com.tangem.features.send.sendnft.confirm.ui.NFTSendConfirmContent import com.tangem.features.send.sendnft.confirm.ui.NFTSendConfirmContent
import com.tangem.features.send.sendnft.ui.state.NFTSendUM import com.tangem.features.send.sendnft.ui.state.NFTSendUM
import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent
import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent
import com.tangem.features.send.impl.R
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -45,7 +55,7 @@ internal class NFTSendConfirmComponent @AssistedInject constructor(
@Assisted params: Params, @Assisted params: Params,
nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory,
feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext { ) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: NFTSendConfirmModel = getOrCreateModel(params = params) private val model: NFTSendConfirmModel = getOrCreateModel(params = params)
@ -129,6 +139,20 @@ internal class NFTSendConfirmComponent @AssistedInject constructor(
model.updateState(state) model.updateState(state)
} }
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
text = stringResourceSafe(R.string.nft_send),
onBackClick = {
model.onBackClick()
router.pop()
},
backIconRes = R.drawable.ic_back_24,
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle() val state by model.uiState.collectAsStateWithLifecycle()
@ -144,6 +168,29 @@ internal class NFTSendConfirmComponent @AssistedInject constructor(
) )
} }
@Composable
override fun Footer() {
val state by model.uiState.collectAsStateWithLifecycle()
Column {
val sendingFooter = (state.confirmUM as? ConfirmUM.Content)?.sendingFooter
AnimatedVisibility(
visible = sendingFooter != null,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
SendingText(footerText = sendingFooter ?: TextReference.EMPTY)
}
NavigationPrimaryButton(
primaryButton = model.primaryButtonUM(),
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
}
data class Params( data class Params(
val state: NFTSendUM, val state: NFTSendUM,
val analyticsCategoryName: String, val analyticsCategoryName: String,
@ -157,7 +204,6 @@ internal class NFTSendConfirmComponent @AssistedInject constructor(
val account: Account.CryptoPortfolio?, val account: Account.CryptoPortfolio?,
val isAccountsMode: Boolean, val isAccountsMode: Boolean,
val callback: ModelCallback, val callback: ModelCallback,
val currentRoute: Flow<CommonSendRoute.Confirm>,
val isBalanceHidingFlow: StateFlow<Boolean>, val isBalanceHidingFlow: StateFlow<Boolean>,
val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>, val onLoadFee: suspend () -> Either<GetFeeError, TransactionFee>,
val onSendTransaction: () -> Unit, val onSendTransaction: () -> Unit,

View file

@ -5,7 +5,6 @@ import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionData
import com.tangem.common.routing.AppRouter import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.navigationButtons.NavigationButton 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.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam
@ -32,28 +31,28 @@ import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.features.nft.entity.NFTSendSuccessTrigger import com.tangem.features.nft.entity.NFTSendSuccessTrigger
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
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource 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.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger 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.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.SendNotificationsUpdateListener
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.SendBalanceUpdater import com.tangem.features.send.common.SendBalanceUpdater
import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.SendConfirmAlertFactory
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.impl.R
import com.tangem.features.send.sendnft.analytics.NFTSendAnalyticHelper import com.tangem.features.send.sendnft.analytics.NFTSendAnalyticHelper
import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent
import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmInitialStateTransformer import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmInitialStateTransformer
import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmSendingStateTransformer import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmSendingStateTransformer
import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2 import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2
import com.tangem.features.send.sendnft.ui.state.NFTSendUM import com.tangem.features.send.sendnft.ui.state.NFTSendUM
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.stripZeroPlainString import com.tangem.utils.extensions.stripZeroPlainString
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
@ -122,7 +121,6 @@ internal class NFTSendConfirmModel @Inject constructor(
private var sendIdleTimer: Long = 0L private var sendIdleTimer: Long = 0L
init { init {
configConfirmNavigation()
subscribeOnNotificationsUpdateTrigger() subscribeOnNotificationsUpdateTrigger()
subscribeOnCheckFeeResultUpdates() subscribeOnCheckFeeResultUpdates()
subscribeOnTapHelpUpdates() subscribeOnTapHelpUpdates()
@ -371,54 +369,18 @@ internal class NFTSendConfirmModel @Inject constructor(
} }
} }
private fun configConfirmNavigation() { fun onBackClick() {
combine( analyticsEventHandler.send(
flow = uiState, CommonSendAnalyticEvents.CloseButtonClicked(
flow2 = params.currentRoute, categoryName = analyticsCategoryName,
transform = { state, route -> state to route }, source = SendScreenSource.Confirm,
).onEach { (state, _) -> isFromSummary = true,
val confirmUM = state.confirmUM isValid = uiState.value.confirmUM.isPrimaryButtonEnabled,
params.callback.onResult( ),
state.copy( )
navigationUM = NavigationUM.Content(
source = CommonSendRoute.Confirm.javaClass.simpleName,
title = resourceReference(R.string.nft_send),
subtitle = null,
backIconRes = when (confirmUM) {
is ConfirmUM.Success -> R.drawable.ic_close_24
else -> R.drawable.ic_back_24
},
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = confirmUM.isPrimaryButtonEnabled,
),
)
router.pop()
},
primaryButton = primaryButtonUM(),
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 as? ConfirmUM.Success)?.txUrl.isNullOrBlank() },
),
),
)
}.launchIn(modelScope)
} }
private fun primaryButtonUM(): NavigationButton { fun primaryButtonUM(): NavigationButton {
val confirmUM = uiState.value.confirmUM val confirmUM = uiState.value.confirmUM
val isContent = confirmUM is ConfirmUM.Content val isContent = confirmUM is ConfirmUM.Content
val isReadyToSend = isContent && !confirmUM.isSending val isReadyToSend = isContent && !confirmUM.isSending

View file

@ -5,13 +5,13 @@ import arrow.core.Either
import arrow.core.getOrElse import arrow.core.getOrElse
import arrow.core.left import arrow.core.left
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.navigation.Router
import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
@ -36,9 +36,9 @@ import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.nft.entity.NFTSendSuccessTrigger import com.tangem.features.nft.entity.NFTSendSuccessTrigger
import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.NFTSendComponent
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.SendDestinationComponent
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.SendConfirmAlertFactory
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
@ -82,8 +82,6 @@ internal class NFTSendModel @Inject constructor(
val initialRoute = CommonSendRoute.Empty val initialRoute = CommonSendRoute.Empty
val currentRouteFlow = MutableStateFlow<CommonSendRoute>(initialRoute)
private val userWalletId = params.userWalletId private val userWalletId = params.userWalletId
private val nftAsset = params.nftAsset private val nftAsset = params.nftAsset
@ -107,10 +105,6 @@ internal class NFTSendModel @Inject constructor(
initAppCurrency() initAppCurrency()
} }
override fun onNavigationResult(navigationUM: NavigationUM) {
uiState.update { it.copy(navigationUM = navigationUM) }
}
override fun onResult(nftSendUM: NFTSendUM) { override fun onResult(nftSendUM: NFTSendUM) {
uiState.value = nftSendUM uiState.value = nftSendUM
} }
@ -119,8 +113,8 @@ internal class NFTSendModel @Inject constructor(
uiState.update { it.copy(destinationUM = destinationUM) } uiState.update { it.copy(destinationUM = destinationUM) }
} }
override fun onBackClick() { override fun onBackClick(currentRoute: Route) {
if (currentRouteFlow.value == CommonSendRoute.ConfirmSuccess) { if (currentRoute == CommonSendRoute.ConfirmSuccess) {
modelScope.launch { modelScope.launch {
nftSendSuccessTrigger.triggerSuccessNFTSend() nftSendSuccessTrigger.triggerSuccessNFTSend()
} }
@ -128,14 +122,14 @@ internal class NFTSendModel @Inject constructor(
router.pop() router.pop()
} }
override fun onNextClick() { override fun onNextClick(currentRoute: Route) {
if (currentRouteFlow.value.isEditMode) { if ((currentRoute as? CommonSendRoute)?.isEditMode == true) {
onBackClick() onBackClick(currentRoute)
} else { } else {
when (currentRouteFlow.value) { when (currentRoute) {
is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm) is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm)
CommonSendRoute.Confirm -> router.replaceAll(CommonSendRoute.ConfirmSuccess) CommonSendRoute.Confirm -> router.replaceAll(CommonSendRoute.ConfirmSuccess)
else -> onBackClick() else -> onBackClick(currentRoute)
} }
} }
} }
@ -241,6 +235,5 @@ internal class NFTSendModel @Inject constructor(
destinationUM = DestinationUM.Empty(), destinationUM = DestinationUM.Empty(),
feeSelectorUM = FeeSelectorUM.Loading, feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty, confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
) )
} }

View file

@ -1,15 +1,27 @@
package com.tangem.features.send.sendnft.success package com.tangem.features.send.sendnft.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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.common.ui.navigationButtons.NavigationModelCallback
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent 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.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
@ -19,24 +31,22 @@ import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams
import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.impl.R
import com.tangem.features.send.sendnft.success.model.NFTSendSuccessModel import com.tangem.features.send.sendnft.success.model.NFTSendSuccessModel
import com.tangem.features.send.sendnft.success.ui.NFTSendSuccessContent import com.tangem.features.send.sendnft.success.ui.NFTSendSuccessContent
import com.tangem.features.send.sendnft.ui.state.NFTSendUM import com.tangem.features.send.sendnft.ui.state.NFTSendUM
import com.tangem.features.send.impl.R
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
internal class NFTSendSuccessComponent @AssistedInject constructor( internal class NFTSendSuccessComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext, @Assisted appComponentContext: AppComponentContext,
@Assisted params: Params, @Assisted private val params: Params,
nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory,
sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory, sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext { ) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: NFTSendSuccessModel = getOrCreateModel(params = params) private val model: NFTSendSuccessModel = getOrCreateModel(params = params)
@ -69,6 +79,19 @@ internal class NFTSendSuccessComponent @AssistedInject constructor(
onClick = {}, onClick = {},
) )
@Composable
override fun Title() {
AppBarWithBackButtonAndIcon(
onBackClick = {
model.onBackClick()
router.pop()
},
backIconRes = R.drawable.ic_close_24,
backgroundColor = TangemTheme.colors.background.tertiary,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
}
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle() val state by model.uiState.collectAsStateWithLifecycle()
@ -80,11 +103,40 @@ internal class NFTSendSuccessComponent @AssistedInject constructor(
) )
} }
@Composable
override fun Footer() {
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,
)).takeIf { params.txUrl.isNotEmpty() },
)
PrimaryButton(
text = stringResourceSafe(R.string.common_close),
onClick = router::pop,
modifier = Modifier.fillMaxWidth(),
)
}
}
data class Params( data class Params(
val nftSendUMFlow: StateFlow<NFTSendUM>, val nftSendUMFlow: StateFlow<NFTSendUM>,
val analyticsCategoryName: String, val analyticsCategoryName: String,
val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
val currentRoute: Flow<CommonSendRoute>,
val cryptoCurrencyStatus: CryptoCurrencyStatus, val cryptoCurrencyStatus: CryptoCurrencyStatus,
val userWallet: UserWallet, val userWallet: UserWallet,
val nftAsset: NFTAsset, val nftAsset: NFTAsset,

View file

@ -1,27 +1,17 @@
package com.tangem.features.send.sendnft.success.model package com.tangem.features.send.sendnft.success.model
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.sendnft.success.NFTSendSuccessComponent import com.tangem.features.send.sendnft.success.NFTSendSuccessComponent
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject import javax.inject.Inject
@Stable @Stable
@ -37,71 +27,23 @@ internal class NFTSendSuccessModel @Inject constructor(
val uiState = params.nftSendUMFlow val uiState = params.nftSendUMFlow
init { fun onBackClick() {
configConfirmSuccessNavigation() analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = true,
),
)
} }
private fun configConfirmSuccessNavigation() { fun onExploreClick() {
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).filter { (state, route) ->
// Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh
// lambdas every time, so the SendUM written back via callback.onResult is never equal to the
// previous one — without this guard the combine re-triggers itself endlessly and the success
// screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY].
route is CommonSendRoute.ConfirmSuccess &&
(state.navigationUM as? NavigationUM.Content)?.source !=
CommonSendRoute.ConfirmSuccess.javaClass.simpleName
}.onEach { (state, _) ->
params.callback.onResult(
state.copy(
navigationUM = NavigationUM.Content(
source = CommonSendRoute.ConfirmSuccess.javaClass.simpleName,
title = stringReference(""),
subtitle = null,
backIconRes = R.drawable.ic_close_24,
backIconClick = {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Confirm,
isFromSummary = true,
isValid = true,
),
)
params.callback.onBackClick()
},
primaryButton = NavigationButton(
textReference = resourceReference(R.string.common_close),
iconRes = null,
isEnabled = true,
isHapticClick = false,
onClick = params.callback::onBackClick,
),
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,
),
),
),
)
}.launchIn(modelScope)
}
private fun onExploreClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName)) analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName))
urlOpener.openUrl(params.txUrl) urlOpener.openUrl(params.txUrl)
} }
private fun onShareClick() { fun onShareClick() {
analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName)) analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName))
shareManager.shareText(params.txUrl) shareManager.shareText(params.txUrl)
} }

View file

@ -9,7 +9,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2
import com.tangem.core.ui.components.Fade import com.tangem.core.ui.components.Fade
import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.transactions.TransactionDoneTitle import com.tangem.core.ui.components.transactions.TransactionDoneTitle
@ -23,8 +22,8 @@ import com.tangem.features.nft.component.NFTDetailsBlockComponent
import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.common.ui.FeeBlockSuccess import com.tangem.features.send.common.ui.FeeBlockSuccess
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.sendnft.ui.state.NFTSendUM
import com.tangem.features.send.impl.R import com.tangem.features.send.impl.R
import com.tangem.features.send.sendnft.ui.state.NFTSendUM
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@Composable @Composable
@ -52,30 +51,20 @@ internal fun NFTSendSuccessContent(
label = "Animate success content", label = "Animate success content",
modifier = modifier, modifier = modifier,
) { ) {
Column { Box(
Box( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.weight(1f) .background(TangemTheme.colors.background.tertiary),
.background(TangemTheme.colors.background.tertiary), ) {
) { SuccessContent(
SuccessContent( nftSendUM = nftSendUM,
nftSendUM = nftSendUM, nftDetailsBlockComponent = nftDetailsBlockComponent,
nftDetailsBlockComponent = nftDetailsBlockComponent, destinationBlockComponent = destinationBlockComponent,
destinationBlockComponent = destinationBlockComponent, modifier = Modifier.fillMaxHeight(),
modifier = Modifier.fillMaxHeight(), )
) Fade(
Fade( modifier = Modifier.align(Alignment.BottomCenter),
modifier = Modifier.align(Alignment.BottomCenter), backgroundColor = TangemTheme.colors.background.tertiary,
backgroundColor = TangemTheme.colors.background.tertiary,
)
}
NavigationButtonsBlockV2(
navigationUM = nftSendUM.navigationUM,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
) )
} }
} }

View file

@ -1,13 +1,11 @@
package com.tangem.features.send.sendnft.ui.state package com.tangem.features.send.sendnft.ui.state
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.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
internal data class NFTSendUM( internal data class NFTSendUM(
val destinationUM: DestinationUM, val destinationUM: DestinationUM,
val feeSelectorUM: FeeSelectorUM, val feeSelectorUM: FeeSelectorUM,
val confirmUM: ConfirmUM, val confirmUM: ConfirmUM,
val navigationUM: NavigationUM,
) )

View file

@ -1,14 +1,23 @@
package com.tangem.features.send.subcomponents.amount package com.tangem.features.send.subcomponents.amount
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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel 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.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.impl.R
import com.tangem.features.send.subcomponents.amount.model.SendAmountModel import com.tangem.features.send.subcomponents.amount.model.SendAmountModel
import com.tangem.features.send.subcomponents.amount.ui.SendAmountContent import com.tangem.features.send.subcomponents.amount.ui.SendAmountContent
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -24,6 +33,23 @@ internal class DefaultSendAmountComponent @AssistedInject constructor(
override fun updateState(amountUM: AmountState) = model.updateState(amountUM) override fun updateState(amountUM: AmountState) = 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 @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle() val state by model.uiState.collectAsStateWithLifecycle()
@ -37,6 +63,30 @@ internal class DefaultSendAmountComponent @AssistedInject constructor(
) )
} }
@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,
),
)
}
@AssistedFactory @AssistedFactory
interface Factory : SendAmountComponent.Factory { interface Factory : SendAmountComponent.Factory {
override fun create( override fun create(

View file

@ -10,8 +10,6 @@ import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
@ -29,14 +27,12 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.entity.isFromMainScreenQr import com.tangem.features.send.api.entity.isFromMainScreenQr
import com.tangem.features.send.api.subcomponents.amount.AmountRoute
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents.SelectedCurrencyType import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents.SelectedCurrencyType
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.impl.R import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.orZero
@ -84,7 +80,6 @@ internal class SendAmountModel @Inject constructor(
private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull() private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull()
init { init {
configAmountNavigation()
initAppCurrency() initAppCurrency()
subscribeOnCryptoCurrencyStatusFlow() subscribeOnCryptoCurrencyStatusFlow()
subscribeOnAmountReduceByTriggerUpdates() subscribeOnAmountReduceByTriggerUpdates()
@ -104,6 +99,7 @@ internal class SendAmountModel @Inject constructor(
}, },
ifRight = { wallet -> ifRight = { wallet ->
userWallet = wallet userWallet = wallet
setSendWithSwapAvailability()
}, },
) )
}.launchIn(modelScope) }.launchIn(modelScope)
@ -274,9 +270,7 @@ internal class SendAmountModel @Inject constructor(
override fun onConvertToAnotherToken() { override fun onConvertToAnotherToken() {
val amountParams = params as? SendAmountComponentParams.AmountParams ?: return val amountParams = params as? SendAmountComponentParams.AmountParams ?: return
modelScope.launch { modelScope.launch {
var isEditMode = false if (amountParams.route.isEditMode) {
amountParams.currentRoute.collect { route -> isEditMode = route.isEditMode }
if (isEditMode) {
sendAmountAlertFactory.showResetSendingAlert { sendAmountAlertFactory.showResetSendingAlert {
params.callback.resetSendNavigation() params.callback.resetSendNavigation()
confirmConvertToToken() confirmConvertToToken()
@ -363,44 +357,6 @@ internal class SendAmountModel @Inject constructor(
) )
} }
private fun configAmountNavigation() {
val params = params as? SendAmountComponentParams.AmountParams ?: return
combine(
flow = uiState,
// Filter on the public AmountRoute interface (not the internal CommonSendRoute.Amount) so an
// external host (e.g. staking) that supplies its own AmountRoute is not silently dropped here.
flow2 = params.currentRoute.filterIsInstance<AmountRoute>(),
transform = { state, route -> state to route },
).onEach { (state, route) ->
setSendWithSwapAvailability()
params.callback.onNavigationResult(
NavigationUM.Content(
source = CommonSendRoute.Amount::class.java.simpleName,
title = resourceReference(R.string.send_amount_label),
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 fun setSendWithSwapAvailability() { private fun setSendWithSwapAvailability() {
// Allowed only in multicurrency wallets // Allowed only in multicurrency wallets
val isMultiCurrency = userWallet?.isMultiCurrency == true val isMultiCurrency = userWallet?.isMultiCurrency == true

View file

@ -1,8 +1,13 @@
package com.tangem.features.send.subcomponents.destination package com.tangem.features.send.subcomponents.destination
import androidx.activity.compose.BackHandler
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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.childSlot
@ -14,9 +19,15 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.addressbook.AddressBookContactsBlockComponent import com.tangem.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.AddressBookFeatureToggles import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.addressbook.AddressSelectorComponent
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams 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.destination.entity.DestinationUM
import com.tangem.features.send.impl.R
import com.tangem.features.send.subcomponents.destination.model.SendDestinationModel import com.tangem.features.send.subcomponents.destination.model.SendDestinationModel
import com.tangem.features.send.subcomponents.destination.ui.SendDestinationContent import com.tangem.features.send.subcomponents.destination.ui.SendDestinationContent
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -68,6 +79,22 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor(
override fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM) override fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM)
@Composable
override fun Title() {
BackHandler(onBack = model::onBackClick)
AppBarWithBackButtonAndIcon(
text = params.title.resolveReference(),
onBackClick = model::onBackClick,
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 @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle() val state by model.uiState.collectAsStateWithLifecycle()
@ -83,6 +110,30 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor(
selector.child?.instance?.BottomSheet() selector.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.saveResult()
params.callback.onNextClick(params.route)
},
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
)
}
@AssistedFactory @AssistedFactory
interface Factory : SendDestinationComponent.Factory { interface Factory : SendDestinationComponent.Factory {
override fun create( override fun create(

View file

@ -8,14 +8,11 @@ import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.entity.AddressBookOpenMode import com.tangem.common.routing.entity.AddressBookOpenMode
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
@ -49,8 +46,6 @@ import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.impl.R
import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory
import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents
@ -142,7 +137,6 @@ internal class SendDestinationModel @Inject constructor(
private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null) private val backupProblematicWalletCache = AtomicReference<Pair<String, UserWalletId?>?>(null)
init { init {
configDestinationNavigation()
subscribeOnQRScannerResult() subscribeOnQRScannerResult()
initialState() initialState()
resetContactOnEdit() resetContactOnEdit()
@ -153,15 +147,12 @@ internal class SendDestinationModel @Inject constructor(
private fun resetContactOnEdit() { private fun resetContactOnEdit() {
val params = params as? SendDestinationComponentParams.DestinationParams ?: return val params = params as? SendDestinationComponentParams.DestinationParams ?: return
params.currentRoute if (params.route.isEditMode) {
.filter { it.isEditMode } val content = uiState.value as? DestinationUM.Content ?: return
.onEach { if (content.addressTextField.contactName != null) {
val content = uiState.value as? DestinationUM.Content ?: return@onEach _uiState.update(SendDestinationContactTransformer(contact = null))
if (content.addressTextField.contactName != null) {
_uiState.update(SendDestinationContactTransformer(contact = null))
}
} }
.launchIn(modelScope) }
} }
fun onContactClick(contact: MatchedContact) { fun onContactClick(contact: MatchedContact) {
@ -259,7 +250,23 @@ internal class SendDestinationModel @Inject constructor(
) )
} }
private fun saveResult() { fun onBackClick() {
val params = params as? SendDestinationComponentParams.DestinationParams ?: return
if (!params.route.isEditMode) {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Address,
isFromSummary = false,
isValid = uiState.value.isPrimaryButtonEnabled,
),
)
saveResult()
}
params.callback.onBackClick(params.route)
}
fun saveResult() {
val params = params as? SendDestinationComponentParams.DestinationParams ?: return val params = params as? SendDestinationComponentParams.DestinationParams ?: return
params.callback.onDestinationResult(uiState.value) params.callback.onDestinationResult(uiState.value)
} }
@ -490,60 +497,10 @@ internal class SendDestinationModel @Inject constructor(
private fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) { private fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) {
if (type.isAutoNext && isValidAddress && isValidMemo) { if (type.isAutoNext && isValidAddress && isValidMemo) {
saveResult() saveResult()
(params as? SendDestinationComponentParams.DestinationParams)?.callback?.onNextClick() (params as? SendDestinationComponentParams.DestinationParams)?.callback?.onNextClick(params.route)
} }
} }
@Suppress("LongMethod")
private fun configDestinationNavigation() {
val params = params as? SendDestinationComponentParams.DestinationParams ?: return
combine(
flow = uiState,
flow2 = params.currentRoute,
transform = { state, route -> state to route },
).onEach { (state, route) ->
params.callback.onNavigationResult(
NavigationUM.Content(
source = CommonSendRoute.Destination::class.java.simpleName,
title = params.title,
subtitle = null,
backIconRes = if (route.isEditMode) {
R.drawable.ic_back_24
} else {
R.drawable.ic_close_24
},
backIconClick = {
if (!route.isEditMode) {
analyticsEventHandler.send(
CommonSendAnalyticEvents.CloseButtonClicked(
categoryName = params.analyticsCategoryName,
source = SendScreenSource.Address,
isFromSummary = false,
isValid = state.isPrimaryButtonEnabled,
),
)
saveResult()
}
params.callback.onBackClick()
},
primaryButton = NavigationButton(
textReference = if (route.isEditMode) {
resourceReference(R.string.common_continue)
} else {
resourceReference(R.string.common_next)
},
isEnabled = state.isPrimaryButtonEnabled,
onClick = {
saveResult()
params.callback.onNextClick()
},
),
secondaryPairButtonsUM = null,
),
)
}.launchIn(modelScope)
}
private companion object { private companion object {
const val RECENT_TX_SIZE = 100 const val RECENT_TX_SIZE = 100
const val RECENT_LOAD_DELAY = 500L const val RECENT_LOAD_DELAY = 500L

View file

@ -1,16 +1,21 @@
package com.tangem.features.send.send package com.tangem.features.send.send
import arrow.core.Either import arrow.core.Either
import arrow.core.right
import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.routing.AppRouter import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
@ -21,55 +26,45 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.SendFeatureToggles import com.tangem.features.send.api.SendFeatureToggles
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.common.SendBalanceUpdater import com.tangem.features.send.common.SendBalanceUpdater
import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.SendConfirmAlertFactory
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.send.analytics.SendAnalyticHelper import com.tangem.features.send.send.analytics.SendAnalyticHelper
import com.tangem.features.send.send.confirm.SendConfirmComponent import com.tangem.features.send.send.confirm.SendConfirmComponent
import com.tangem.features.send.send.confirm.model.SendConfirmModel import com.tangem.features.send.send.confirm.model.SendConfirmModel
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger
import com.tangem.features.send.testDispatcherProvider
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase
import com.tangem.domain.settings.NeverShowTapHelpUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.qrscanning.models.SourceType
import arrow.core.right
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.send.model.SendModel import com.tangem.features.send.send.model.SendModel
import io.mockk.MockKAnnotations import com.tangem.features.send.send.ui.state.SendUM
import io.mockk.clearMocks import com.tangem.features.send.testDispatcherProvider
import io.mockk.coEvery import io.mockk.*
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -89,7 +84,8 @@ internal abstract class SendModelTestBase {
protected val router: Router = mockk(relaxed = true) protected val router: Router = mockk(relaxed = true)
protected val appRouter: AppRouter = mockk(relaxed = true) protected val appRouter: AppRouter = mockk(relaxed = true)
protected val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) protected val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true) protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase =
mockk(relaxed = true)
protected val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true) protected val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true)
protected val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true) protected val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true)
protected val parseQrCodeUseCase: ParseQrCodeUseCase = mockk(relaxed = true) protected val parseQrCodeUseCase: ParseQrCodeUseCase = mockk(relaxed = true)
@ -260,7 +256,6 @@ internal abstract class SendModelTestBase {
destinationUM = DestinationUM.Empty(), destinationUM = DestinationUM.Empty(),
feeSelectorUM = FeeSelectorUM.Loading, feeSelectorUM = FeeSelectorUM.Loading,
confirmUM = ConfirmUM.Empty, confirmUM = ConfirmUM.Empty,
navigationUM = NavigationUM.Empty,
confirmData = null, confirmData = null,
), ),
cryptoCurrencyStatus: CryptoCurrencyStatus = testCryptoCurrencyStatus, cryptoCurrencyStatus: CryptoCurrencyStatus = testCryptoCurrencyStatus,
@ -278,7 +273,6 @@ internal abstract class SendModelTestBase {
isAccountModeFlow = kotlinx.coroutines.flow.MutableStateFlow(false), isAccountModeFlow = kotlinx.coroutines.flow.MutableStateFlow(false),
appCurrency = AppCurrency.Default, appCurrency = AppCurrency.Default,
callback = mockk(relaxed = true), callback = mockk(relaxed = true),
currentRoute = kotlinx.coroutines.flow.flowOf(),
isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false), isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false),
predefinedValues = PredefinedValues.Empty, predefinedValues = PredefinedValues.Empty,
onLoadFee = { Either.Right(mockk(relaxed = true)) }, onLoadFee = { Either.Right(mockk(relaxed = true)) },

View file

@ -7,36 +7,25 @@ import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.models.TransactionFeeExtended
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.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem 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.FeeNonce
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM 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.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.send.SendModelTestBase import com.tangem.features.send.send.SendModelTestBase
import com.tangem.features.send.send.ui.state.SendUM
import com.tangem.test.core.ProvideTestModels import com.tangem.test.core.ProvideTestModels
import io.mockk.coEvery import io.mockk.*
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal import java.math.BigDecimal
@ -73,10 +62,30 @@ internal class SendConfirmModelTest : SendModelTestBase() {
// Assert // Assert
if (model.expectedSendInitiated) { if (model.expectedSendInitiated) {
coVerify(exactly = 1) { createTransferTransactionUseCase(any(), any<Fee>(), any(), any(), any(), any(), any()) } coVerify(exactly = 1) {
createTransferTransactionUseCase(
any(),
any<Fee>(),
any(),
any(),
any(),
any(),
any()
)
}
coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
} else { } else {
coVerify(exactly = 0) { createTransferTransactionUseCase(any(), any<Fee>(), any(), any(), any(), any(), any()) } coVerify(exactly = 0) {
createTransferTransactionUseCase(
any(),
any<Fee>(),
any(),
any(),
any(),
any(),
any()
)
}
coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
} }
} }
@ -108,9 +117,29 @@ internal class SendConfirmModelTest : SendModelTestBase() {
// Assert // Assert
if (model.expectedSendInitiated) { if (model.expectedSendInitiated) {
coVerify(exactly = 1) { createTransferTransactionUseCase(any(), any<Fee>(), any(), any(), any(), any(), any()) } coVerify(exactly = 1) {
createTransferTransactionUseCase(
any(),
any<Fee>(),
any(),
any(),
any(),
any(),
any()
)
}
} else { } else {
coVerify(exactly = 0) { createTransferTransactionUseCase(any(), any<Fee>(), any(), any(), any(), any(), any()) } coVerify(exactly = 0) {
createTransferTransactionUseCase(
any(),
any<Fee>(),
any(),
any(),
any(),
any(),
any()
)
}
} }
} }
@ -161,7 +190,8 @@ internal class SendConfirmModelTest : SendModelTestBase() {
fun `GIVEN successful send WHEN verifyAndSend THEN notify onSendTransaction`() = runTest { fun `GIVEN successful send WHEN verifyAndSend THEN notify onSendTransaction`() = runTest {
// Arrange // Arrange
val onSendTransaction = mockk<() -> Unit>(relaxed = true) val onSendTransaction = mockk<() -> Unit>(relaxed = true)
val callback = mockk<com.tangem.features.send.send.confirm.SendConfirmComponent.ModelCallback>(relaxed = true) val callback =
mockk<com.tangem.features.send.send.confirm.SendConfirmComponent.ModelCallback>(relaxed = true)
val resultFlow = MutableSharedFlow<Boolean>(extraBufferCapacity = 1) val resultFlow = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow
coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right() coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right()
@ -256,7 +286,6 @@ internal class SendConfirmModelTest : SendModelTestBase() {
destinationUM = destination, destinationUM = destination,
feeSelectorUM = feeSelector, feeSelectorUM = feeSelector,
confirmUM = mockk<ConfirmUM.Content>(relaxed = true), confirmUM = mockk<ConfirmUM.Content>(relaxed = true),
navigationUM = NavigationUM.Empty,
confirmData = null, confirmData = null,
) )
} }

View file

@ -0,0 +1,198 @@
package com.tangem.features.send.send.model
import arrow.core.right
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.features.send.api.SendComponent
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource
import com.tangem.features.send.common.CommonSendRoute
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.emptyFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.CloseButtonClicked as CloseButtonClickedEvent
/**
* Guards the navigation refactor that moved the footer/app-bar actions out of the model into
* `DefaultSendComponent`. The model's [SendModel.onBackClick] / [SendModel.onNextClick] no longer read
* an internal `currentRoute` StateFlow they receive the active [com.tangem.core.decompose.navigation.Route]
* as a parameter and decide routing/analytics from it. These are pure, synchronous decisions, so each
* method is asserted *before* advancing the scheduler; the model is then destroyed inside the test body
* so its `init {}` collectors (all `modelScope.launch`/`launchIn`, including an infinite status collector)
* are cancelled never run before `runTest`'s terminal advance.
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class SendModelNavigationTest {
private val router: Router = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true)
private val cryptoCurrency = MockCryptoCurrencyFactory().createCoin(Blockchain.Ethereum)
// region onNextClick
@Test
fun `GIVEN manual entry on amount WHEN onNextClick THEN pushes destination`() = runTest {
val model = createModel(this)
model.onNextClick(CommonSendRoute.Amount(isEditMode = false))
verify(exactly = 1) { router.push(CommonSendRoute.Destination(isEditMode = false)) }
verify(exactly = 0) { router.push(CommonSendRoute.Confirm) }
model.onDestroy()
}
@Test
fun `GIVEN main-screen QR predefined values WHEN onNextClick on amount THEN skips destination and pushes confirm`() =
runTest {
// Arrange — address-only predefined values resolve to a MAIN_SCREEN QrCode (isFromMainScreenQr = true).
val model = createModel(this, params = qrParams())
// Act
model.onNextClick(CommonSendRoute.Amount(isEditMode = false))
// Assert
verify(exactly = 1) { router.push(CommonSendRoute.Confirm) }
verify(exactly = 0) { router.push(CommonSendRoute.Destination(isEditMode = false)) }
model.onDestroy()
}
@Test
fun `GIVEN destination step WHEN onNextClick THEN pushes confirm`() = runTest {
val model = createModel(this)
model.onNextClick(CommonSendRoute.Destination(isEditMode = false))
verify(exactly = 1) { router.push(CommonSendRoute.Confirm) }
model.onDestroy()
}
@Test
fun `GIVEN edit-mode route WHEN onNextClick THEN pops instead of advancing`() = runTest {
val model = createModel(this)
model.onNextClick(CommonSendRoute.Amount(isEditMode = true))
verify(exactly = 1) { router.pop() }
verify(exactly = 0) { router.push(any()) }
model.onDestroy()
}
@Test
fun `GIVEN confirm-success route WHEN onNextClick THEN pops`() = runTest {
val model = createModel(this)
model.onNextClick(CommonSendRoute.ConfirmSuccess)
verify(exactly = 1) { router.pop() }
verify(exactly = 0) { router.push(any()) }
model.onDestroy()
}
// endregion
// region onBackClick
@Test
fun `GIVEN amount step not in edit mode WHEN onBackClick THEN sends amount close analytics and pops`() = runTest {
val model = createModel(this)
model.onBackClick(CommonSendRoute.Amount(isEditMode = false))
verify(exactly = 1) {
analyticsEventHandler.send(match { it is CloseButtonClickedEvent && it.source == SendScreenSource.Amount })
}
verify(exactly = 1) { router.pop() }
model.onDestroy()
}
@Test
fun `GIVEN destination step not in edit mode WHEN onBackClick THEN sends address close analytics and pops`() =
runTest {
val model = createModel(this)
model.onBackClick(CommonSendRoute.Destination(isEditMode = false))
verify(exactly = 1) {
analyticsEventHandler.send(
match { it is CloseButtonClickedEvent && it.source == SendScreenSource.Address },
)
}
verify(exactly = 1) { router.pop() }
model.onDestroy()
}
@Test
fun `GIVEN edit-mode route WHEN onBackClick THEN pops without close analytics`() = runTest {
val model = createModel(this)
model.onBackClick(CommonSendRoute.Amount(isEditMode = true))
verify(exactly = 0) { analyticsEventHandler.send(any<CloseButtonClickedEvent>()) }
verify(exactly = 1) { router.pop() }
model.onDestroy()
}
// endregion
private fun manualParams() = SendComponent.Params(
userWalletId = UserWalletId(stringValue = "0123456789"),
currency = cryptoCurrency,
)
private fun qrParams() = SendComponent.Params(
userWalletId = UserWalletId(stringValue = "0123456789"),
currency = cryptoCurrency,
destinationAddress = "0xRECIPIENT",
)
private fun createModel(testScope: TestScope, params: SendComponent.Params = manualParams()): SendModel {
// Runs synchronously in init {}; a relaxed Either<Exception, …> would break getOrElse, so stub a Right.
every { listenToQrScanningUseCase(any()) } returns emptyFlow<String>().right()
return SendModel(
paramsContainer = MutableParamsContainer(value = params),
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
router = router,
getUserWalletUseCase = mockk(relaxed = true),
getFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true),
getSelectedAppCurrencyUseCase = mockk(relaxed = true),
listenToQrScanningUseCase = listenToQrScanningUseCase,
parseQrCodeUseCase = mockk(relaxed = true),
sendConfirmAlertFactory = mockk(relaxed = true),
saveBlockchainErrorUseCase = mockk(relaxed = true),
getWalletMetaInfoUseCase = mockk(relaxed = true),
sendFeedbackEmailUseCase = mockk(relaxed = true),
getBalanceHidingSettingsUseCase = mockk(relaxed = true),
createTransferTransactionUseCase = mockk(relaxed = true),
getFeeUseCase = mockk(relaxed = true),
getFeeForGaslessUseCase = mockk(relaxed = true),
getFeeForTokenUseCase = mockk(relaxed = true),
getAccountCurrencyStatusUseCase = mockk(relaxed = true),
isAccountsModeEnabledUseCase = mockk(relaxed = true),
sendAmountUpdateTrigger = mockk(relaxed = true),
analyticsEventHandler = analyticsEventHandler,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -11,7 +11,6 @@ import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.features.send.send.SendModelTestBase import com.tangem.features.send.send.SendModelTestBase
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.mockk import io.mockk.mockk
@ -33,7 +32,6 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN amount route AND predefined main screen QR WHEN onNextClick THEN push Confirm`() = runTest { fun `GIVEN amount route AND predefined main screen QR WHEN onNextClick THEN push Confirm`() = runTest {
// Arrange // Arrange
val model = createSendModel(this) val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
model.predefinedValues = PredefinedValues.Content.QrCode( model.predefinedValues = PredefinedValues.Content.QrCode(
amount = "1.0", amount = "1.0",
address = "addr123", address = "addr123",
@ -42,7 +40,7 @@ internal class SendModelTest : SendModelTestBase() {
) )
// Act // Act
model.onNextClick() model.onNextClick(CommonSendRoute.Amount(isEditMode = false))
// Assert // Assert
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
@ -52,11 +50,10 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN amount route AND NOT main screen QR WHEN onNextClick THEN push Destination`() = runTest { fun `GIVEN amount route AND NOT main screen QR WHEN onNextClick THEN push Destination`() = runTest {
// Arrange // Arrange
val model = createSendModel(this) val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
model.predefinedValues = PredefinedValues.Empty model.predefinedValues = PredefinedValues.Empty
// Act // Act
model.onNextClick() model.onNextClick(CommonSendRoute.Amount(isEditMode = false))
// Assert // Assert
verify(exactly = 1) { router.push(CommonSendRoute.Destination(isEditMode = false), any()) } verify(exactly = 1) { router.push(CommonSendRoute.Destination(isEditMode = false), any()) }
@ -66,10 +63,9 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN destination route WHEN onNextClick THEN push Confirm`() = runTest { fun `GIVEN destination route WHEN onNextClick THEN push Confirm`() = runTest {
// Arrange // Arrange
val model = createSendModel(this) val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Destination(isEditMode = false)
// Act // Act
model.onNextClick() model.onNextClick(CommonSendRoute.Destination(isEditMode = false))
// Assert // Assert
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
@ -79,10 +75,9 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN route in edit mode WHEN onNextClick THEN pop without push`() = runTest { fun `GIVEN route in edit mode WHEN onNextClick THEN pop without push`() = runTest {
// Arrange // Arrange
val model = createSendModel(this) val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = true)
// Act // Act
model.onNextClick() model.onNextClick(CommonSendRoute.Amount(isEditMode = true))
// Assert // Assert
verify(exactly = 1) { router.pop(any()) } verify(exactly = 1) { router.pop(any()) }
@ -90,19 +85,18 @@ internal class SendModelTest : SendModelTestBase() {
} }
@Test @Test
fun `GIVEN confirm route WHEN onNextClick THEN pop (Confirm isEditMode is true so push branch is dead)`() = fun `GIVEN confirm route WHEN onNextClick THEN push ConfirmSuccess`() =
runTest { runTest {
// Arrange // Arrange
// CommonSendRoute.Confirm.isEditMode == true, so onNextClick short-circuits to onBackClick(). // CommonSendRoute.Confirm.isEditMode == false, so onNextClick pushes ConfirmSuccess.
val model = createSendModel(this) val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Confirm
// Act // Act
model.onNextClick() model.onNextClick(CommonSendRoute.Confirm)
// Assert // Assert
verify(exactly = 1) { router.pop(any()) } verify(exactly = 1) { router.push(CommonSendRoute.ConfirmSuccess, any()) }
verify(exactly = 0) { router.push(CommonSendRoute.ConfirmSuccess, any()) } verify(exactly = 0) { router.pop(any()) }
} }
} }
@ -210,10 +204,9 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN amount route non-edit WHEN onBackClick THEN send analytics and pop`() = runTest { fun `GIVEN amount route non-edit WHEN onBackClick THEN send analytics and pop`() = runTest {
// Arrange // Arrange
val model = createSendModel(this) val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
// Act // Act
model.onBackClick() model.onBackClick(CommonSendRoute.Amount(isEditMode = false))
// Assert // Assert
verify(exactly = 1) { analyticsEventHandler.send(any<CommonSendAnalyticEvents.CloseButtonClicked>()) } verify(exactly = 1) { analyticsEventHandler.send(any<CommonSendAnalyticEvents.CloseButtonClicked>()) }
@ -224,10 +217,9 @@ internal class SendModelTest : SendModelTestBase() {
fun `GIVEN destination route edit WHEN onBackClick THEN pop without analytics`() = runTest { fun `GIVEN destination route edit WHEN onBackClick THEN pop without analytics`() = runTest {
// Arrange // Arrange
val model = createSendModel(this) val model = createSendModel(this)
model.currentRoute.value = CommonSendRoute.Destination(isEditMode = true)
// Act // Act
model.onBackClick() model.onBackClick(CommonSendRoute.Destination(isEditMode = true))
// Assert // Assert
verify(exactly = 0) { analyticsEventHandler.send(any<CommonSendAnalyticEvents.CloseButtonClicked>()) } verify(exactly = 0) { analyticsEventHandler.send(any<CommonSendAnalyticEvents.CloseButtonClicked>()) }
@ -251,7 +243,6 @@ internal class SendModelTest : SendModelTestBase() {
assertThat(state.feeSelectorUM).isEqualTo(FeeSelectorUMRedesigned.Loading) assertThat(state.feeSelectorUM).isEqualTo(FeeSelectorUMRedesigned.Loading)
assertThat(state.confirmUM).isEqualTo(ConfirmUM.Empty) assertThat(state.confirmUM).isEqualTo(ConfirmUM.Empty)
assertThat(state.confirmData).isNull() assertThat(state.confirmData).isNull()
assertThat(state.navigationUM).isEqualTo(NavigationUM.Empty)
verify(exactly = 1) { router.popTo(CommonSendRoute.Amount(isEditMode = false), any()) } verify(exactly = 1) { router.popTo(CommonSendRoute.Amount(isEditMode = false), any()) }
} }
} }

View file

@ -6,9 +6,7 @@ import arrow.core.right
import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
import com.tangem.common.routing.AppRouter import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
@ -32,50 +30,36 @@ import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.features.nft.entity.NFTSendSuccessTrigger import com.tangem.features.nft.entity.NFTSendSuccessTrigger
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
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.DestinationUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
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.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.send.common.SendBalanceUpdater import com.tangem.features.send.common.SendBalanceUpdater
import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.SendConfirmAlertFactory
import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.state.ConfirmUM
import com.tangem.features.send.loadedStatus import com.tangem.features.send.loadedStatus
import com.tangem.features.send.testDispatcherProvider
import com.tangem.features.send.sendnft.analytics.NFTSendAnalyticHelper import com.tangem.features.send.sendnft.analytics.NFTSendAnalyticHelper
import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent
import com.tangem.features.send.sendnft.ui.state.NFTSendUM import com.tangem.features.send.sendnft.ui.state.NFTSendUM
import com.tangem.features.send.testDispatcherProvider
import com.tangem.test.core.ProvideTestModels import com.tangem.test.core.ProvideTestModels
import io.mockk.MockKAnnotations import io.mockk.*
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal import java.math.BigDecimal
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
internal class NFTSendConfirmModelTest { internal class NFTSendConfirmModelTest {
@ -166,10 +150,30 @@ internal class NFTSendConfirmModelTest {
// Assert // Assert
if (model.expectedSendInitiated) { if (model.expectedSendInitiated) {
coVerify(exactly = 1) { createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } coVerify(exactly = 1) {
createNFTTransferTransactionUseCase(
any(),
any(),
any(),
any(),
any(),
any(),
any()
)
}
coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
} else { } else {
coVerify(exactly = 0) { createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } coVerify(exactly = 0) {
createNFTTransferTransactionUseCase(
any(),
any(),
any(),
any(),
any(),
any(),
any()
)
}
coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() }
} }
} }
@ -303,7 +307,6 @@ internal class NFTSendConfirmModelTest {
account = null, account = null,
isAccountsMode = false, isAccountsMode = false,
callback = mockk(relaxed = true), callback = mockk(relaxed = true),
currentRoute = flowOf(),
isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false), isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false),
onLoadFee = { mockk<com.tangem.blockchain.common.transaction.TransactionFee>(relaxed = true).right() }, onLoadFee = { mockk<com.tangem.blockchain.common.transaction.TransactionFee>(relaxed = true).right() },
onSendTransaction = {}, onSendTransaction = {},
@ -328,7 +331,6 @@ internal class NFTSendConfirmModelTest {
destinationUM = destination, destinationUM = destination,
feeSelectorUM = feeSelector, feeSelectorUM = feeSelector,
confirmUM = mockk<ConfirmUM.Content>(relaxed = true), confirmUM = mockk<ConfirmUM.Content>(relaxed = true),
navigationUM = NavigationUM.Empty,
) )
} }

View file

@ -0,0 +1,137 @@
package com.tangem.features.send.sendnft.model
import arrow.core.left
import arrow.core.right
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.wallets.models.errors.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.nft.entity.NFTSendSuccessTrigger
import com.tangem.features.send.api.NFTSendComponent
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
/**
* Guards the NFT-send navigation refactor: [NFTSendModel.onBackClick] / [NFTSendModel.onNextClick] now
* receive the active [com.tangem.core.decompose.navigation.Route] as a parameter (instead of reading an
* internal `currentRouteFlow`). Routing is synchronous, so those assertions run without advancing; the
* one async effect firing `NFTSendSuccessTrigger.triggerSuccessNFTSend()` when leaving the success
* screen is verified after `advanceUntilIdle()`.
*
* `init {}` collectors are kept harmless under `advanceUntilIdle()` by stubbing the wallet lookup to the
* not-found branch (so no further currency/account fetching is reached) and the app-currency lookup.
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class NFTSendModelNavigationTest {
private val router: Router = mockk(relaxed = true)
private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true)
private val nftSendSuccessTrigger: NFTSendSuccessTrigger = mockk(relaxed = true)
private var model: NFTSendModel? = null
@AfterEach
fun tearDown() {
model?.onDestroy()
model = null
}
@Test
fun `GIVEN destination step WHEN onNextClick THEN pushes confirm`() = runTest {
val model = createModel(this)
model.onNextClick(CommonSendRoute.Destination(isEditMode = false))
verify(exactly = 1) { router.push(CommonSendRoute.Confirm) }
}
@Test
fun `GIVEN edit-mode destination WHEN onNextClick THEN pops instead of advancing`() = runTest {
val model = createModel(this)
model.onNextClick(CommonSendRoute.Destination(isEditMode = true))
verify(exactly = 1) { router.pop() }
verify(exactly = 0) { router.push(any()) }
}
@Test
fun `GIVEN destination step WHEN onBackClick THEN pops without firing success trigger`() = runTest {
val model = createModel(this)
model.onBackClick(CommonSendRoute.Destination(isEditMode = false))
advanceUntilIdle()
verify(exactly = 1) { router.pop() }
coVerify(exactly = 0) { nftSendSuccessTrigger.triggerSuccessNFTSend() }
}
@Test
fun `GIVEN confirm-success route WHEN onBackClick THEN fires success trigger and pops`() = runTest {
val model = createModel(this)
model.onBackClick(CommonSendRoute.ConfirmSuccess)
advanceUntilIdle()
coVerify(exactly = 1) { nftSendSuccessTrigger.triggerSuccessNFTSend() }
verify(exactly = 1) { router.pop() }
}
private fun createModel(testScope: TestScope): NFTSendModel {
every { getUserWalletUseCase(any()) } returns GetUserWalletError.UserWalletNotFound.left()
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
val params = NFTSendComponent.Params(
userWalletId = UserWalletId(stringValue = "0123456789"),
nftAsset = mockk<NFTAsset>(relaxed = true),
nftCollectionName = "Test Collection",
)
return NFTSendModel(
paramsContainer = MutableParamsContainer(value = params),
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
router = router,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
getUserWalletUseCase = getUserWalletUseCase,
multiWalletCryptoCurrenciesSupplier = mockk(relaxed = true),
getFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true),
createNFTTransferTransactionUseCase = mockk(relaxed = true),
getFeeUseCase = mockk(relaxed = true),
saveBlockchainErrorUseCase = mockk(relaxed = true),
getWalletMetaInfoUseCase = mockk(relaxed = true),
sendFeedbackEmailUseCase = mockk(relaxed = true),
alertFactory = mockk(relaxed = true),
nftSendSuccessTrigger = nftSendSuccessTrigger,
isAccountsModeEnabledUseCase = mockk(relaxed = true),
getAccountCurrencyStatusUseCase = mockk(relaxed = true),
analyticsEventHandler = mockk(relaxed = true),
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -102,31 +102,41 @@ internal class NFTSendModelTest {
// Arrange // Arrange
val sut = buildModel() val sut = buildModel()
advanceUntilIdle() advanceUntilIdle()
sut.currentRouteFlow.value = model.route
// Act // Act
sut.onNextClick() sut.onNextClick(model.route)
advanceUntilIdle() advanceUntilIdle()
// Assert // Assert
if (model.expectPushConfirm) { when (model.expectedAction) {
verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } NextClickAction.PushConfirm -> {
verify(exactly = 0) { router.pop(any()) } verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) }
} else { verify(exactly = 0) { router.pop(any()) }
verify(exactly = 1) { router.pop(any()) } verify(exactly = 0) { router.replaceAll(*anyVararg(), onComplete = any()) }
verify(exactly = 0) { router.push(any(), any()) } }
// Confirm.isEditMode == true, so the `Confirm -> replaceAll(ConfirmSuccess)` branch is unreachable NextClickAction.ReplaceAllConfirmSuccess -> {
verify(exactly = 0) { router.replaceAll(CommonSendRoute.ConfirmSuccess, onComplete = any()) } // Confirm route → replaceAll(ConfirmSuccess)
verify(exactly = 1) { router.replaceAll(CommonSendRoute.ConfirmSuccess, onComplete = any()) }
verify(exactly = 0) { router.pop(any()) }
verify(exactly = 0) { router.push(any(), any()) }
}
NextClickAction.Pop -> {
verify(exactly = 1) { router.pop(any()) }
verify(exactly = 0) { router.push(any(), any()) }
verify(exactly = 0) { router.replaceAll(*anyVararg(), onComplete = any()) }
}
} }
} }
private fun provideTestModels() = listOf( private fun provideTestModels() = listOf(
NextClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectPushConfirm = true), NextClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectedAction = NextClickAction.PushConfirm),
NextClickModel(route = CommonSendRoute.Destination(isEditMode = true), expectPushConfirm = false), NextClickModel(route = CommonSendRoute.Destination(isEditMode = true), expectedAction = NextClickAction.Pop),
NextClickModel(route = CommonSendRoute.Confirm, expectPushConfirm = false), NextClickModel(route = CommonSendRoute.Confirm, expectedAction = NextClickAction.ReplaceAllConfirmSuccess),
) )
} }
enum class NextClickAction { PushConfirm, ReplaceAllConfirmSuccess, Pop }
@Nested @Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class OnBackClick { inner class OnBackClick {
@ -138,10 +148,9 @@ internal class NFTSendModelTest {
// Arrange // Arrange
val sut = buildModel() val sut = buildModel()
advanceUntilIdle() advanceUntilIdle()
sut.currentRouteFlow.value = model.route
// Act // Act
sut.onBackClick() sut.onBackClick(model.route)
advanceUntilIdle() advanceUntilIdle()
// Assert // Assert
@ -221,7 +230,7 @@ internal class NFTSendModelTest {
) )
} }
data class NextClickModel(val route: CommonSendRoute, val expectPushConfirm: Boolean) data class NextClickModel(val route: CommonSendRoute, val expectedAction: NextClickAction)
data class BackClickModel(val route: CommonSendRoute, val expectedTriggerCalls: Int) data class BackClickModel(val route: CommonSendRoute, val expectedTriggerCalls: Int)

View file

@ -1,8 +1,11 @@
package com.tangem.features.send.subcomponents.amount.model package com.tangem.features.send.subcomponents.amount.model
import arrow.core.right import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
@ -13,43 +16,28 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.loadedStatus
import com.tangem.features.send.testDispatcherProvider
import com.tangem.features.send.api.subcomponents.amount.AmountRoute
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent
import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.common.CommonSendRoute
import com.tangem.features.send.loadedStatus
import com.tangem.features.send.testDispatcherProvider
import com.tangem.test.core.ProvideTestModels import com.tangem.test.core.ProvideTestModels
import com.google.common.truth.Truth.assertThat import io.mockk.*
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.*
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal import java.math.BigDecimal
@ -79,7 +67,14 @@ internal class SendAmountModelTest {
fun setUp() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
// PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows. // PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows.
clearMocks(callback, sendAmountAlertFactory, analyticsEventHandler, answers = false, recordedCalls = true, childMocks = false) clearMocks(
callback,
sendAmountAlertFactory,
analyticsEventHandler,
answers = false,
recordedCalls = true,
childMocks = false
)
every { getUserWalletUseCase.invokeFlow(testUserWalletId) } returns flowOf(coldWallet().right()) every { getUserWalletUseCase.invokeFlow(testUserWalletId) } returns flowOf(coldWallet().right())
coEvery { getMinimumTransactionAmountSyncUseCase(any(), any()) } returns BigDecimal.ONE.right() coEvery { getMinimumTransactionAmountSyncUseCase(any(), any()) } returns BigDecimal.ONE.right()
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
@ -107,12 +102,7 @@ internal class SendAmountModelTest {
PredefinedValues.Empty PredefinedValues.Empty
} }
// Start off an Amount route so the navigation combine stays idle until the wallet is loaded. // Start off an Amount route so the navigation combine stays idle until the wallet is loaded.
val currentRoute = MutableStateFlow<CommonSendRoute>(CommonSendRoute.Confirm) val sut = buildModel(predefinedValues = predefined, route = CommonSendRoute.Amount(false))
val sut = buildModel(predefinedValues = predefined, currentRoute = currentRoute)
advanceUntilIdle()
// Act — flip to Amount so setSendWithSwapAvailability() re-runs with the loaded wallet
currentRoute.value = CommonSendRoute.Amount(isEditMode = false)
advanceUntilIdle() advanceUntilIdle()
// Assert // Assert
@ -138,7 +128,7 @@ internal class SendAmountModelTest {
fun `WHEN onConvertToAnotherToken THEN reset-alert in edit mode else convert directly`(model: ConvertModel) = fun `WHEN onConvertToAnotherToken THEN reset-alert in edit mode else convert directly`(model: ConvertModel) =
runTest { runTest {
// Arrange // Arrange
val sut = buildModel(currentRoute = MutableStateFlow(CommonSendRoute.Amount(isEditMode = model.isEditMode))) val sut = buildModel(route = CommonSendRoute.Amount(isEditMode = model.isEditMode))
advanceUntilIdle() advanceUntilIdle()
// Act // Act
@ -248,7 +238,10 @@ internal class SendAmountModelTest {
} }
private fun provideTestModels() = listOf( private fun provideTestModels() = listOf(
AmountNextModel(isFiat = true, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.AppCurrency), AmountNextModel(
isFiat = true,
expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.AppCurrency
),
AmountNextModel(isFiat = false, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.Token), AmountNextModel(isFiat = false, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.Token),
) )
} }
@ -257,7 +250,7 @@ internal class SendAmountModelTest {
private fun TestScope.buildModel( private fun TestScope.buildModel(
predefinedValues: PredefinedValues = PredefinedValues.Empty, predefinedValues: PredefinedValues = PredefinedValues.Empty,
currentRoute: MutableStateFlow<CommonSendRoute> = MutableStateFlow(CommonSendRoute.Amount(isEditMode = false)), route: CommonSendRoute.Amount = CommonSendRoute.Amount(isEditMode = false),
cryptoCurrencyStatusFlow: MutableStateFlow<CryptoCurrencyStatus> = cryptoCurrencyStatusFlow: MutableStateFlow<CryptoCurrencyStatus> =
MutableStateFlow(loadedStatus(cryptoCurrency, balance = BigDecimal.TEN)), MutableStateFlow(loadedStatus(cryptoCurrency, balance = BigDecimal.TEN)),
state: AmountState = AmountState.Empty, state: AmountState = AmountState.Empty,
@ -275,7 +268,7 @@ internal class SendAmountModelTest {
accountFlow = MutableStateFlow<Account?>(null), accountFlow = MutableStateFlow<Account?>(null),
isAccountModeFlow = MutableStateFlow(false), isAccountModeFlow = MutableStateFlow(false),
callback = callback, callback = callback,
currentRoute = currentRoute.filterIsInstance<AmountRoute>(), route = route,
) )
return SendAmountModel( return SendAmountModel(
paramsContainer = MutableParamsContainer(params), paramsContainer = MutableParamsContainer(params),

View file

@ -1,14 +1,11 @@
package com.tangem.features.send.subcomponents.amount.model package com.tangem.features.send.subcomponents.amount.model
import arrow.core.right import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
@ -25,13 +22,11 @@ import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentPara
import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener
import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.send.impl.R
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -46,12 +41,14 @@ import org.junit.jupiter.api.Test
import java.math.BigDecimal import java.math.BigDecimal
/** /**
* Guards the route-decoupling fix: `SendAmountModel.configAmountNavigation()` filters its route flow on * Guards the route-decoupling fix at the model level. The amount step is now hostable by foreign flows
* the public `AmountRoute` interface, not the `internal CommonSendRoute.Amount`. The test feeds a * (e.g. staking / send-with-swap) that supply their own [AmountRoute] implementation rather than the
* foreign `AmountRoute` (which is NOT a `CommonSendRoute.Amount`) and asserts the navigation result is * `internal CommonSendRoute.Amount`. The navigation chrome (back icon / Next vs Continue) moved out of
* still produced before the fix the `combine`'s `filterIsInstance<CommonSendRoute.Amount>()` dropped * the model into `DefaultSendAmountComponent`, but the model still makes a route-driven decision in
* it and `onNavigationResult` never fired, leaving an external host (e.g. staking) with a dead Next * [SendAmountModel.onConvertToAnotherToken]: it reads `params.route.isEditMode` *directly* (previously
* button. Also checks the `isEditMode` back-icon / primary-button mapping is unaffected. * it collected the route flow). This test feeds a foreign [AmountRoute] and verifies that edit-mode
* branches to the "reset sending" alert while a fresh (non-edit) route converts immediately both via
* the public interface, not `CommonSendRoute`.
*/ */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
internal class SendAmountNavigationTest { internal class SendAmountNavigationTest {
@ -79,10 +76,11 @@ internal class SendAmountNavigationTest {
sendAmountUpdateListener, sendAmountUpdateListener,
getSelectedAppCurrencyUseCase, getSelectedAppCurrencyUseCase,
getUserWalletUseCase, getUserWalletUseCase,
sendAmountAlertFactory,
callback, callback,
) )
// No wallet → the model stays on AmountState.Empty (the heavy AmountStateConverter path is skipped), // No wallet → the model stays on AmountState.Empty (the heavy AmountStateConverter path is skipped),
// which is all the navigation block needs to emit. // which is all these route-driven assertions need.
every { getUserWalletUseCase.invokeFlow(any()) } returns emptyFlow() every { getUserWalletUseCase.invokeFlow(any()) } returns emptyFlow()
every { sendAmountReduceListener.reduceToTriggerFlow } returns emptyFlow() every { sendAmountReduceListener.reduceToTriggerFlow } returns emptyFlow()
every { sendAmountReduceListener.reduceByTriggerFlow } returns emptyFlow() every { sendAmountReduceListener.reduceByTriggerFlow } returns emptyFlow()
@ -94,43 +92,41 @@ internal class SendAmountNavigationTest {
@AfterEach @AfterEach
fun tearDown() { fun tearDown() {
// Cancel modelScope so the long-lived navigation/status collectors stop between tests. // Cancel modelScope so the long-lived reduce/status collectors stop between tests.
model?.onDestroy() model?.onDestroy()
model = null model = null
} }
@Test @Test
fun `GIVEN a foreign AmountRoute WHEN model created THEN navigation produced with close icon and next button`() = fun `GIVEN foreign AmountRoute in edit mode WHEN onConvertToAnotherToken THEN reset sending alert shown`() =
runTest {
// Arrange — a route that is NOT CommonSendRoute.Amount (the impl type the model used to filter on).
val navSlot = slot<NavigationUM>()
// Act
createModel(testScope = this, route = TestAmountRoute(isEditMode = false))
advanceUntilIdle()
// Assert — before the fix this never fired for a non-CommonSendRoute.Amount route.
verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) }
val content = navSlot.captured as NavigationUM.Content
assertThat(content.backIconRes).isEqualTo(R.drawable.ic_close_24)
assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_next))
}
@Test
fun `GIVEN a foreign AmountRoute in edit mode WHEN model created THEN navigation has back icon and continue button`() =
runTest { runTest {
// Arrange // Arrange
val navSlot = slot<NavigationUM>()
// Act
createModel(testScope = this, route = TestAmountRoute(isEditMode = true)) createModel(testScope = this, route = TestAmountRoute(isEditMode = true))
advanceUntilIdle() advanceUntilIdle()
// Assert // Act
verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) } model?.onConvertToAnotherToken()
val content = navSlot.captured as NavigationUM.Content advanceUntilIdle()
assertThat(content.backIconRes).isEqualTo(R.drawable.ic_back_24)
assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_continue)) // Assert — edit mode must guard the conversion behind the reset-sending confirmation.
verify(exactly = 1) { sendAmountAlertFactory.showResetSendingAlert(any()) }
verify(exactly = 0) { callback.onConvertToAnotherToken(any(), any()) }
}
@Test
fun `GIVEN foreign AmountRoute not in edit mode WHEN onConvertToAnotherToken THEN converts without alert`() =
runTest {
// Arrange
createModel(testScope = this, route = TestAmountRoute(isEditMode = false))
advanceUntilIdle()
// Act
model?.onConvertToAnotherToken()
advanceUntilIdle()
// Assert — a fresh route converts immediately, with no reset-sending alert.
verify(exactly = 0) { sendAmountAlertFactory.showResetSendingAlert(any()) }
verify(exactly = 1) { callback.onConvertToAnotherToken(any(), any()) }
} }
private fun createModel(testScope: TestScope, route: AmountRoute): SendAmountModel { private fun createModel(testScope: TestScope, route: AmountRoute): SendAmountModel {
@ -147,7 +143,7 @@ internal class SendAmountNavigationTest {
accountFlow = MutableStateFlow<Account?>(null), accountFlow = MutableStateFlow<Account?>(null),
isAccountModeFlow = MutableStateFlow(false), isAccountModeFlow = MutableStateFlow(false),
callback = callback, callback = callback,
currentRoute = MutableStateFlow(route), route = route,
) )
return SendAmountModel( return SendAmountModel(
paramsContainer = MutableParamsContainer(value = params), paramsContainer = MutableParamsContainer(value = params),

View file

@ -2,6 +2,8 @@ package com.tangem.features.send.subcomponents.destination.model
import arrow.core.left import arrow.core.left
import arrow.core.right import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.navigation.Router
@ -9,6 +11,8 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.addressbook.model.*
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.CryptoCurrencyAddress import com.tangem.domain.models.network.CryptoCurrencyAddress
@ -21,25 +25,16 @@ import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.error.AddressValidation import com.tangem.domain.transaction.error.AddressValidation
import com.tangem.domain.transaction.error.AddressValidationResult import com.tangem.domain.transaction.error.AddressValidationResult
import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.send.api.entity.PredefinedValues
import kotlinx.collections.immutable.toImmutableList
import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase
import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.ContactSelectionListener import com.tangem.features.addressbook.ContactSelectionListener
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues
import com.tangem.features.send.api.subcomponents.destination.DestinationRoute import com.tangem.features.send.api.subcomponents.destination.DestinationRoute
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams
@ -49,13 +44,14 @@ import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFa
import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource
import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents
import com.tangem.features.send.testDispatcherProvider import com.tangem.features.send.testDispatcherProvider
import io.mockk.MockKAnnotations import com.tangem.test.core.ProvideTestModels
import io.mockk.clearMocks import io.mockk.*
import io.mockk.coEvery import androidx.compose.foundation.text.KeyboardOptions
import io.mockk.coVerify import androidx.compose.ui.text.input.ImeAction
import io.mockk.every import androidx.compose.ui.text.input.KeyboardType
import io.mockk.mockk import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM
import io.mockk.verify import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -64,7 +60,6 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -127,7 +122,15 @@ internal class SendDestinationModelTest {
fun `GIVEN valid non-problematic address WHEN address entered THEN send valid analytics without backup alert`() = fun `GIVEN valid non-problematic address WHEN address entered THEN send valid analytics without backup alert`() =
runTest { runTest {
// Arrange // Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns coEvery {
validateWalletAddressUseCase(
any(),
any(),
any(),
any<List<CryptoCurrencyAddress>>(),
any()
)
} returns
AddressValidation.Success.Valid.right() AddressValidation.Success.Valid.right()
val sut = buildModel() val sut = buildModel()
advanceUntilIdle() advanceUntilIdle()
@ -144,14 +147,22 @@ internal class SendDestinationModelTest {
} }
verify(exactly = 0) { sendDestinationAlertFactory.showRecipientBackupErrorAlert(any()) } verify(exactly = 0) { sendDestinationAlertFactory.showRecipientBackupErrorAlert(any()) }
// InputField is not an auto-next source → no auto-advance even for a valid address // InputField is not an auto-next source → no auto-advance even for a valid address
verify(exactly = 0) { callback.onNextClick() } verify(exactly = 0) { callback.onNextClick(CommonSendRoute.Destination(false)) }
} }
@Test @Test
fun `GIVEN valid backup-problematic address WHEN address entered THEN show recipient backup error alert`() = fun `GIVEN valid backup-problematic address WHEN address entered THEN show recipient backup error alert`() =
runTest { runTest {
// Arrange // Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns coEvery {
validateWalletAddressUseCase(
any(),
any(),
any(),
any<List<CryptoCurrencyAddress>>(),
any()
)
} returns
AddressValidation.Success.Valid.right() AddressValidation.Success.Valid.right()
coEvery { getBackupProblematicWalletForAddressUseCase(any()) } returns testUserWalletId coEvery { getBackupProblematicWalletForAddressUseCase(any()) } returns testUserWalletId
val sut = buildModel() val sut = buildModel()
@ -172,7 +183,15 @@ internal class SendDestinationModelTest {
@Test @Test
fun `GIVEN invalid address WHEN address entered THEN send invalid analytics`() = runTest { fun `GIVEN invalid address WHEN address entered THEN send invalid analytics`() = runTest {
// Arrange // Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns coEvery {
validateWalletAddressUseCase(
any(),
any(),
any(),
any<List<CryptoCurrencyAddress>>(),
any()
)
} returns
AddressValidation.Error.InvalidAddress.left() AddressValidation.Error.InvalidAddress.left()
val sut = buildModel() val sut = buildModel()
advanceUntilIdle() advanceUntilIdle()
@ -193,7 +212,15 @@ internal class SendDestinationModelTest {
fun `GIVEN memo change with null type WHEN handled THEN no address-entered analytics and no auto-next`() = fun `GIVEN memo change with null type WHEN handled THEN no address-entered analytics and no auto-next`() =
runTest { runTest {
// Arrange // Arrange
coEvery { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } returns coEvery {
validateWalletAddressUseCase(
any(),
any(),
any(),
any<List<CryptoCurrencyAddress>>(),
any()
)
} returns
AddressValidation.Success.Valid.right() AddressValidation.Success.Valid.right()
val sut = buildModel() val sut = buildModel()
advanceUntilIdle() advanceUntilIdle()
@ -206,7 +233,7 @@ internal class SendDestinationModelTest {
verify(exactly = 0) { verify(exactly = 0) {
analyticsEventHandler.send(any<SendDestinationAnalyticEvents.AddressEntered>()) analyticsEventHandler.send(any<SendDestinationAnalyticEvents.AddressEntered>())
} }
verify(exactly = 0) { callback.onNextClick() } verify(exactly = 0) { callback.onNextClick(CommonSendRoute.Destination(false)) }
} }
} }
@ -231,7 +258,7 @@ internal class SendDestinationModelTest {
advanceUntilIdle() advanceUntilIdle()
// Assert // Assert
verify(exactly = model.expectedNextClicks) { callback.onNextClick() } verify(exactly = model.expectedNextClicks) { callback.onNextClick(CommonSendRoute.Destination(false)) }
} }
private fun provideTestModels() = listOf( private fun provideTestModels() = listOf(
@ -256,7 +283,15 @@ internal class SendDestinationModelTest {
advanceUntilIdle() advanceUntilIdle()
// Assert // Assert
coVerify(exactly = 0) { validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) } coVerify(exactly = 0) {
validateWalletAddressUseCase(
any(),
any(),
any(),
any<List<CryptoCurrencyAddress>>(),
any()
)
}
} }
} }
@ -286,25 +321,38 @@ internal class SendDestinationModelTest {
} }
@Test @Test
fun `GIVEN a contact is set WHEN route switches to edit mode THEN the contact is reset`() = runTest { fun `GIVEN a contact is pre-set in state AND route is edit mode WHEN model initializes THEN the contact is reset`() =
// Arrange runTest {
coEvery { // Arrange — build initial state with a contact name already set and isInitialized = true
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any()) // so the InitialStateTransformer does NOT overwrite it, leaving resetContactOnEdit() to clear it.
} returns AddressValidation.Success.Valid.right() val stateWithContact = DestinationUM.Content(
val currentRoute = MutableStateFlow<DestinationRoute>(CommonSendRoute.Destination(isEditMode = false)) isPrimaryButtonEnabled = false,
val sut = buildModel(currentRoute = currentRoute) isInitialized = true,
advanceUntilIdle() addressTextField = DestinationTextFieldUM.RecipientAddress(
sut.applySelectedContact(selectedContact(name = "Dave", address = "0xDave")) value = "0xDave",
advanceUntilIdle() keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Text),
assertThat(content(sut).addressTextField.contactName).isEqualTo("Dave") placeholder = com.tangem.core.ui.extensions.stringReference(""),
label = com.tangem.core.ui.extensions.stringReference(""),
isValuePasted = false,
contactName = "Dave",
),
memoTextField = null,
recent = persistentListOf(),
wallets = persistentListOf(),
networkName = "Ethereum",
isRecentHidden = false,
)
// Act — entering edit mode must clear the bound contact // Act — init with isEditMode = true triggers resetContactOnEdit()
currentRoute.value = CommonSendRoute.Destination(isEditMode = true) val sut = buildModel(
advanceUntilIdle() currentRoute = CommonSendRoute.Destination(isEditMode = true),
initialState = stateWithContact,
)
advanceUntilIdle()
// Assert // Assert
assertThat(content(sut).addressTextField.contactName).isNull() assertThat(content(sut).addressTextField.contactName).isNull()
} }
} }
@Nested @Nested
@ -335,9 +383,19 @@ internal class SendDestinationModelTest {
private fun provideTestModels() = listOf( private fun provideTestModels() = listOf(
// saved "0xAddr", entered "0xaddr" → case-insensitive match // saved "0xAddr", entered "0xaddr" → case-insensitive match
ContactRecognitionModel(savedName = "Alice", savedAddress = "0xAddr", enteredAddress = "0xaddr", expectedContactName = "Alice"), ContactRecognitionModel(
savedName = "Alice",
savedAddress = "0xAddr",
enteredAddress = "0xaddr",
expectedContactName = "Alice"
),
// entered address not among saved contacts → no recognition // entered address not among saved contacts → no recognition
ContactRecognitionModel(savedName = "Alice", savedAddress = "0xOther", enteredAddress = "0xAddr", expectedContactName = null), ContactRecognitionModel(
savedName = "Alice",
savedAddress = "0xOther",
enteredAddress = "0xAddr",
expectedContactName = null
),
) )
} }
@ -411,29 +469,44 @@ internal class SendDestinationModelTest {
private fun provideTestModels() = listOf( private fun provideTestModels() = listOf(
// not available -> never shown, even for a fresh valid address // not available -> never shown, even for a fresh valid address
AddContactModel(isAddContactAvailable = false, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = false), AddContactModel(
isAddContactAvailable = false,
savedAddresses = emptyList(),
enteredAddress = "0xFresh",
expectedShown = false
),
// available + address not in the book -> shown // available + address not in the book -> shown
AddContactModel(isAddContactAvailable = true, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = true), AddContactModel(
isAddContactAvailable = true,
savedAddresses = emptyList(),
enteredAddress = "0xFresh",
expectedShown = true
),
// available but address already saved -> hidden // available but address already saved -> hidden
AddContactModel(isAddContactAvailable = true, savedAddresses = listOf("0xSaved"), enteredAddress = "0xSaved", expectedShown = false), AddContactModel(
isAddContactAvailable = true,
savedAddresses = listOf("0xSaved"),
enteredAddress = "0xSaved",
expectedShown = false
),
) )
} }
// region fixtures // region fixtures
private fun TestScope.buildModel( private fun TestScope.buildModel(
currentRoute: MutableStateFlow<DestinationRoute> = currentRoute: DestinationRoute = CommonSendRoute.Destination(isEditMode = false),
MutableStateFlow(CommonSendRoute.Destination(isEditMode = false)), initialState: DestinationUM = DestinationUM.Empty(),
): SendDestinationModel { ): SendDestinationModel {
val params = SendDestinationComponentParams.DestinationParams( val params = SendDestinationComponentParams.DestinationParams(
state = DestinationUM.Empty(), state = initialState,
analyticsCategoryName = "test_send", analyticsCategoryName = "test_send",
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send,
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
userWalletId = testUserWalletId, userWalletId = testUserWalletId,
title = stringReference("Send to"), title = stringReference("Send to"),
isBalanceHidingFlow = MutableStateFlow(false), isBalanceHidingFlow = MutableStateFlow(false),
currentRoute = currentRoute, route = currentRoute,
callback = callback, callback = callback,
isAllowSelfSend = false, isAllowSelfSend = false,
) )

View file

@ -98,4 +98,6 @@ dependencies {
testImplementation(deps.test.junit5) testImplementation(deps.test.junit5)
testImplementation(deps.test.truth) testImplementation(deps.test.truth)
testImplementation(deps.test.mockk) 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 package com.tangem.features.swap.v2.impl.amount
import androidx.compose.foundation.background 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState 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.AppComponentContext
import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel 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.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.core.ui.res.TangemTheme
import com.tangem.domain.express.models.ExpressRateType 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.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.model.SwapAmountModel import com.tangem.features.swap.v2.impl.amount.model.SwapAmountModel
import com.tangem.features.swap.v2.impl.amount.ui.SwapAmountContent import com.tangem.features.swap.v2.impl.amount.ui.SwapAmountContent
@ -28,7 +36,7 @@ import dagger.assisted.AssistedInject
internal class SwapAmountComponent @AssistedInject constructor( internal class SwapAmountComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext, @Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SwapAmountComponentParams.AmountParams, @Assisted private val params: SwapAmountComponentParams.AmountParams,
) : ComposableContentComponent, AppComponentContext by appComponentContext { ) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SwapAmountModel = getOrCreateModel(params = params) private val model: SwapAmountModel = getOrCreateModel(params = params)
@ -49,6 +57,23 @@ internal class SwapAmountComponent @AssistedInject constructor(
fun updateState(amountUM: SwapAmountUM) = model.updateState(amountUM) 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 @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val amountUM by model.uiState.collectAsStateWithLifecycle() val amountUM by model.uiState.collectAsStateWithLifecycle()
@ -63,6 +88,30 @@ internal class SwapAmountComponent @AssistedInject constructor(
rateInfo.child?.instance?.BottomSheet() 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( private fun rateInfoChild(
config: ExpressRateType, config: ExpressRateType,
componentContext: ComponentContext, 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.send.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
internal sealed class SwapAmountComponentParams { internal sealed class SwapAmountComponentParams {
@ -41,7 +40,7 @@ internal sealed class SwapAmountComponentParams {
override val isAccountModeFlow: StateFlow<Boolean>, override val isAccountModeFlow: StateFlow<Boolean>,
val title: TextReference, val title: TextReference,
val callback: SwapAmountComponent.ModelCallback, val callback: SwapAmountComponent.ModelCallback,
val currentRoute: Flow<SendWithSwapRoute>, val route: SendWithSwapRoute,
) : SwapAmountComponentParams() ) : SwapAmountComponentParams()
data class AmountBlockParams( 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.converters.MaxEnterAmountConverter
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary 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.common.ui.notifications.NotificationId
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.datasource.local.swap.SwapBestRateAnimationStore import com.tangem.datasource.local.swap.SwapBestRateAnimationStore
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase 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.amount.analytics.CommonSendAmountAnalyticEvents
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkListener 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.SwapAmountBlockComponent.SwapChooseProviderConfig
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams
import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceListener 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.chooseprovider.SwapChooseProviderComponent
import com.tangem.features.swap.v2.impl.common.SwapAlertFactory 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.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
import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents.NoticeFixedRate.toAnalyticsRateType import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents.NoticeFixedRate.toAnalyticsRateType
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -125,7 +120,6 @@ internal class SwapAmountModel @Inject constructor(
private val amountAnalyticsSender = SwapAmountAnalyticsSender(analyticsEventHandler) private val amountAnalyticsSender = SwapAmountAnalyticsSender(analyticsEventHandler)
private var autoUpdateSubscriberJob: Job? = null private var autoUpdateSubscriberJob: Job? = null
private var navigationJob: Job? = null
val uiState: StateFlow<SwapAmountUM> val uiState: StateFlow<SwapAmountUM>
field = MutableStateFlow(params.amountUM) field = MutableStateFlow(params.amountUM)
@ -156,7 +150,6 @@ internal class SwapAmountModel @Inject constructor(
} else { } else {
QUOTES_UPDATE_DELAY QUOTES_UPDATE_DELAY
} }
configAmountNavigation()
quoteTaskScheduler.scheduleTask( quoteTaskScheduler.scheduleTask(
scope = modelScope, scope = modelScope,
task = loadQuotesTask(initialDelay = initialDelay), task = loadQuotesTask(initialDelay = initialDelay),
@ -167,7 +160,6 @@ internal class SwapAmountModel @Inject constructor(
fun onStop() { fun onStop() {
quoteTaskScheduler.cancelTask() quoteTaskScheduler.cancelTask()
autoUpdateSubscriberJob?.cancel() autoUpdateSubscriberJob?.cancel()
navigationJob?.cancel()
} }
override fun onDestroy() { override fun onDestroy() {
@ -325,7 +317,7 @@ internal class SwapAmountModel @Inject constructor(
val isShowSendViaSwapNotification = shouldShowNotificationUseCase( val isShowSendViaSwapNotification = shouldShowNotificationUseCase(
NotificationId.SendViaSwapTokenSelectorNotification.key, NotificationId.SendViaSwapTokenSelectorNotification.key,
) )
val isEditMode = amountParams.currentRoute.firstOrNull()?.isEditMode == true val isEditMode = amountParams.route.isEditMode
val selectedCurrency = (uiState.value as? SwapAmountUM.Content)?.secondaryCryptoCurrencyStatus?.currency val selectedCurrency = (uiState.value as? SwapAmountUM.Content)?.secondaryCryptoCurrencyStatus?.currency
appRouter.push( appRouter.push(
AppRoute.ChooseManagedTokens( AppRoute.ChooseManagedTokens(
@ -354,7 +346,7 @@ internal class SwapAmountModel @Inject constructor(
val amountParams = params as? SwapAmountComponentParams.AmountParams ?: return val amountParams = params as? SwapAmountComponentParams.AmountParams ?: return
modelScope.launch { modelScope.launch {
if (amountParams.currentRoute.firstOrNull()?.isEditMode == true) { if (amountParams.route.isEditMode) {
swapAmountAlertFactory.showCloseSendWithSwapAlert { swapAmountAlertFactory.showCloseSendWithSwapAlert {
params.callback.resetSendWithSwapNavigation(resetNavigation = true) params.callback.resetSendWithSwapNavigation(resetNavigation = true)
confirmSendWithSwapClose() 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 { private companion object {
const val DEBOUNCE_AMOUNT_DELAY = 500L const val DEBOUNCE_AMOUNT_DELAY = 500L
const val QUOTES_UPDATE_DELAY = 10000L 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.value.ObserveLifecycleMode import com.arkivanov.decompose.value.ObserveLifecycleMode
import com.arkivanov.decompose.value.subscribe import com.arkivanov.decompose.value.subscribe
import com.tangem.common.ui.navigationButtons.NavigationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.swap.models.R import com.tangem.domain.swap.models.R
import com.tangem.domain.swap.models.SwapDirection 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.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
internal class DefaultSendWithSwapComponent @AssistedInject constructor( internal class DefaultSendWithSwapComponent @AssistedInject constructor(
@ -50,7 +46,6 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
private val sendDestinationComponentFactory: SendDestinationComponent.Factory, private val sendDestinationComponentFactory: SendDestinationComponent.Factory,
private val confirmComponentFactory: SendWithSwapConfirmComponent.Factory, private val confirmComponentFactory: SendWithSwapConfirmComponent.Factory,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val urlOpener: UrlOpener,
) : SendWithSwapComponent, AppComponentContext by appComponentContext { ) : SendWithSwapComponent, AppComponentContext by appComponentContext {
private val stackNavigation = StackNavigation<SendWithSwapRoute>() private val stackNavigation = StackNavigation<SendWithSwapRoute>()
@ -99,7 +94,7 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor(
activeComponent.updateState(model.uiState.value.destinationUM) activeComponent.updateState(model.uiState.value.destinationUM)
} }
is SendWithSwapConfirmComponent -> { is SendWithSwapConfirmComponent -> {
if (model.currentRoute.value.isEditMode) { if (stack.active.configuration.isEditMode) {
activeComponent.updateState(model.uiState.value) activeComponent.updateState(model.uiState.value)
} }
// Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate ([REDACTED_TASK_KEY]). // 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 @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val stackState by childStack.subscribeAsState() val stackState by childStack.subscribeAsState()
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler( BackHandler(
onBack = { onBack = ::onChildBack,
(state.navigationUM as? NavigationUM.Content)?.backIconClick() ?: onChildBack()
},
) )
SendWithSwapContent( SendWithSwapContent(
navigationUM = state.navigationUM,
confirmUM = state.confirmUM,
stackState = stackState, stackState = stackState,
onLinkClick = urlOpener::openUrl,
) )
} }
private fun createChild(route: SendWithSwapRoute, childContext: AppComponentContext) = when (route) { private fun createChild(route: SendWithSwapRoute, childContext: AppComponentContext) = when (route) {
is SendWithSwapRoute.Amount -> getAmountComponent(factoryContext = childContext) is SendWithSwapRoute.Amount -> getAmountComponent(route, factoryContext = childContext)
is SendWithSwapRoute.Destination -> getDestinationComponent(factoryContext = childContext) is SendWithSwapRoute.Destination -> getDestinationComponent(route = route, factoryContext = childContext)
is SendWithSwapRoute.Confirm -> getConfirmComponent(factoryContext = childContext) is SendWithSwapRoute.Confirm -> getConfirmComponent(factoryContext = childContext)
is SendWithSwapRoute.Success -> getSuccessComponent(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( return SwapAmountComponent(
appComponentContext = factoryContext, appComponentContext = factoryContext,
params = SwapAmountComponentParams.AmountParams( params = SwapAmountComponentParams.AmountParams(
amountUM = model.uiState.value.amountUM, amountUM = model.uiState.value.amountUM,
title = resourceReference(R.string.common_send), title = resourceReference(R.string.common_send),
currentRoute = model.currentRoute, route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow, isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName, analyticsCategoryName = model.analyticCategoryName,
primaryCryptoCurrencyStatusFlow = model.primaryCryptoCurrencyStatusFlow, 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 val amountContentUM = model.uiState.value.amountUM as? SwapAmountUM.Content
?: return ComposableContentComponent.EMPTY ?: return ComposableModularContentComponent.EMPTY
val secondaryCryptoCurrency = amountContentUM.secondaryCryptoCurrencyStatus?.currency val secondaryCryptoCurrency = amountContentUM.secondaryCryptoCurrencyStatus?.currency
?: return ComposableContentComponent.EMPTY ?: return ComposableModularContentComponent.EMPTY
return sendDestinationComponentFactory.create( return sendDestinationComponentFactory.create(
context = factoryContext, context = factoryContext,
params = SendDestinationComponentParams.DestinationParams( params = SendDestinationComponentParams.DestinationParams(
state = model.uiState.value.destinationUM, state = model.uiState.value.destinationUM,
currentRoute = model.currentRoute.filterIsInstance<DestinationRoute>(), route = route,
isBalanceHidingFlow = model.isBalanceHiddenFlow, isBalanceHidingFlow = model.isBalanceHiddenFlow,
analyticsCategoryName = model.analyticCategoryName, analyticsCategoryName = model.analyticCategoryName,
analyticsSendSource = model.analyticsSendSource, 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( return confirmComponentFactory.create(
appComponentContext = factoryContext, appComponentContext = factoryContext,
params = SendWithSwapConfirmComponent.Params( params = SendWithSwapConfirmComponent.Params(
sendWithSwapUM = model.uiState.value, sendWithSwapUM = model.uiState.value,
currentRoute = model.currentRoute.filterIsInstance<SendWithSwapRoute.Confirm>(),
isBalanceHidingFlow = model.isBalanceHiddenFlow, isBalanceHidingFlow = model.isBalanceHiddenFlow,
appCurrency = model.appCurrency, appCurrency = model.appCurrency,
userWallet = model.userWallet, 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( return SendWithSwapSuccessComponent(
appComponentContext = factoryContext, appComponentContext = factoryContext,
params = SendWithSwapSuccessComponent.Params( params = SendWithSwapSuccessComponent.Params(
sendWithSwapUMFlow = model.uiState, sendWithSwapUMFlow = model.uiState,
currentRoute = model.currentRoute.filterIsInstance<SendWithSwapRoute.Success>(),
callback = model, callback = model,
analyticsCategoryName = model.analyticCategoryName, analyticsCategoryName = model.analyticCategoryName,
), ),

View file

@ -1,37 +1,52 @@
package com.tangem.features.swap.v2.impl.sendviaswap.confirm 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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 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.AppComponentContext
import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel 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.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapDirection 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.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.api.entity.PredefinedValues 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.SendDestinationBlockComponent
import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams 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.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.SwapAmountBlockComponent
import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams 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.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.ConfirmUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM 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.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.model.SendWithSwapConfirmModel
import com.tangem.features.swap.v2.impl.sendviaswap.confirm.ui.SendWithSwapConfirmContent import com.tangem.features.swap.v2.impl.sendviaswap.confirm.ui.SendWithSwapConfirmContent
import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.orZero
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@ -44,7 +59,8 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory, sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory,
feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory, feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory,
sendNotificationsComponentFactory: SendNotificationsComponent.Factory, sendNotificationsComponentFactory: SendNotificationsComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext { private val urlOpener: UrlOpener,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SendWithSwapConfirmModel = getOrCreateModel(params = params) 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 @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val sendWithSwapUM by model.uiState.collectAsStateWithLifecycle() 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( data class Params(
val sendWithSwapUM: SendWithSwapUM, val sendWithSwapUM: SendWithSwapUM,
val analyticsCategoryName: String, val analyticsCategoryName: String,
val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
val userWallet: UserWallet, val userWallet: UserWallet,
val appCurrency: AppCurrency, val appCurrency: AppCurrency,
val currentRoute: Flow<SendWithSwapRoute>,
val swapDirection: SwapDirection, val swapDirection: SwapDirection,
val isBalanceHidingFlow: StateFlow<Boolean>, val isBalanceHidingFlow: StateFlow<Boolean>,
val primaryCryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>, val primaryCryptoCurrencyStatusFlow: StateFlow<CryptoCurrencyStatus>,
@ -218,6 +352,6 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor(
} }
interface ModelCallback { 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.converters.AmountReduceByTransformer
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.navigationButtons.NavigationButton 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.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam 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.EstimateFeeForGaslessTxUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase 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
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource 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.destination.entity.DestinationUM
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger 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.SendNotificationsUpdateListener
import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger
import com.tangem.features.swap.v2.api.SwapFeatureToggles import com.tangem.features.swap.v2.api.SwapFeatureToggles
@ -176,7 +175,6 @@ internal class SendWithSwapConfirmModel @Inject constructor(
init { init {
updateAmountSubtractAvailability() updateAmountSubtractAvailability()
configConfirmNavigation()
initialState() initialState()
subscribeOnNotificationUpdates() subscribeOnNotificationUpdates()
subscribeOnTapHelpUpdates() subscribeOnTapHelpUpdates()
@ -383,6 +381,7 @@ internal class SendWithSwapConfirmModel @Inject constructor(
swapDataModel = data, swapDataModel = data,
), ),
) )
params.callback.onResult(uiState.value)
router.replaceAll(SendWithSwapRoute.Success) router.replaceAll(SendWithSwapRoute.Success)
}, },
expressOperationType = ExpressOperationType.SEND_WITH_SWAP, expressOperationType = ExpressOperationType.SEND_WITH_SWAP,
@ -582,49 +581,29 @@ internal class SendWithSwapConfirmModel @Inject constructor(
} }
} }
private fun configConfirmNavigation() { fun primaryButtonUM(confirmUM: ConfirmUM): NavigationButton {
combine( val isContent = confirmUM is ConfirmUM.Content
flow = uiState, val isReadyToSend = isContent && !confirmUM.isTransactionInProcess
flow2 = params.currentRoute, val isHoldToConfirm = params.userWallet.isHotWallet && isContent
transform = { state, route -> state to route },
).filter { return NavigationButton(
it.second is SendWithSwapRoute.Confirm textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm),
}.onEach { (state, _) -> iconRes = walletInterationIcon(params.userWallet),
val confirmUM = state.confirmUM isIconVisible = isReadyToSend && !isHoldToConfirm,
val isContent = confirmUM is ConfirmUM.Content isHapticClick = isReadyToSend,
val isReadyToSend = isContent && !confirmUM.isTransactionInProcess isHoldToConfirm = isHoldToConfirm,
val isHoldToConfirm = params.userWallet.isHotWallet && isContent isEnabled = confirmUM.isPrimaryButtonEnabled,
params.callback.onResult( onClick = {
route = SendWithSwapRoute.Confirm, when (confirmUM) {
sendWithSwapUM = state.copy( is ConfirmUM.Content -> if (confirmUM.isTransactionInProcess) {
navigationUM = NavigationUM.Content( return@NavigationButton
source = SendWithSwapRoute.Confirm.javaClass.simpleName, } else {
title = resourceReference(id = R.string.send_with_swap_confirm_title), onSendClick()
subtitle = null, }
backIconRes = R.drawable.ic_back_24, else -> return@NavigationButton
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)
} }
private fun getPrimaryButtonText(confirmUM: ConfirmUM, isHoldToConfirm: Boolean): TextReference { private fun getPrimaryButtonText(confirmUM: ConfirmUM, isHoldToConfirm: Boolean): TextReference {

View file

@ -1,8 +1,7 @@
package com.tangem.features.swap.v2.impl.sendviaswap.entity 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.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.amount.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM
@ -11,5 +10,4 @@ internal data class SendWithSwapUM(
val destinationUM: DestinationUM, val destinationUM: DestinationUM,
val feeSelectorUM: FeeSelectorUM, val feeSelectorUM: FeeSelectorUM,
val confirmUM: ConfirmUM, val confirmUM: ConfirmUM,
val navigationUM: NavigationUM,
) )

View file

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

View file

@ -1,37 +1,91 @@
package com.tangem.features.swap.v2.impl.sendviaswap.success 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.common.ui.navigationButtons.NavigationModelCallback
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.components.PrimaryButton
import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute 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.entity.SendWithSwapUM
import com.tangem.features.swap.v2.impl.sendviaswap.success.model.SendWithSwapSuccessModel import com.tangem.features.swap.v2.impl.sendviaswap.success.model.SendWithSwapSuccessModel
import com.tangem.features.swap.v2.impl.sendviaswap.success.ui.SendWithSwapSuccessContent import com.tangem.features.swap.v2.impl.sendviaswap.success.ui.SendWithSwapSuccessContent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
internal class SendWithSwapSuccessComponent( internal class SendWithSwapSuccessComponent(
appComponentContext: AppComponentContext, appComponentContext: AppComponentContext,
params: Params, private val params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext { ) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: SendWithSwapSuccessModel = getOrCreateModel(params = params) 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 @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle() val state by model.uiState.collectAsStateWithLifecycle()
SendWithSwapSuccessContent(sendWithSwapUM = state) 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( data class Params(
val sendWithSwapUMFlow: StateFlow<SendWithSwapUM>, val sendWithSwapUMFlow: StateFlow<SendWithSwapUM>,
val analyticsCategoryName: String, val analyticsCategoryName: String,
val currentRoute: Flow<SendWithSwapRoute.Success>,
val callback: NavigationModelCallback, val callback: NavigationModelCallback,
) )
} }

View file

@ -1,18 +1,11 @@
package com.tangem.features.swap.v2.impl.sendviaswap.success.model 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.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener 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.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.entity.SendWithSwapUM
import com.tangem.features.swap.v2.impl.sendviaswap.success.SendWithSwapSuccessComponent import com.tangem.features.swap.v2.impl.sendviaswap.success.SendWithSwapSuccessComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -24,7 +17,6 @@ internal class SendWithSwapSuccessModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener, private val urlOpener: UrlOpener,
private val shareManager: ShareManager, private val shareManager: ShareManager,
private val appRouter: AppRouter,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
) : Model() { ) : Model() {
@ -34,47 +26,14 @@ internal class SendWithSwapSuccessModel @Inject constructor(
val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success
init { fun onExploreClick() {
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() {
if (confirmUM == null) return if (confirmUM == null) return
// analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName)) // analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName))
urlOpener.openUrl(confirmUM.txUrl) urlOpener.openUrl(confirmUM.txUrl)
} }
private fun onShareClick() { fun onShareClick() {
if (confirmUM == null) return if (confirmUM == null) return
// analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName)) // 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.account.AccountTitleUM
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.utils.getFiatReference 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.Fade
import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.icons.identicon.IdentIcon 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.SwapDataModel
import com.tangem.domain.swap.models.SwapDataTransactionModel import com.tangem.domain.swap.models.SwapDataTransactionModel
import com.tangem.domain.utils.convertToSdkAmount 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.FeeExtraInfo
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem 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.FeeNonce
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM 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.R
import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM 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.entity.SwapAmountUM
import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview 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.ConfirmUM
import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM 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 com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal import java.math.BigDecimal
@Composable @Composable
internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) { internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) {
if (sendWithSwapUM.navigationUM !is NavigationUM.Content) return
Column { Column {
Box( Box(
modifier = Modifier modifier = Modifier
@ -82,14 +76,6 @@ internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) {
backgroundColor = TangemTheme.colors.background.tertiary, 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, 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 package com.tangem.features.swap.v2.impl.sendviaswap.ui
import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.fade 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.plus
import com.arkivanov.decompose.extensions.compose.stack.animation.slide import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.router.stack.ChildStack 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.Fade
import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.* import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme 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.features.swap.v2.impl.sendviaswap.SendWithSwapRoute
import com.tangem.utils.StringsSigns
@Composable @Composable
internal fun SendWithSwapContent( internal fun SendWithSwapContent(stackState: ChildStack<SendWithSwapRoute, ComposableModularContentComponent>) {
navigationUM: NavigationUM,
confirmUM: ConfirmUM,
stackState: ChildStack<SendWithSwapRoute, ComposableContentComponent>,
onLinkClick: (String) -> Unit,
) {
val navigationUMContent = navigationUM as? NavigationUM.Content ?: return
Column( Column(
modifier = Modifier modifier = Modifier
.background(color = TangemTheme.colors.background.tertiary) .background(color = TangemTheme.colors.background.tertiary)
@ -48,12 +28,7 @@ internal fun SendWithSwapContent(
.systemBarsPadding(), .systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
AppBarWithBackButton( stackState.active.instance.Title()
text = navigationUMContent.title.resolveReference(),
onBackClick = navigationUMContent.backIconClick,
iconRes = navigationUMContent.additionalIconRes,
modifier = Modifier.height(TangemTheme.dimens.size56),
)
Children( Children(
stack = stackState, stack = stackState,
animation = stackAnimation { child -> animation = stackAnimation { child ->
@ -76,125 +51,6 @@ internal fun SendWithSwapContent(
} }
} }
} }
if (stackState.active.configuration != SendWithSwapRoute.Success) { stackState.active.instance.Footer()
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,
)
},
)
}
} }
} }

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,
)
}
}

View file

@ -406,6 +406,24 @@ internal data class TangemMessageBannerStory(
} }
} }
internal data class TextStyleStory(
val style: Style,
val textScale: Float,
val onStyleChange: (Style) -> Unit,
val onTextScaleChange: (Float) -> Unit,
) : DsStoryBookPage {
/** DS3 typography3 text styles available in the preview. Labels match the Figma naming. */
enum class Style(val label: String) {
Display("Display"),
HeadM("Head.M"),
HeadS("Head.S"),
Body("Body"),
SubH("Sub.H"),
Caption("Caption"),
}
}
internal data class TangemBadgeV2Story( internal data class TangemBadgeV2Story(
val variant: TangemBadge.Variant, val variant: TangemBadge.Variant,
val status: TangemBadge.Status, val status: TangemBadge.Status,

View file

@ -26,6 +26,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner.ta
import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSearchStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSearchStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.textstyle.textStyleStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.tangemTopNavigationStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.tangemTopNavigationStoryFactory
private data class DsStoryItem(val title: String, val factory: StoryPageFactory) private data class DsStoryItem(val title: String, val factory: StoryPageFactory)
@ -43,6 +44,7 @@ private fun buildDsStories() = listOf(
DsStoryItem(title = "🧭 TangemTopNavigation", factory = tangemTopNavigationStoryFactory), DsStoryItem(title = "🧭 TangemTopNavigation", factory = tangemTopNavigationStoryFactory),
DsStoryItem(title = "💫 TangemGlowRing", factory = tangemGlowRingStoryFactory), DsStoryItem(title = "💫 TangemGlowRing", factory = tangemGlowRingStoryFactory),
DsStoryItem(title = "📢 TangemMessageBanner", factory = tangemMessageBannerStoryFactory), DsStoryItem(title = "📢 TangemMessageBanner", factory = tangemMessageBannerStoryFactory),
DsStoryItem(title = "🔤 Text styles", factory = textStyleStoryFactory),
) )
@Composable @Composable

View file

@ -0,0 +1,21 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.textstyle
import com.tangem.feature.tester.presentation.storybook.entity.TextStyleStory
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TextStyleStory>.build(): TextStyleStory {
return TextStyleStory(
style = TextStyleStory.Style.HeadM,
textScale = 1f,
onStyleChange = { style ->
updateStory { it.copy(style = style) }
},
onTextScaleChange = { scale ->
updateStory { it.copy(textScale = scale) }
},
)
}
internal val textStyleStoryFactory
get() = storyPageFactory(StateUpdater<TextStyleStory>::build)

View file

@ -0,0 +1,192 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.textstyle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
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.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.storybook.entity.TextStyleStory
@Composable
internal fun TextStyleStory(state: TextStyleStory, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.statusBarsPadding()
.fillMaxSize()
.background(TangemTheme.colors2.surface.level1),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
ComponentPreview(style = state.style, textScale = state.textScale)
StyleSelector(selected = state.style, onSelect = state.onStyleChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
}
}
@Composable
private fun ComponentPreview(style: TextStyleStory.Style, textScale: Float) {
val textStyle = style.toTextStyle()
val baseDensity = LocalDensity.current
val scaledDensity = remember(baseDensity, textScale) {
Density(density = baseDensity.density, fontScale = textScale)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors2.surface.level2)
.padding(24.dp),
) {
CompositionLocalProvider(LocalDensity provides scaledDensity) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
SampleText(text = "multiline\nstring", style = textStyle)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
SampleText(text = "one string", style = textStyle)
SampleText(text = "separate string", style = textStyle)
}
}
}
}
}
@Composable
private fun SampleText(text: String, style: TextStyle) {
Text(
text = text,
style = style,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
)
}
@Composable
private fun StyleSelector(selected: TextStyleStory.Style, onSelect: (TextStyleStory.Style) -> Unit) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Text(
text = "Text style",
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
val shape = RoundedCornerShape(50)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(TangemTheme.colors2.surface.level2)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.secondary,
shape = shape,
)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
TextStyleStory.Style.entries.forEach { style ->
StyleChip(
label = style.label,
selected = style == selected,
onClick = { onSelect(style) },
modifier = Modifier.weight(1f),
)
}
}
}
}
@Composable
private fun TextScaleSlider(value: Float, onChange: (Float) -> Unit) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Text(
text = "Text scale: ${"%.2f".format(value)}x",
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Slider(
modifier = Modifier.fillMaxWidth(),
value = value,
onValueChange = onChange,
valueRange = 0.5f..2f,
steps = 14,
colors = SliderDefaults.colors(
thumbColor = TangemTheme.colors.text.accent,
activeTrackColor = TangemTheme.colors.text.accent,
activeTickColor = TangemTheme.colors2.surface.level3,
inactiveTrackColor = TangemTheme.colors2.surface.level3,
inactiveTickColor = TangemTheme.colors.text.accent,
),
)
}
}
@Composable
private fun StyleChip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val chipShape = RoundedCornerShape(50)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(chipShape)
.background(
if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2,
)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
Text(
text = label,
style = TangemTheme.typography.caption2,
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
)
}
}
@Composable
@ReadOnlyComposable
private fun TextStyleStory.Style.toTextStyle(): TextStyle = when (this) {
TextStyleStory.Style.Display -> TangemTheme.typography3.display.medium
TextStyleStory.Style.HeadM -> TangemTheme.typography3.heading.medium
TextStyleStory.Style.HeadS -> TangemTheme.typography3.heading.small
TextStyleStory.Style.Body -> TangemTheme.typography3.body.medium
TextStyleStory.Style.SubH -> TangemTheme.typography3.subheading.medium
TextStyleStory.Style.Caption -> TangemTheme.typography3.caption.medium
}

View file

@ -48,6 +48,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.messagebanner.Ta
import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory
import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSearchStory import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSearchStory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory
import com.tangem.feature.tester.presentation.storybook.page.ds.textstyle.TextStyleStory
import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.TangemTopNavigationStory import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.TangemTopNavigationStory
import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory
import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory
@ -105,6 +106,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
is TangemFadeStory -> TangemFadeStory(state = storyState) is TangemFadeStory -> TangemFadeStory(state = storyState)
is TangemTopNavigationStory -> TangemTopNavigationStory(state = storyState) is TangemTopNavigationStory -> TangemTopNavigationStory(state = storyState)
is TangemMessageBannerStory -> TangemMessageBannerStory(state = storyState) is TangemMessageBannerStory -> TangemMessageBannerStory(state = storyState)
is TextStyleStory -> TextStyleStory(state = storyState)
} }
} }
} }

View file

@ -67,7 +67,6 @@ internal class WalletTokensListUMConverterTest {
isAccountsModeEnabled = false, isAccountsModeEnabled = false,
expandedAccounts = emptySet(), expandedAccounts = emptySet(),
stakingAvailabilityMap = emptyMap(), stakingAvailabilityMap = emptyMap(),
isAddAndManageTokensEnabled = true,
shouldShowMainPromo = false, shouldShowMainPromo = false,
) )