diff --git a/features/swap-v2/impl/build.gradle.kts b/features/swap-v2/impl/build.gradle.kts index bab79b9a43..5f2307c771 100644 --- a/features/swap-v2/impl/build.gradle.kts +++ b/features/swap-v2/impl/build.gradle.kts @@ -98,4 +98,6 @@ dependencies { testImplementation(deps.test.junit5) testImplementation(deps.test.truth) testImplementation(deps.test.mockk) + testImplementation(deps.test.coroutine) + testImplementation(projects.common.test) } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponent.kt index 91b726bd17..c8d24d34c3 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponent.kt @@ -1,9 +1,13 @@ package com.tangem.features.swap.v2.impl.amount import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -14,10 +18,14 @@ import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.express.models.ExpressRateType +import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.SwapAmountModel import com.tangem.features.swap.v2.impl.amount.ui.SwapAmountContent @@ -28,7 +36,7 @@ import dagger.assisted.AssistedInject internal class SwapAmountComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SwapAmountComponentParams.AmountParams, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SwapAmountModel = getOrCreateModel(params = params) @@ -49,6 +57,23 @@ internal class SwapAmountComponent @AssistedInject constructor( fun updateState(amountUM: SwapAmountUM) = model.updateState(amountUM) + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + text = stringResourceSafe(R.string.send_amount_label), + onBackClick = { + params.callback.onBackClick(params.route) + }, + backIconRes = if (params.route.isEditMode) { + R.drawable.ic_back_24 + } else { + R.drawable.ic_close_24 + }, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val amountUM by model.uiState.collectAsStateWithLifecycle() @@ -63,6 +88,30 @@ internal class SwapAmountComponent @AssistedInject constructor( rateInfo.child?.instance?.BottomSheet() } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + PrimaryButton( + text = if (params.route.isEditMode) { + stringResourceSafe(R.string.common_continue) + } else { + stringResourceSafe(R.string.common_next) + }, + enabled = state.isPrimaryButtonEnabled, + onClick = { + model.onAmountNext() + params.callback.onNextClick(params.route) + }, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) + } + private fun rateInfoChild( config: ExpressRateType, componentContext: ComponentContext, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt index 50fc5eba87..0c2c6dded1 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt @@ -10,7 +10,6 @@ import com.tangem.domain.swap.models.SwapDirection import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow internal sealed class SwapAmountComponentParams { @@ -41,7 +40,7 @@ internal sealed class SwapAmountComponentParams { override val isAccountModeFlow: StateFlow, val title: TextReference, val callback: SwapAmountComponent.ModelCallback, - val currentRoute: Flow, + val route: SendWithSwapRoute, ) : SwapAmountComponentParams() data class AmountBlockParams( diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index 09064c9ed5..6043bcf6c9 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -8,14 +8,11 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.common.ui.notifications.NotificationId import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.swap.SwapBestRateAnimationStore import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -40,7 +37,6 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkListener -import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent.SwapChooseProviderConfig import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceListener @@ -54,7 +50,6 @@ import com.tangem.features.swap.v2.impl.amount.model.transformers.* import com.tangem.features.swap.v2.impl.chooseprovider.SwapChooseProviderComponent import com.tangem.features.swap.v2.impl.common.SwapAlertFactory import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM -import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents.NoticeFixedRate.toAnalyticsRateType import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -125,7 +120,6 @@ internal class SwapAmountModel @Inject constructor( private val amountAnalyticsSender = SwapAmountAnalyticsSender(analyticsEventHandler) private var autoUpdateSubscriberJob: Job? = null - private var navigationJob: Job? = null val uiState: StateFlow field = MutableStateFlow(params.amountUM) @@ -156,7 +150,6 @@ internal class SwapAmountModel @Inject constructor( } else { QUOTES_UPDATE_DELAY } - configAmountNavigation() quoteTaskScheduler.scheduleTask( scope = modelScope, task = loadQuotesTask(initialDelay = initialDelay), @@ -167,7 +160,6 @@ internal class SwapAmountModel @Inject constructor( fun onStop() { quoteTaskScheduler.cancelTask() autoUpdateSubscriberJob?.cancel() - navigationJob?.cancel() } override fun onDestroy() { @@ -325,7 +317,7 @@ internal class SwapAmountModel @Inject constructor( val isShowSendViaSwapNotification = shouldShowNotificationUseCase( NotificationId.SendViaSwapTokenSelectorNotification.key, ) - val isEditMode = amountParams.currentRoute.firstOrNull()?.isEditMode == true + val isEditMode = amountParams.route.isEditMode val selectedCurrency = (uiState.value as? SwapAmountUM.Content)?.secondaryCryptoCurrencyStatus?.currency appRouter.push( AppRoute.ChooseManagedTokens( @@ -354,7 +346,7 @@ internal class SwapAmountModel @Inject constructor( val amountParams = params as? SwapAmountComponentParams.AmountParams ?: return modelScope.launch { - if (amountParams.currentRoute.firstOrNull()?.isEditMode == true) { + if (amountParams.route.isEditMode) { swapAmountAlertFactory.showCloseSendWithSwapAlert { params.callback.resetSendWithSwapNavigation(resetNavigation = true) confirmSendWithSwapClose() @@ -934,42 +926,6 @@ internal class SwapAmountModel @Inject constructor( ) } - private fun configAmountNavigation() { - val params = params as? SwapAmountComponentParams.AmountParams ?: return - navigationJob?.cancel() - navigationJob = combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).filter { (_, route) -> route is SendWithSwapRoute.Amount }.onEach { (state, route) -> - params.callback.onNavigationResult( - NavigationUM.Content( - source = SendWithSwapRoute.Amount::class.java.simpleName, - title = resourceReference(R.string.common_amount), - subtitle = null, - backIconRes = if (route.isEditMode) { - R.drawable.ic_back_24 - } else { - R.drawable.ic_close_24 - }, - backIconClick = params.callback::onBackClick, - primaryButton = NavigationButton( - textReference = if (route.isEditMode) { - resourceReference(R.string.common_continue) - } else { - resourceReference(R.string.common_next) - }, - isEnabled = state.isPrimaryButtonEnabled, - onClick = { - onAmountNext() - params.callback.onNextClick() - }, - ), - ), - ) - }.launchIn(modelScope) - } - private companion object { const val DEBOUNCE_AMOUNT_DELAY = 500L const val QUOTES_UPDATE_DELAY = 10000L diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt index f34b1ad38e..edf0958a75 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt @@ -4,21 +4,18 @@ import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.value.ObserveLifecycleMode import com.arkivanov.decompose.value.subscribe -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter -import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.swap.models.R import com.tangem.domain.swap.models.SwapDirection @@ -41,7 +38,6 @@ import com.tangem.features.swap.v2.impl.sendviaswap.ui.SendWithSwapContent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch internal class DefaultSendWithSwapComponent @AssistedInject constructor( @@ -50,7 +46,6 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( private val sendDestinationComponentFactory: SendDestinationComponent.Factory, private val confirmComponentFactory: SendWithSwapConfirmComponent.Factory, private val analyticsEventHandler: AnalyticsEventHandler, - private val urlOpener: UrlOpener, ) : SendWithSwapComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -99,7 +94,7 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( activeComponent.updateState(model.uiState.value.destinationUM) } is SendWithSwapConfirmComponent -> { - if (model.currentRoute.value.isEditMode) { + if (stack.active.configuration.isEditMode) { activeComponent.updateState(model.uiState.value) } // Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate ([REDACTED_TASK_KEY]). @@ -120,7 +115,6 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( ) } } - model.currentRoute.emit(stack.active.configuration) } } } @@ -128,35 +122,33 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val stackState by childStack.subscribeAsState() - val state by model.uiState.collectAsStateWithLifecycle() BackHandler( - onBack = { - (state.navigationUM as? NavigationUM.Content)?.backIconClick() ?: onChildBack() - }, + onBack = ::onChildBack, ) + SendWithSwapContent( - navigationUM = state.navigationUM, - confirmUM = state.confirmUM, stackState = stackState, - onLinkClick = urlOpener::openUrl, ) } private fun createChild(route: SendWithSwapRoute, childContext: AppComponentContext) = when (route) { - is SendWithSwapRoute.Amount -> getAmountComponent(factoryContext = childContext) - is SendWithSwapRoute.Destination -> getDestinationComponent(factoryContext = childContext) + is SendWithSwapRoute.Amount -> getAmountComponent(route, factoryContext = childContext) + is SendWithSwapRoute.Destination -> getDestinationComponent(route = route, factoryContext = childContext) is SendWithSwapRoute.Confirm -> getConfirmComponent(factoryContext = childContext) is SendWithSwapRoute.Success -> getSuccessComponent(factoryContext = childContext) } - private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getAmountComponent( + route: SendWithSwapRoute.Amount, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent { return SwapAmountComponent( appComponentContext = factoryContext, params = SwapAmountComponentParams.AmountParams( amountUM = model.uiState.value.amountUM, title = resourceReference(R.string.common_send), - currentRoute = model.currentRoute, + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, primaryCryptoCurrencyStatusFlow = model.primaryCryptoCurrencyStatusFlow, @@ -172,17 +164,20 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( ) } - private fun getDestinationComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getDestinationComponent( + route: DestinationRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent { val amountContentUM = model.uiState.value.amountUM as? SwapAmountUM.Content - ?: return ComposableContentComponent.EMPTY + ?: return ComposableModularContentComponent.EMPTY val secondaryCryptoCurrency = amountContentUM.secondaryCryptoCurrencyStatus?.currency - ?: return ComposableContentComponent.EMPTY + ?: return ComposableModularContentComponent.EMPTY return sendDestinationComponentFactory.create( context = factoryContext, params = SendDestinationComponentParams.DestinationParams( state = model.uiState.value.destinationUM, - currentRoute = model.currentRoute.filterIsInstance(), + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, analyticsSendSource = model.analyticsSendSource, @@ -195,12 +190,11 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( ) } - private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { return confirmComponentFactory.create( appComponentContext = factoryContext, params = SendWithSwapConfirmComponent.Params( sendWithSwapUM = model.uiState.value, - currentRoute = model.currentRoute.filterIsInstance(), isBalanceHidingFlow = model.isBalanceHiddenFlow, appCurrency = model.appCurrency, userWallet = model.userWallet, @@ -216,12 +210,11 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( ) } - private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { return SendWithSwapSuccessComponent( appComponentContext = factoryContext, params = SendWithSwapSuccessComponent.Params( sendWithSwapUMFlow = model.uiState, - currentRoute = model.currentRoute.filterIsInstance(), callback = model, analyticsCategoryName = model.analyticCategoryName, ), diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 9ae5d97b6f..1a601cbc7d 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -1,37 +1,52 @@ package com.tangem.features.swap.v2.impl.sendviaswap.confirm +import androidx.compose.animation.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.footers.SendingText +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapDirection -import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues -import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.* import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.* +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams import com.tangem.features.swap.v2.impl.common.SwapUtils.SEND_WITH_SWAP_PROVIDER_TYPES import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent -import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.confirm.model.SendWithSwapConfirmModel import com.tangem.features.swap.v2.impl.sendviaswap.confirm.ui.SendWithSwapConfirmContent import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM +import com.tangem.utils.StringsSigns import com.tangem.utils.extensions.orZero import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -44,7 +59,8 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory, feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory, sendNotificationsComponentFactory: SendNotificationsComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { + private val urlOpener: UrlOpener, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SendWithSwapConfirmModel = getOrCreateModel(params = params) @@ -177,6 +193,17 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( } } + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + text = stringResourceSafe(R.string.send_with_swap_confirm_title), + onBackClick = router::pop, + backIconRes = R.drawable.ic_back_24, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val sendWithSwapUM by model.uiState.collectAsStateWithLifecycle() @@ -196,13 +223,120 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( ) } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + Column { + val confirmUMContent = state.confirmUM as? ConfirmUM.Content + AnimatedVisibility( + visible = confirmUMContent != null, + enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + ) { + val sendFooter = confirmUMContent?.sendingFooter ?: TextReference.EMPTY + val legalFooter = getAnnotatedStringForLegals( + tosUM = confirmUMContent?.tosUM, + sendFooter = sendFooter, + onClick = urlOpener::openUrl, + ) + val footerText = remember(sendFooter, legalFooter) { + if (sendFooter != TextReference.EMPTY || legalFooter != TextReference.EMPTY) { + combinedReference(sendFooter, legalFooter) + } else { + TextReference.EMPTY + } + } + SendingText(footerText = footerText) + } + NavigationPrimaryButton( + primaryButton = model.primaryButtonUM(state.confirmUM), + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) + } + } + + @Composable + private fun getAnnotatedStringForLegals( + tosUM: ConfirmUM.Content.TosUM?, + sendFooter: TextReference, + onClick: (String) -> Unit, + ): TextReference { + if (tosUM == null) return TextReference.EMPTY + val tos = tosUM.tosLink + val policy = tosUM.policyLink + return if (tos != null && policy != null) { + val tosTitle = tos.title.resolveReference() + val policyTitle = policy.title.resolveReference() + val fullString = stringResourceSafe(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle) + val tosIndex = fullString.indexOf(tosTitle) + val policyIndex = fullString.indexOf(policyTitle) + + annotatedReference { + if (!sendFooter.resolveReference().endsWith(StringsSigns.POINT_SIGN)) { + append(StringsSigns.POINT_SIGN) + } + appendSpace() + append(fullString.substring(0, tosIndex)) + withLink( + link = LinkAnnotation.Clickable( + tag = "TOS_TAG", + linkInteractionListener = { onClick(tos.link) }, + ), + block = { + appendColored( + text = fullString.substring(tosIndex, tosIndex + tosTitle.length), + color = TangemTheme.colors.text.accent, + ) + }, + ) + append(fullString.substring(tosIndex + tosTitle.length, policyIndex)) + withLink( + link = LinkAnnotation.Clickable( + tag = "POLICY_TAG", + linkInteractionListener = { onClick(policy.link) }, + ), + block = { + appendColored( + text = fullString.substring(policyIndex, policyIndex + policyTitle.length), + color = TangemTheme.colors.text.accent, + ) + }, + ) + } + } else { + val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" } + val legalTitle = legal.title.resolveReference() + val fullString = stringResourceSafe(id = R.string.express_legal_one_placeholder, legalTitle) + val legalIndex = fullString.indexOf(legalTitle) + + annotatedReference { + append(fullString.substring(0, legalIndex)) + withLink( + link = LinkAnnotation.Clickable( + tag = "LEGAL_TAG", + linkInteractionListener = { onClick(legal.link) }, + ), + block = { + appendColored( + text = fullString.substring(legalIndex, legalIndex + legalTitle.length), + color = TangemTheme.colors.text.accent, + ) + }, + ) + } + } + } + data class Params( val sendWithSwapUM: SendWithSwapUM, val analyticsCategoryName: String, val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, val userWallet: UserWallet, val appCurrency: AppCurrency, - val currentRoute: Flow, val swapDirection: SwapDirection, val isBalanceHidingFlow: StateFlow, val primaryCryptoCurrencyStatusFlow: StateFlow, @@ -218,6 +352,6 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( } interface ModelCallback { - fun onResult(route: SendWithSwapRoute, sendWithSwapUM: SendWithSwapUM) + fun onResult(sendWithSwapUM: SendWithSwapUM) } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 9c63c30efb..31c16b0228 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -8,7 +8,6 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -38,14 +37,14 @@ import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.swap.v2.api.SwapFeatureToggles @@ -176,7 +175,6 @@ internal class SendWithSwapConfirmModel @Inject constructor( init { updateAmountSubtractAvailability() - configConfirmNavigation() initialState() subscribeOnNotificationUpdates() subscribeOnTapHelpUpdates() @@ -383,6 +381,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( swapDataModel = data, ), ) + params.callback.onResult(uiState.value) router.replaceAll(SendWithSwapRoute.Success) }, expressOperationType = ExpressOperationType.SEND_WITH_SWAP, @@ -582,49 +581,29 @@ internal class SendWithSwapConfirmModel @Inject constructor( } } - private fun configConfirmNavigation() { - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).filter { - it.second is SendWithSwapRoute.Confirm - }.onEach { (state, _) -> - val confirmUM = state.confirmUM - val isContent = confirmUM is ConfirmUM.Content - val isReadyToSend = isContent && !confirmUM.isTransactionInProcess - val isHoldToConfirm = params.userWallet.isHotWallet && isContent - params.callback.onResult( - route = SendWithSwapRoute.Confirm, - sendWithSwapUM = state.copy( - navigationUM = NavigationUM.Content( - source = SendWithSwapRoute.Confirm.javaClass.simpleName, - title = resourceReference(id = R.string.send_with_swap_confirm_title), - subtitle = null, - backIconRes = R.drawable.ic_back_24, - backIconClick = router::pop, - primaryButton = NavigationButton( - textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm), - iconRes = walletInterationIcon(params.userWallet), - isIconVisible = isReadyToSend && !isHoldToConfirm, - isHapticClick = isReadyToSend, - isHoldToConfirm = isHoldToConfirm, - isEnabled = confirmUM.isPrimaryButtonEnabled, - onClick = { - when (confirmUM) { - is ConfirmUM.Content -> if (confirmUM.isTransactionInProcess) { - return@NavigationButton - } else { - onSendClick() - } - else -> return@NavigationButton - } - }, - ), - ), - ), - ) - }.launchIn(modelScope) + fun primaryButtonUM(confirmUM: ConfirmUM): NavigationButton { + val isContent = confirmUM is ConfirmUM.Content + val isReadyToSend = isContent && !confirmUM.isTransactionInProcess + val isHoldToConfirm = params.userWallet.isHotWallet && isContent + + return NavigationButton( + textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm), + iconRes = walletInterationIcon(params.userWallet), + isIconVisible = isReadyToSend && !isHoldToConfirm, + isHapticClick = isReadyToSend, + isHoldToConfirm = isHoldToConfirm, + isEnabled = confirmUM.isPrimaryButtonEnabled, + onClick = { + when (confirmUM) { + is ConfirmUM.Content -> if (confirmUM.isTransactionInProcess) { + return@NavigationButton + } else { + onSendClick() + } + else -> return@NavigationButton + } + }, + ) } private fun getPrimaryButtonText(confirmUM: ConfirmUM, isHoldToConfirm: Boolean): TextReference { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt index 1b3c333b80..b4702ac55d 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt @@ -1,8 +1,7 @@ package com.tangem.features.swap.v2.impl.sendviaswap.entity -import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM @@ -11,5 +10,4 @@ internal data class SendWithSwapUM( val destinationUM: DestinationUM, val feeSelectorUM: FeeSelectorUM, val confirmUM: ConfirmUM, - val navigationUM: NavigationUM, ) \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt index 671ae85c1d..3424e016eb 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt @@ -1,10 +1,10 @@ package com.tangem.features.swap.v2.impl.sendviaswap.model import arrow.core.getOrElse -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Route import com.tangem.core.decompose.navigation.Router import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase @@ -20,9 +20,9 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapDirection import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.swap.v2.api.SendWithSwapComponent import com.tangem.features.swap.v2.impl.amount.SwapAmountComponent import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM @@ -61,7 +61,6 @@ internal class SendWithSwapModel @Inject constructor( val analyticCategoryName = CommonSendAnalyticEvents.SEND_CATEGORY val analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.SendWithSwap val initialRoute = SendWithSwapRoute.Amount(false) - val currentRoute = MutableStateFlow(initialRoute) var userWallet: UserWallet by Delegates.notNull() var appCurrency: AppCurrency = AppCurrency.Default @@ -108,14 +107,8 @@ internal class SendWithSwapModel @Inject constructor( uiState.update { it.copy(destinationUM = destinationUM) } } - override fun onResult(route: SendWithSwapRoute, sendWithSwapUM: SendWithSwapUM) { - if (currentRoute.value == route) { - uiState.value = sendWithSwapUM - } - } - - override fun onNavigationResult(navigationUM: NavigationUM) { - uiState.update { it.copy(navigationUM = navigationUM) } + override fun onResult(sendWithSwapUM: SendWithSwapUM) { + uiState.value = sendWithSwapUM } override fun onSeparatorClick(lastAmount: String, isEnterInFiatSelected: Boolean) { @@ -129,7 +122,6 @@ internal class SendWithSwapModel @Inject constructor( destinationUM = DestinationUM.Empty(), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, ) } if (resetNavigation) { @@ -137,17 +129,17 @@ internal class SendWithSwapModel @Inject constructor( } } - override fun onBackClick() = router.pop() + override fun onBackClick(currentRoute: Route) = router.pop() - override fun onNextClick() { - if (currentRoute.value.isEditMode) { - onBackClick() + override fun onNextClick(currentRoute: Route) { + if ((currentRoute as? SendWithSwapRoute)?.isEditMode == true) { + onBackClick(currentRoute) } else { - when (currentRoute.value) { + when (currentRoute) { is SendWithSwapRoute.Amount -> router.push(SendWithSwapRoute.Destination(isEditMode = false)) is SendWithSwapRoute.Destination -> router.push(SendWithSwapRoute.Confirm) SendWithSwapRoute.Confirm -> router.push(SendWithSwapRoute.Success) - SendWithSwapRoute.Success -> onBackClick() + SendWithSwapRoute.Success -> onBackClick(currentRoute) } } } @@ -171,7 +163,7 @@ internal class SendWithSwapModel @Inject constructor( ) } }, - popBack = ::onBackClick, + popBack = router::pop, ) }, ) @@ -189,7 +181,6 @@ internal class SendWithSwapModel @Inject constructor( destinationUM = DestinationUM.Empty(), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/SendWithSwapSuccessComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/SendWithSwapSuccessComponent.kt index 26d40108f5..07c8cc6e43 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/SendWithSwapSuccessComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/SendWithSwapSuccessComponent.kt @@ -1,37 +1,91 @@ package com.tangem.features.swap.v2.impl.sendviaswap.success +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.navigationButtons.DoneButtons +import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.swap.v2.impl.R +import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM import com.tangem.features.swap.v2.impl.sendviaswap.success.model.SendWithSwapSuccessModel import com.tangem.features.swap.v2.impl.sendviaswap.success.ui.SendWithSwapSuccessContent -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow internal class SendWithSwapSuccessComponent( appComponentContext: AppComponentContext, - params: Params, -) : ComposableContentComponent, AppComponentContext by appComponentContext { + private val params: Params, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SendWithSwapSuccessModel = getOrCreateModel(params = params) + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + onBackClick = router::pop, + backIconRes = R.drawable.ic_close_24, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() SendWithSwapSuccessContent(sendWithSwapUM = state) } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + DoneButtons( + (NavigationButton( + textReference = resourceReference(R.string.common_explore), + iconRes = R.drawable.ic_web_24, + onClick = model::onExploreClick, + ) to NavigationButton( + textReference = resourceReference(R.string.common_share), + iconRes = R.drawable.ic_share_24, + onClick = model::onShareClick, + )).takeUnless { (state.confirmUM as? ConfirmUM.Success)?.txUrl.isNullOrBlank() }, + ) + PrimaryButton( + text = stringResourceSafe(R.string.common_close), + onClick = router::pop, + modifier = Modifier.fillMaxWidth(), + ) + } + } + data class Params( val sendWithSwapUMFlow: StateFlow, val analyticsCategoryName: String, - val currentRoute: Flow, val callback: NavigationModelCallback, ) } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/model/SendWithSwapSuccessModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/model/SendWithSwapSuccessModel.kt index 6b743b5db5..8837471c49 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/model/SendWithSwapSuccessModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/model/SendWithSwapSuccessModel.kt @@ -1,18 +1,11 @@ package com.tangem.features.swap.v2.impl.sendviaswap.success.model -import com.tangem.common.routing.AppRouter -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM -import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM import com.tangem.features.swap.v2.impl.sendviaswap.success.SendWithSwapSuccessComponent import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -24,7 +17,6 @@ internal class SendWithSwapSuccessModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val urlOpener: UrlOpener, private val shareManager: ShareManager, - private val appRouter: AppRouter, paramsContainer: ParamsContainer, ) : Model() { @@ -34,47 +26,14 @@ internal class SendWithSwapSuccessModel @Inject constructor( val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success - init { - configConfirmSuccessNavigation() - } - - private fun configConfirmSuccessNavigation() { - params.callback.onNavigationResult( - NavigationUM.Content( - source = SendWithSwapRoute.Success.javaClass.simpleName, - title = TextReference.EMPTY, - subtitle = null, - backIconRes = R.drawable.ic_close_24, - backIconClick = appRouter::pop, - primaryButton = NavigationButton( - textReference = resourceReference(R.string.common_close), - iconRes = null, - isEnabled = true, - isHapticClick = false, - onClick = appRouter::pop, - ), - prevButton = null, - secondaryPairButtonsUM = (NavigationButton( - textReference = resourceReference(R.string.common_explore), - iconRes = R.drawable.ic_web_24, - onClick = ::onExploreClick, - ) to NavigationButton( - textReference = resourceReference(R.string.common_share), - iconRes = R.drawable.ic_share_24, - onClick = ::onShareClick, - )).takeUnless { confirmUM?.txUrl.isNullOrBlank() }, - ), - ) - } - - private fun onExploreClick() { + fun onExploreClick() { if (confirmUM == null) return // analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName)) urlOpener.openUrl(confirmUM.txUrl) } - private fun onShareClick() { + fun onShareClick() { if (confirmUM == null) return // analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName)) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt index bf24732fbe..bbfcb80e96 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt @@ -21,9 +21,6 @@ import com.tangem.common.ui.account.AccountTitle import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.utils.getFiatReference -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2 -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.ui.components.Fade import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.icons.identicon.IdentIcon @@ -45,27 +42,24 @@ import com.tangem.domain.express.models.ExpressProviderType import com.tangem.domain.swap.models.SwapDataModel import com.tangem.domain.swap.models.SwapDataTransactionModel import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM -import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM -import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM -import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal @Composable internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) { - if (sendWithSwapUM.navigationUM !is NavigationUM.Content) return - Column { Box( modifier = Modifier @@ -82,14 +76,6 @@ internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) { backgroundColor = TangemTheme.colors.background.tertiary, ) } - NavigationButtonsBlockV2( - navigationUM = sendWithSwapUM.navigationUM, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), - ) } } @@ -458,31 +444,6 @@ private fun SendWithSwapSuccessContent_Preview() { isPrimaryButtonEnabled = false, ), ), - navigationUM = NavigationUM.Content( - source = SendWithSwapRoute.Success.javaClass.simpleName, - title = TextReference.EMPTY, - subtitle = null, - backIconRes = R.drawable.ic_close_24, - backIconClick = {}, - additionalIconRes = null, - additionalIconClick = null, - primaryButton = NavigationButton( - textReference = resourceReference(R.string.common_close), - isEnabled = true, - onClick = {}, - ), - secondaryPairButtonsUM = NavigationButton( - textReference = resourceReference(R.string.common_explore), - iconRes = R.drawable.ic_web_24, - isEnabled = true, - onClick = {}, - ) to NavigationButton( - textReference = resourceReference(R.string.common_share), - iconRes = R.drawable.ic_share_24, - isEnabled = true, - onClick = {}, - ), - ), ), ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt index dab9d55aa2..7d8a0218c1 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt @@ -1,45 +1,25 @@ package com.tangem.features.swap.v2.impl.sendviaswap.ui -import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.withLink -import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.stack.Children import com.arkivanov.decompose.extensions.compose.stack.animation.fade import com.arkivanov.decompose.extensions.compose.stack.animation.plus import com.arkivanov.decompose.extensions.compose.stack.animation.slide import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.router.stack.ChildStack -import com.tangem.common.ui.footers.SendingText -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.ui.components.Fade -import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.swap.v2.impl.R -import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute -import com.tangem.utils.StringsSigns @Composable -internal fun SendWithSwapContent( - navigationUM: NavigationUM, - confirmUM: ConfirmUM, - stackState: ChildStack, - onLinkClick: (String) -> Unit, -) { - val navigationUMContent = navigationUM as? NavigationUM.Content ?: return - +internal fun SendWithSwapContent(stackState: ChildStack) { Column( modifier = Modifier .background(color = TangemTheme.colors.background.tertiary) @@ -48,12 +28,7 @@ internal fun SendWithSwapContent( .systemBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { - AppBarWithBackButton( - text = navigationUMContent.title.resolveReference(), - onBackClick = navigationUMContent.backIconClick, - iconRes = navigationUMContent.additionalIconRes, - modifier = Modifier.height(TangemTheme.dimens.size56), - ) + stackState.active.instance.Title() Children( stack = stackState, animation = stackAnimation { child -> @@ -76,125 +51,6 @@ internal fun SendWithSwapContent( } } } - if (stackState.active.configuration != SendWithSwapRoute.Success) { - SendWithSwapFooter( - confirmUM = confirmUM, - stackState = stackState, - primaryButton = navigationUMContent.primaryButton, - onLinkClick = onLinkClick, - ) - } - } -} - -@Composable -private fun SendWithSwapFooter( - confirmUM: ConfirmUM, - stackState: ChildStack, - primaryButton: NavigationButton, - onLinkClick: (String) -> Unit, -) { - Column { - AnimatedVisibility( - visible = stackState.active.configuration == SendWithSwapRoute.Confirm, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), - ) { - val confirmContentUM = confirmUM as? ConfirmUM.Content - val sendFooter = confirmContentUM?.sendingFooter ?: TextReference.EMPTY - val legalFooter = getAnnotatedStringForLegals( - tosUM = confirmContentUM?.tosUM, - sendFooter = sendFooter, - onClick = onLinkClick, - ) - val footerText = remember(sendFooter, legalFooter) { - if (sendFooter != TextReference.EMPTY || legalFooter != TextReference.EMPTY) { - combinedReference(sendFooter, legalFooter) - } else { - TextReference.EMPTY - } - } - SendingText(footerText = footerText) - } - NavigationPrimaryButton( - primaryButton = primaryButton, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), - ) - } -} - -@Composable -private fun getAnnotatedStringForLegals( - tosUM: ConfirmUM.Content.TosUM?, - sendFooter: TextReference, - onClick: (String) -> Unit, -): TextReference { - if (tosUM == null) return TextReference.EMPTY - val tos = tosUM.tosLink - val policy = tosUM.policyLink - return if (tos != null && policy != null) { - val tosTitle = tos.title.resolveReference() - val policyTitle = policy.title.resolveReference() - val fullString = stringResourceSafe(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle) - val tosIndex = fullString.indexOf(tosTitle) - val policyIndex = fullString.indexOf(policyTitle) - - annotatedReference { - if (!sendFooter.resolveReference().endsWith(StringsSigns.POINT_SIGN)) { - append(StringsSigns.POINT_SIGN) - } - appendSpace() - append(fullString.substring(0, tosIndex)) - withLink( - link = LinkAnnotation.Clickable( - tag = "TOS_TAG", - linkInteractionListener = { onClick(tos.link) }, - ), - block = { - appendColored( - text = fullString.substring(tosIndex, tosIndex + tosTitle.length), - color = TangemTheme.colors.text.accent, - ) - }, - ) - append(fullString.substring(tosIndex + tosTitle.length, policyIndex)) - withLink( - link = LinkAnnotation.Clickable( - tag = "POLICY_TAG", - linkInteractionListener = { onClick(policy.link) }, - ), - block = { - appendColored( - text = fullString.substring(policyIndex, policyIndex + policyTitle.length), - color = TangemTheme.colors.text.accent, - ) - }, - ) - } - } else { - val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" } - val legalTitle = legal.title.resolveReference() - val fullString = stringResourceSafe(id = R.string.express_legal_one_placeholder, legalTitle) - val legalIndex = fullString.indexOf(legalTitle) - - annotatedReference { - append(fullString.substring(0, legalIndex)) - withLink( - link = LinkAnnotation.Clickable( - tag = "LEGAL_TAG", - linkInteractionListener = { onClick(legal.link) }, - ), - block = { - appendColored( - text = fullString.substring(legalIndex, legalIndex + legalTitle.length), - color = TangemTheme.colors.text.accent, - ) - }, - ) - } + stackState.active.instance.Footer() } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModelNavigationTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModelNavigationTest.kt new file mode 100644 index 0000000000..3f50b05aa9 --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModelNavigationTest.kt @@ -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, + ) + } +} \ No newline at end of file