From f5e14a9ab4361a31a43e79fc38b9839904152eed Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 11:59:59 +0500 Subject: [PATCH 1/6] Updated on 2026-08-14 --- .../NavigationModelCallback.kt | 7 +- .../api/subcomponents/amount/AmountRoute.kt | 4 +- .../amount/SendAmountComponent.kt | 4 +- .../amount/SendAmountComponentParams.kt | 3 +- .../destination/DestinationRoute.kt | 4 +- .../destination/SendDestinationComponent.kt | 4 +- .../SendDestinationComponentParams.kt | 3 +- .../features/send/common/CommonSendRoute.kt | 2 +- .../amount/DefaultSendAmountComponent.kt | 50 +++++++++++ .../amount/model/SendAmountModel.kt | 48 +--------- .../DefaultSendDestinationComponent.kt | 51 +++++++++++ .../destination/model/SendDestinationModel.kt | 89 +++++-------------- .../amount/model/SendAmountNavigationTest.kt | 76 ++++++++-------- 13 files changed, 179 insertions(+), 166 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationModelCallback.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationModelCallback.kt index 2e84e796a8..e21434050b 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationModelCallback.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationModelCallback.kt @@ -1,7 +1,8 @@ package com.tangem.common.ui.navigationButtons +import com.tangem.core.decompose.navigation.Route + interface NavigationModelCallback { - fun onNavigationResult(navigationUM: NavigationUM) - fun onBackClick() - fun onNextClick() + fun onBackClick(currentRoute: Route) + fun onNextClick(currentRoute: Route) } \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt index afcdebbae2..a4a4ce0301 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt @@ -1,8 +1,10 @@ package com.tangem.features.send.api.subcomponents.amount +import com.tangem.core.decompose.navigation.Route + /** * Common route for amount */ -interface AmountRoute { +interface AmountRoute : Route { val isEditMode: Boolean } \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt index 79e3d44a57..759d7edb35 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt @@ -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.navigationButtons.NavigationModelCallback 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 -interface SendAmountComponent : ComposableContentComponent { +interface SendAmountComponent : ComposableModularContentComponent { fun updateState(amountUM: AmountState) diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt index bda588bfee..e976dbb5ca 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt @@ -9,7 +9,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow sealed class SendAmountComponentParams { @@ -39,7 +38,7 @@ sealed class SendAmountComponentParams { override val accountFlow: StateFlow, override val isAccountModeFlow: StateFlow, val callback: SendAmountComponent.ModelCallback, - val currentRoute: Flow, + val route: AmountRoute, ) : SendAmountComponentParams() data class AmountBlockParams( diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/DestinationRoute.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/DestinationRoute.kt index c83ce3ebf0..79ac0df1d3 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/DestinationRoute.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/DestinationRoute.kt @@ -1,8 +1,10 @@ package com.tangem.features.send.api.subcomponents.destination +import com.tangem.core.decompose.navigation.Route + /** * Common route for destination */ -interface DestinationRoute { +interface DestinationRoute : Route { val isEditMode: Boolean } \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponent.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponent.kt index 6a323c5bf3..4eb849925a 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponent.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponent.kt @@ -2,10 +2,10 @@ package com.tangem.features.send.api.subcomponents.destination import com.tangem.common.ui.navigationButtons.NavigationModelCallback 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 -interface SendDestinationComponent : ComposableContentComponent { +interface SendDestinationComponent : ComposableModularContentComponent { fun updateState(destinationUM: DestinationUM) diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt index ead4f35164..7399b00475 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt @@ -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.entity.PredefinedValues import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow sealed class SendDestinationComponentParams { @@ -26,7 +25,7 @@ sealed class SendDestinationComponentParams { override val userWalletId: UserWalletId, val title: TextReference, val isBalanceHidingFlow: StateFlow, - val currentRoute: Flow, + val route: DestinationRoute, val callback: SendDestinationComponent.ModelCallback, override val isAllowSelfSend: Boolean = false, ) : SendDestinationComponentParams() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt index c098d9b414..9483285003 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt @@ -16,7 +16,7 @@ internal sealed class CommonSendRoute : Route { @Serializable data object Confirm : CommonSendRoute() { - override val isEditMode: Boolean = true + override val isEditMode: Boolean = false } @Serializable diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt index 0732ec6492..2546c2eb3a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt @@ -1,14 +1,23 @@ 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.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.decompose.context.AppComponentContext 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.SendAmountComponentParams +import com.tangem.features.send.impl.R import com.tangem.features.send.subcomponents.amount.model.SendAmountModel import com.tangem.features.send.subcomponents.amount.ui.SendAmountContent import dagger.assisted.Assisted @@ -24,6 +33,23 @@ internal class DefaultSendAmountComponent @AssistedInject constructor( 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 override fun Content(modifier: Modifier) { 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 interface Factory : SendAmountComponent.Factory { override fun create( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt index 0b2b8231da..2d505312ed 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt @@ -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.EnterAmountBoundary 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.decompose.di.ModelScoped 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.features.send.api.entity.PredefinedValues 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.SendAmountReduceListener 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.SelectedCurrencyType 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.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero @@ -84,7 +80,6 @@ internal class SendAmountModel @Inject constructor( private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull() init { - configAmountNavigation() initAppCurrency() subscribeOnCryptoCurrencyStatusFlow() subscribeOnAmountReduceByTriggerUpdates() @@ -104,6 +99,7 @@ internal class SendAmountModel @Inject constructor( }, ifRight = { wallet -> userWallet = wallet + setSendWithSwapAvailability() }, ) }.launchIn(modelScope) @@ -274,9 +270,7 @@ internal class SendAmountModel @Inject constructor( override fun onConvertToAnotherToken() { val amountParams = params as? SendAmountComponentParams.AmountParams ?: return modelScope.launch { - var isEditMode = false - amountParams.currentRoute.collect { route -> isEditMode = route.isEditMode } - if (isEditMode) { + if (amountParams.route.isEditMode) { sendAmountAlertFactory.showResetSendingAlert { params.callback.resetSendNavigation() 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(), - 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() { // Allowed only in multicurrency wallets val isMultiCurrency = userWallet?.isMultiCurrency == true diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt index e2f98cccea..648d4e5a93 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt @@ -1,8 +1,13 @@ 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.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.extensions.compose.subscribeAsState 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.AddressBookFeatureToggles 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.SendDestinationComponentParams 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.ui.SendDestinationContent import dagger.assisted.Assisted @@ -68,6 +79,22 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor( 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 override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() @@ -83,6 +110,30 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor( 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 interface Factory : SendDestinationComponent.Factory { override fun create( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index d493255dca..055470d142 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -8,14 +8,11 @@ import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.routing.AppRoute 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.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer 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.usecase.GetBackupProblematicWalletForAddressUseCase 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.DestinationBlockParams 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.analytics.EnterAddressSource import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents @@ -142,7 +137,6 @@ internal class SendDestinationModel @Inject constructor( private val backupProblematicWalletCache = AtomicReference?>(null) init { - configDestinationNavigation() subscribeOnQRScannerResult() initialState() resetContactOnEdit() @@ -153,15 +147,12 @@ internal class SendDestinationModel @Inject constructor( private fun resetContactOnEdit() { val params = params as? SendDestinationComponentParams.DestinationParams ?: return - params.currentRoute - .filter { it.isEditMode } - .onEach { - val content = uiState.value as? DestinationUM.Content ?: return@onEach - if (content.addressTextField.contactName != null) { - _uiState.update(SendDestinationContactTransformer(contact = null)) - } + if (params.route.isEditMode) { + val content = uiState.value as? DestinationUM.Content ?: return + if (content.addressTextField.contactName != null) { + _uiState.update(SendDestinationContactTransformer(contact = null)) } - .launchIn(modelScope) + } } 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 params.callback.onDestinationResult(uiState.value) } @@ -490,60 +497,10 @@ internal class SendDestinationModel @Inject constructor( private fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) { if (type.isAutoNext && isValidAddress && isValidMemo) { 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 { const val RECENT_TX_SIZE = 100 const val RECENT_LOAD_DELAY = 500L diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt index 837e9d2ee3..57a55fc5b8 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt @@ -1,14 +1,11 @@ package com.tangem.features.send.subcomponents.amount.model import arrow.core.right -import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Blockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory 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.decompose.model.MutableParamsContainer -import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency 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.SendAmountUpdateListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger -import com.tangem.features.send.impl.R import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -46,12 +41,14 @@ import org.junit.jupiter.api.Test import java.math.BigDecimal /** - * Guards the route-decoupling fix: `SendAmountModel.configAmountNavigation()` filters its route flow on - * the public `AmountRoute` interface, not the `internal CommonSendRoute.Amount`. The test feeds a - * foreign `AmountRoute` (which is NOT a `CommonSendRoute.Amount`) and asserts the navigation result is - * still produced — before the fix the `combine`'s `filterIsInstance()` dropped - * it and `onNavigationResult` never fired, leaving an external host (e.g. staking) with a dead Next - * button. Also checks the `isEditMode` → back-icon / primary-button mapping is unaffected. + * Guards the route-decoupling fix at the model level. The amount step is now hostable by foreign flows + * (e.g. staking / send-with-swap) that supply their own [AmountRoute] implementation rather than the + * `internal CommonSendRoute.Amount`. The navigation chrome (back icon / Next vs Continue) moved out of + * the model into `DefaultSendAmountComponent`, but the model still makes a route-driven decision in + * [SendAmountModel.onConvertToAnotherToken]: it reads `params.route.isEditMode` *directly* (previously + * 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) internal class SendAmountNavigationTest { @@ -79,10 +76,11 @@ internal class SendAmountNavigationTest { sendAmountUpdateListener, getSelectedAppCurrencyUseCase, getUserWalletUseCase, + sendAmountAlertFactory, callback, ) // 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 { sendAmountReduceListener.reduceToTriggerFlow } returns emptyFlow() every { sendAmountReduceListener.reduceByTriggerFlow } returns emptyFlow() @@ -94,43 +92,41 @@ internal class SendAmountNavigationTest { @AfterEach 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 = null } @Test - fun `GIVEN a foreign AmountRoute WHEN model created THEN navigation produced with close icon and next button`() = - runTest { - // Arrange — a route that is NOT CommonSendRoute.Amount (the impl type the model used to filter on). - val navSlot = slot() - - // 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`() = + fun `GIVEN foreign AmountRoute in edit mode WHEN onConvertToAnotherToken THEN reset sending alert shown`() = runTest { // Arrange - val navSlot = slot() - - // Act createModel(testScope = this, route = TestAmountRoute(isEditMode = true)) advanceUntilIdle() - // Assert - verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) } - val content = navSlot.captured as NavigationUM.Content - assertThat(content.backIconRes).isEqualTo(R.drawable.ic_back_24) - assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_continue)) + // Act + model?.onConvertToAnotherToken() + advanceUntilIdle() + + // 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 { @@ -147,7 +143,7 @@ internal class SendAmountNavigationTest { accountFlow = MutableStateFlow(null), isAccountModeFlow = MutableStateFlow(false), callback = callback, - currentRoute = MutableStateFlow(route), + route = route, ) return SendAmountModel( paramsContainer = MutableParamsContainer(value = params), From b9a5220984885e44c9291699184a0bdc151ffa6b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 12:00:16 +0500 Subject: [PATCH 2/6] Updated on 2026-08-14 --- .../features/send/common/ui/SendContent.kt | 99 --------- .../send/common/ui/SendModularContent.kt | 68 ++++++ .../send/common/utils/SendRouteUtils.kt | 28 --- .../send/send/DefaultSendComponent.kt | 199 +++++++----------- .../send/send/confirm/SendConfirmComponent.kt | 57 ++++- .../send/confirm/model/SendConfirmModel.kt | 66 ++---- .../features/send/send/model/SendModel.kt | 32 ++- .../success/SendConfirmSuccessComponent.kt | 107 +++++++++- .../success/model/SendConfirmSuccessModel.kt | 84 +------- .../success/ui/SendConfirmSuccessContent.kt | 9 - .../features/send/send/ui/state/SendUM.kt | 4 +- .../send/model/SendModelNavigationTest.kt | 198 +++++++++++++++++ 12 files changed, 526 insertions(+), 425 deletions(-) delete mode 100644 features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt delete mode 100644 features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt deleted file mode 100644 index a0c00f2d22..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt +++ /dev/null @@ -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, -) { - 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), - ) -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt new file mode 100644 index 0000000000..7d5efc908c --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt @@ -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) { + 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() + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt deleted file mode 100644 index 8e1878cadc..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt +++ /dev/null @@ -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>, - popBack: () -> Unit, -) { - if (currentRoute.isEditMode) { - popBack() - } else { - val isAlreadyInStack = childStack.value.items.any { it.configuration == nextRoute } - if (isAlreadyInStack) { - popTo(nextRoute) - } else { - push(nextRoute) - } - } -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt index a44f29903c..b4fe92d902 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt @@ -1,15 +1,9 @@ package com.tangem.features.send.send 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.getValue -import androidx.compose.ui.Alignment 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 @@ -17,49 +11,45 @@ import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.value.ObserveLifecycleMode import com.arkivanov.decompose.value.subscribe 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.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.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.res.TangemTheme import com.tangem.domain.models.account.derivationIndex import com.tangem.features.send.api.SendComponent 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.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.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.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.impl.R import com.tangem.features.send.send.confirm.SendConfirmComponent import com.tangem.features.send.send.model.SendModel 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.AssistedFactory 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( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SendComponent.Params, private val analyticsEventHandler: AnalyticsEventHandler, private val amountComponentFactory: SendAmountComponent.Factory, + private val destinationComponentFactory: SendDestinationComponent.Factory, + private val sendConfirmSuccessComponent: SendConfirmSuccessComponent.Factory, private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, - private val sendDestinationComponentFactory: SendDestinationComponent.Factory, ) : SendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -93,48 +83,45 @@ internal class DefaultSendComponent @AssistedInject constructor( lifecycle = lifecycle, mode = ObserveLifecycleMode.CREATE_DESTROY, ) { stack -> - componentScope.launch { - when (val activeComponent = stack.active.instance) { - is SendConfirmComponent -> { - val fromCurrency = params.currency - val fromDerivationIndex = model.accountFlow.value?.derivationIndex?.value - .takeIf { model.isAccountModeFlow.value } - analyticsEventHandler.send( - CommonSendAnalyticEvents.ConfirmationScreenOpened( - categoryName = model.analyticCategoryName, - source = model.analyticsSendSource, - sendBlockchain = fromCurrency.network.name, - sendToken = fromCurrency.symbol, - fromDerivationIndex = fromDerivationIndex, - toDerivationIndex = null, - type = model.consumeEntryType(), - ), - ) - if (model.currentRoute.value.isEditMode) { - 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) + when (val activeComponent = stack.active.instance) { + is SendConfirmComponent -> { + val fromCurrency = params.currency + val fromDerivationIndex = model.accountFlow.value?.derivationIndex?.value + .takeIf { model.isAccountModeFlow.value } + analyticsEventHandler.send( + CommonSendAnalyticEvents.ConfirmationScreenOpened( + categoryName = model.analyticCategoryName, + source = model.analyticsSendSource, + sendBlockchain = fromCurrency.network.name, + sendToken = fromCurrency.symbol, + fromDerivationIndex = fromDerivationIndex, + toDerivationIndex = null, + type = model.consumeEntryType(), + ), + ) + if (childStack.value.active.configuration.isEditMode) { + activeComponent.updateState(model.uiState.value) } } - 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 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() - }, - ) - SendContent( - navigationUM = state.navigationUM, - confirmUM = state.confirmUM, - stackState = stackState, - ) + BackHandler(onBack = ::onChildBack) + SendModularContent(stackState = stackState) } - private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { - CommonSendRoute.Empty -> getStubComponent() - is CommonSendRoute.Destination -> getDestinationComponent(factoryContext) - is CommonSendRoute.Amount -> getAmountComponent(factoryContext) + private fun createChild( + route: CommonSendRoute, + factoryContext: AppComponentContext, + ): 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.ConfirmSuccess -> getConfirmSuccessComponent(factoryContext) } - private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent = - sendDestinationComponentFactory.create( + private fun getDestinationComponent( + route: DestinationRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent { + return destinationComponentFactory.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, @@ -179,13 +163,17 @@ internal class DefaultSendComponent @AssistedInject constructor( callback = model, ), ) + } - private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getAmountComponent( + route: AmountRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent { return amountComponentFactory.create( context = factoryContext, params = SendAmountComponentParams.AmountParams( state = model.uiState.value.amountUM, - currentRoute = model.currentRoute.filterIsInstance(), + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, 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) { val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value val feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatusFlow.value @@ -210,7 +198,6 @@ internal class DefaultSendComponent @AssistedInject constructor( params = SendConfirmComponent.Params( state = model.uiState.value, userWallet = model.userWallet, - currentRoute = model.currentRoute, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -233,79 +220,43 @@ internal class DefaultSendComponent @AssistedInject constructor( ) } else { model.showAlertError() - getStubComponent() + ComposableModularContentComponent.EMPTY } } - private fun getConfirmSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getConfirmSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { val state = model.uiState.value val sendAmount = (state.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value val txUrl = (state.confirmUM as? ConfirmUM.Success)?.txUrl - val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value if (sendAmount == null || destinationAddress == null || txUrl == null ) { model.showAlertError() - return getStubComponent() + return ComposableModularContentComponent.EMPTY } - val destinationBlockComponent = - 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( + return sendConfirmSuccessComponent.create( appComponentContext = factoryContext, params = SendConfirmSuccessComponent.Params( sendUMFlow = model.uiState, - destinationBlockComponent = destinationBlockComponent, + userWalletId = params.userWalletId, + cryptoCurrency = params.currency, + predefinedValues = model.predefinedValues, analyticsCategoryName = model.analyticCategoryName, - currentRoute = model.currentRoute, txUrl = txUrl, 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() { val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty val isEmptyStack = childStack.value.backStack.isEmpty() 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 isPopSend = isEmptyRoute || isEmptyStack || isSuccess || isStubComponent diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt index 6e369f2e78..d13114d87c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt @@ -1,15 +1,26 @@ 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.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import arrow.core.Either 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.child 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.models.account.Account 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.features.send.api.analytics.CommonSendAnalyticEvents 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.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.send.common.CommonSendRoute 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.ui.SendConfirmContent import com.tangem.features.send.send.ui.state.SendUM 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.notifications.DefaultSendNotificationsComponent import com.tangem.utils.extensions.orZero @@ -38,7 +49,7 @@ internal class SendConfirmComponent( appComponentContext: AppComponentContext, params: Params, feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SendConfirmModel = getOrCreateModel(params = params) @@ -133,6 +144,20 @@ internal class SendConfirmComponent( 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 override fun Content(modifier: Modifier) { 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( val state: SendUM, val analyticsCategoryName: String, @@ -161,7 +209,6 @@ internal class SendConfirmComponent( val isAccountModeFlow: StateFlow, val appCurrency: AppCurrency, val callback: ModelCallback, - val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, val predefinedValues: PredefinedValues, val onLoadFee: suspend () -> Either, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt index 48dabef006..d30b1aaa76 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt @@ -10,7 +10,6 @@ import com.tangem.common.routing.AppRouter 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 @@ -160,7 +159,6 @@ internal class SendConfirmModel @Inject constructor( init { updateAmountSubtractAvailability() - configConfirmNavigation() subscribeOnNotificationsUpdateTrigger() subscribeOnCheckFeeResultUpdates() 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() { val confirmUM = uiState.value.confirmUM @@ -543,58 +552,7 @@ internal class SendConfirmModel @Inject constructor( return isHighNetworkFeeUseCase(feeCurrency, feeAmount) } - @Suppress("LongMethod") - 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 + fun primaryButtonUM(confirmUM: ConfirmUM): NavigationButton { val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isSending val isHoldToConfirm = userWallet.isHotWallet && isContent diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt index 643b2cfae1..29bbb27bd4 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt @@ -7,13 +7,13 @@ import arrow.core.left import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee 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.models.AnalyticsParam import com.tangem.core.analytics.models.Basic 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.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseBigDecimalOrNull @@ -123,8 +123,6 @@ internal class SendModel @Inject constructor( CommonSendRoute.Empty } - val currentRoute = MutableStateFlow(initialRoute) - val cryptoCurrencyStatusFlow: StateFlow field = MutableStateFlow( CryptoCurrencyStatus( @@ -149,7 +147,7 @@ internal class SendModel @Inject constructor( return cryptoCurrencyStatus.isAvailableForSend() && feeCryptoCurrencyStatus.isAvailableForSend() } - val isUnavailableForSend: Boolean + private val isUnavailableForSend: Boolean get() { val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value val feeCryptoCurrencyStatus = feeCryptoCurrencyStatusFlow.value @@ -179,10 +177,6 @@ internal class SendModel @Inject constructor( initAppCurrency() } - override fun onNavigationResult(navigationUM: NavigationUM) { - uiState.update { it.copy(navigationUM = navigationUM) } - } - override fun onDestinationResult(destinationUM: DestinationUM) { uiState.update { it.copy(destinationUM = destinationUM) } } @@ -196,9 +190,9 @@ internal class SendModel @Inject constructor( uiState.update { sendUM } } - override fun onBackClick() { - when (val route = currentRoute.value) { - is CommonSendRoute.Amount -> if (!route.isEditMode) { + override fun onBackClick(currentRoute: Route) { + when (currentRoute) { + is CommonSendRoute.Amount -> if (!currentRoute.isEditMode) { analyticsEventHandler.send( CommonSendAnalyticEvents.CloseButtonClicked( 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( CommonSendAnalyticEvents.CloseButtonClicked( categoryName = analyticCategoryName, @@ -224,11 +218,11 @@ internal class SendModel @Inject constructor( router.pop() } - override fun onNextClick() { - if (currentRoute.value.isEditMode) { - onBackClick() + override fun onNextClick(currentRoute: Route) { + if ((currentRoute as? CommonSendRoute)?.isEditMode == true) { + onBackClick(currentRoute) } else { - when (currentRoute.value) { + when (currentRoute) { is CommonSendRoute.Amount -> { val nextRoute = if (predefinedValues.isFromMainScreenQr) { CommonSendRoute.Confirm @@ -239,7 +233,7 @@ internal class SendModel @Inject constructor( } is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm) CommonSendRoute.Confirm -> router.push(CommonSendRoute.ConfirmSuccess) - else -> onBackClick() + else -> router.pop() } } } @@ -263,7 +257,6 @@ internal class SendModel @Inject constructor( feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, confirmData = null, - navigationUM = NavigationUM.Empty, ) } router.popTo(CommonSendRoute.Amount(isEditMode = false)) @@ -442,7 +435,7 @@ internal class SendModel @Inject constructor( cryptoCurrencyStatusFlow, feeCryptoCurrencyStatusFlow, ) { cryptoCurrencyStatus, _ -> - if (!isAvailableForSend || currentRoute.value != initialRoute) { + if (!isAvailableForSend) { if (isUnavailableForSend) showAlertError() return@combine } @@ -548,7 +541,6 @@ internal class SendModel @Inject constructor( cryptoCurrency = cryptoCurrency, ).transform(DestinationUM.Empty()), confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, confirmData = null, feeSelectorUM = FeeSelectorUM.Loading, ) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt index 314345341d..6890d5f4fd 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt @@ -1,27 +1,77 @@ 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.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment 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.child 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.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.ui.SendConfirmSuccessContent 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 -internal class SendConfirmSuccessComponent( - appComponentContext: AppComponentContext, - params: Params, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +internal class SendConfirmSuccessComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: Params, + destinationBlockComponentFactory: SendDestinationBlockComponent.Factory, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { 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 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( val sendUMFlow: StateFlow, - val destinationBlockComponent: SendDestinationBlockComponent, + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, val analyticsCategoryName: String, - val currentRoute: Flow, + val predefinedValues: PredefinedValues, val txUrl: String, val callback: ModelCallback, ) @@ -44,4 +126,9 @@ internal class SendConfirmSuccessComponent( interface ModelCallback { fun onResult(sendUM: SendUM) } + + @AssistedFactory + interface Factory { + fun create(appComponentContext: AppComponentContext, params: Params): SendConfirmSuccessComponent + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt index f13a80be84..c7854d8fef 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt @@ -1,28 +1,17 @@ package com.tangem.features.send.send.success.model 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.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.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents 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.ui.state.SendUM -import com.tangem.features.send.impl.R 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 @Stable @@ -31,7 +20,6 @@ internal class SendConfirmSuccessModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, - private val appRouter: AppRouter, private val urlOpener: UrlOpener, private val shareManager: ShareManager, ) : Model() { @@ -39,73 +27,23 @@ internal class SendConfirmSuccessModel @Inject constructor( private val _uiState = params.sendUMFlow val uiState = _uiState - init { - configConfirmSuccessNavigation() + fun onBackClick() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = params.analyticsCategoryName, + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = true, + ), + ) } - private fun configConfirmSuccessNavigation() { - 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() { + fun onExploreClick() { analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName)) urlOpener.openUrl(params.txUrl) } - private fun onShareClick() { + fun onShareClick() { analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName)) shareManager.shareText(params.txUrl) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt index 598a87cf9d..edfa9bc05a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp 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.SpacerH import com.tangem.core.ui.components.transactions.TransactionDoneTitle @@ -63,14 +62,6 @@ internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent backgroundColor = TangemTheme.colors.background.tertiary, ) } - NavigationButtonsBlockV2( - navigationUM = sendUM.navigationUM, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), - ) } } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt index 0ebe64eceb..70c2599c4c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt @@ -1,9 +1,8 @@ package com.tangem.features.send.send.ui.state 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.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.send.confirm.model.ConfirmData @@ -12,6 +11,5 @@ internal data class SendUM( val destinationUM: DestinationUM, val feeSelectorUM: FeeSelectorUM, val confirmUM: ConfirmUM, - val navigationUM: NavigationUM, val confirmData: ConfirmData?, ) \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt new file mode 100644 index 0000000000..1b070f7492 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt @@ -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()) } + 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 would break getOrElse, so stub a Right. + every { listenToQrScanningUseCase(any()) } returns emptyFlow().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, + ) + } +} \ No newline at end of file From c96e72fac40cab2e9b453c99f01460ba5809a8c7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 12:00:27 +0500 Subject: [PATCH 3/6] Updated on 2026-08-14 --- .../send/sendnft/DefaultNFTSendComponent.kt | 163 +++++++++--------- .../confirm/NFTSendConfirmComponent.kt | 62 ++++++- .../confirm/model/NFTSendConfirmModel.kt | 66 ++----- .../send/sendnft/model/NFTSendModel.kt | 25 +-- .../success/NFTSendSuccessComponent.kt | 66 ++++++- .../success/model/NFTSendSuccessModel.kt | 80 ++------- .../success/ui/NFTSendSuccessContent.kt | 41 ++--- .../send/sendnft/ui/state/NFTSendUM.kt | 4 +- .../model/NFTSendModelNavigationTest.kt | 137 +++++++++++++++ 9 files changed, 380 insertions(+), 264 deletions(-) create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt index e06f11e545..05b948fa64 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt @@ -4,7 +4,6 @@ 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 @@ -16,14 +15,15 @@ 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.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.send.api.NFTSendComponent 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.SendDestinationComponentParams 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.impl.R 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.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch internal class DefaultNFTSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: NFTSendComponent.Params, + private val destinationComponentFactory: SendDestinationComponent.Factory, private val nftSendConfirmComponentFactory: NFTSendConfirmComponent.Factory, private val nftSendSuccessComponentFactory: NFTSendSuccessComponent.Factory, private val analyticsEventHandler: AnalyticsEventHandler, - private val sendDestinationComponentFactory: SendDestinationComponent.Factory, ) : NFTSendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -77,37 +75,35 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( lifecycle = lifecycle, mode = ObserveLifecycleMode.CREATE_DESTROY, ) { stack -> - componentScope.launch { - when (val activeComponent = stack.active.instance) { - is NFTSendConfirmComponent -> { - val fromCurrency = model.cryptoCurrency - val fromDerivationIndex = model.account?.derivationIndex?.value - .takeIf { model.isAccountsMode } - analyticsEventHandler.send( - CommonSendAnalyticEvents.ConfirmationScreenOpened( - categoryName = analyticsCategoryName, - source = analyticsSendSource, - sendBlockchain = fromCurrency.network.name, - sendToken = fromCurrency.symbol, - fromDerivationIndex = fromDerivationIndex, - toDerivationIndex = null, - ), - ) - if (model.currentRouteFlow.value.isEditMode) { - activeComponent.updateState(model.uiState.value) - } - } - is SendDestinationComponent -> { - analyticsEventHandler.send( - CommonSendAnalyticEvents.AddressScreenOpened( - categoryName = analyticsCategoryName, - source = analyticsSendSource, - ), - ) - activeComponent.updateState(model.uiState.value.destinationUM) + when (val activeComponent = stack.active.instance) { + is NFTSendConfirmComponent -> { + val fromCurrency = model.cryptoCurrency + val fromDerivationIndex = model.account?.derivationIndex?.value + .takeIf { model.isAccountsMode } + analyticsEventHandler.send( + CommonSendAnalyticEvents.ConfirmationScreenOpened( + categoryName = analyticsCategoryName, + source = analyticsSendSource, + sendBlockchain = fromCurrency.network.name, + sendToken = fromCurrency.symbol, + fromDerivationIndex = fromDerivationIndex, + toDerivationIndex = null, + ), + ) + // Push current state into a reused Confirm on (re)entry. Confirm.isEditMode is `true` + if (stack.active.configuration.isEditMode) { + activeComponent.updateState(model.uiState.value) } } - 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 override fun Content(modifier: Modifier) { val stackState by childStack.subscribeAsState() - val state by model.uiState.collectAsStateWithLifecycle() - BackHandler(onBack = model::onBackClick) - SendContent( - navigationUM = state.navigationUM, - confirmUM = state.confirmUM, - stackState = stackState, - ) + BackHandler(onBack = ::onChildBack) + SendModularContent(stackState = stackState) } - private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { - is CommonSendRoute.Destination -> getDestinationComponent(factoryContext) + private fun createChild( + route: CommonSendRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent = when (route) { + is CommonSendRoute.Destination -> getDestinationComponent(route, factoryContext) CommonSendRoute.Confirm -> getConfirmComponent(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 = - sendDestinationComponentFactory.create( - context = factoryContext, - params = SendDestinationComponentParams.DestinationParams( - state = model.uiState.value.destinationUM, - currentRoute = model.currentRouteFlow.filterIsInstance(), - isBalanceHidingFlow = model.isBalanceHiddenFlow, - title = resourceReference(R.string.nft_send), - 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(), + private fun getDestinationComponent( + route: DestinationRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent = destinationComponentFactory.create( + context = factoryContext, + params = SendDestinationComponentParams.DestinationParams( + state = model.uiState.value.destinationUM, + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, - onLoadFee = model::loadFee, + title = resourceReference(R.string.nft_send), + analyticsCategoryName = analyticsCategoryName, analyticsSendSource = analyticsSendSource, - account = model.account, - isAccountsMode = model.isAccountsMode, - onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) }, + userWalletId = params.userWalletId, + cryptoCurrency = model.cryptoCurrency, + 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 if (txUrl == null) { model.showAlertError() - return getStubComponent() + return ComposableModularContentComponent.EMPTY } return nftSendSuccessComponentFactory.create( @@ -189,7 +189,6 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( nftAsset = params.nftAsset, nftCollectionName = params.nftCollectionName, callback = model, - currentRoute = model.currentRouteFlow.filterIsInstance(), txUrl = txUrl, account = model.account, isAccountsMode = model.isAccountsMode, @@ -197,8 +196,6 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( ) } - private fun getStubComponent() = ComposableContentComponent { } - private fun onChildBack() { val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty val isEmptyStack = childStack.value.backStack.isEmpty() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt index 1093c06d6c..297793f4e6 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt @@ -1,17 +1,28 @@ 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.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import arrow.core.Either 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.child 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.stringResourceSafe 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.models.account.Account 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.transaction.error.GetFeeError 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.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.FeeStateConfiguration -import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams -import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent 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.ui.NFTSendConfirmContent import com.tangem.features.send.sendnft.ui.state.NFTSendUM import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent -import com.tangem.features.send.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -45,7 +55,7 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( @Assisted params: Params, nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: NFTSendConfirmModel = getOrCreateModel(params = params) @@ -129,6 +139,20 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( 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 override fun Content(modifier: Modifier) { 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( val state: NFTSendUM, val analyticsCategoryName: String, @@ -157,7 +204,6 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( val account: Account.CryptoPortfolio?, val isAccountsMode: Boolean, val callback: ModelCallback, - val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, val onLoadFee: suspend () -> Either, val onSendTransaction: () -> Unit, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt index cec3676b65..98216a2934 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -5,7 +5,6 @@ import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionData import com.tangem.common.routing.AppRouter 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 @@ -32,28 +31,28 @@ import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase 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.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.feeSelector.FeeSelectorCheckReloadListener 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.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.SendNotificationsUpdateTrigger import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.SendBalanceUpdater import com.tangem.features.send.common.SendConfirmAlertFactory 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.confirm.NFTSendConfirmComponent 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.NFTSendConfirmationNotificationsTransformerV2 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.extensions.stripZeroPlainString import com.tangem.utils.logging.TangemLogger @@ -122,7 +121,6 @@ internal class NFTSendConfirmModel @Inject constructor( private var sendIdleTimer: Long = 0L init { - configConfirmNavigation() subscribeOnNotificationsUpdateTrigger() subscribeOnCheckFeeResultUpdates() subscribeOnTapHelpUpdates() @@ -371,54 +369,18 @@ internal class NFTSendConfirmModel @Inject constructor( } } - private fun configConfirmNavigation() { - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).onEach { (state, _) -> - val confirmUM = state.confirmUM - 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) + fun onBackClick() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = analyticsCategoryName, + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = uiState.value.confirmUM.isPrimaryButtonEnabled, + ), + ) } - private fun primaryButtonUM(): NavigationButton { + fun primaryButtonUM(): NavigationButton { val confirmUM = uiState.value.confirmUM val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isSending diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt index 46af42fff2..69094e6fb5 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt @@ -5,13 +5,13 @@ import arrow.core.Either import arrow.core.getOrElse import arrow.core.left 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.models.AnalyticsParam import com.tangem.core.analytics.models.Basic 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.datasource.local.nft.converter.NFTSdkAssetConverter 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.features.nft.entity.NFTSendSuccessTrigger 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.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.SendConfirmAlertFactory import com.tangem.features.send.common.ui.state.ConfirmUM @@ -82,8 +82,6 @@ internal class NFTSendModel @Inject constructor( val initialRoute = CommonSendRoute.Empty - val currentRouteFlow = MutableStateFlow(initialRoute) - private val userWalletId = params.userWalletId private val nftAsset = params.nftAsset @@ -107,10 +105,6 @@ internal class NFTSendModel @Inject constructor( initAppCurrency() } - override fun onNavigationResult(navigationUM: NavigationUM) { - uiState.update { it.copy(navigationUM = navigationUM) } - } - override fun onResult(nftSendUM: NFTSendUM) { uiState.value = nftSendUM } @@ -119,8 +113,8 @@ internal class NFTSendModel @Inject constructor( uiState.update { it.copy(destinationUM = destinationUM) } } - override fun onBackClick() { - if (currentRouteFlow.value == CommonSendRoute.ConfirmSuccess) { + override fun onBackClick(currentRoute: Route) { + if (currentRoute == CommonSendRoute.ConfirmSuccess) { modelScope.launch { nftSendSuccessTrigger.triggerSuccessNFTSend() } @@ -128,14 +122,14 @@ internal class NFTSendModel @Inject constructor( router.pop() } - override fun onNextClick() { - if (currentRouteFlow.value.isEditMode) { - onBackClick() + override fun onNextClick(currentRoute: Route) { + if ((currentRoute as? CommonSendRoute)?.isEditMode == true) { + onBackClick(currentRoute) } else { - when (currentRouteFlow.value) { + when (currentRoute) { is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm) CommonSendRoute.Confirm -> router.replaceAll(CommonSendRoute.ConfirmSuccess) - else -> onBackClick() + else -> onBackClick(currentRoute) } } } @@ -241,6 +235,5 @@ internal class NFTSendModel @Inject constructor( destinationUM = DestinationUM.Empty(), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, ) } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt index 2642e9da0d..2384aa7724 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt @@ -1,15 +1,27 @@ 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.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.context.child 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.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus 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.subcomponents.destination.SendDestinationBlockComponent 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.ui.NFTSendSuccessContent import com.tangem.features.send.sendnft.ui.state.NFTSendUM -import com.tangem.features.send.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow internal class NFTSendSuccessComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: Params, + @Assisted private val params: Params, nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: NFTSendSuccessModel = getOrCreateModel(params = params) @@ -69,6 +79,19 @@ internal class NFTSendSuccessComponent @AssistedInject constructor( 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 override fun Content(modifier: Modifier) { 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( val nftSendUMFlow: StateFlow, val analyticsCategoryName: String, val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, - val currentRoute: Flow, val cryptoCurrencyStatus: CryptoCurrencyStatus, val userWallet: UserWallet, val nftAsset: NFTAsset, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt index 5c9061f7f8..7ebfaa2317 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt @@ -1,27 +1,17 @@ package com.tangem.features.send.sendnft.success.model 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.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.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents 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.sendnft.success.NFTSendSuccessComponent -import com.tangem.features.send.impl.R 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 @Stable @@ -37,71 +27,23 @@ internal class NFTSendSuccessModel @Inject constructor( val uiState = params.nftSendUMFlow - init { - configConfirmSuccessNavigation() + fun onBackClick() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = params.analyticsCategoryName, + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = true, + ), + ) } - private fun configConfirmSuccessNavigation() { - 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() { + fun onExploreClick() { analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName)) urlOpener.openUrl(params.txUrl) } - private fun onShareClick() { + fun onShareClick() { analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName)) shareManager.shareText(params.txUrl) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt index aa4bd4ceff..8ca229c671 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.SpacerH 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.common.ui.FeeBlockSuccess 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.sendnft.ui.state.NFTSendUM import kotlinx.coroutines.delay @Composable @@ -52,30 +51,20 @@ internal fun NFTSendSuccessContent( label = "Animate success content", modifier = modifier, ) { - Column { - Box( - modifier = Modifier - .weight(1f) - .background(TangemTheme.colors.background.tertiary), - ) { - SuccessContent( - nftSendUM = nftSendUM, - nftDetailsBlockComponent = nftDetailsBlockComponent, - destinationBlockComponent = destinationBlockComponent, - modifier = Modifier.fillMaxHeight(), - ) - Fade( - modifier = Modifier.align(Alignment.BottomCenter), - backgroundColor = TangemTheme.colors.background.tertiary, - ) - } - NavigationButtonsBlockV2( - navigationUM = nftSendUM.navigationUM, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), + Box( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors.background.tertiary), + ) { + SuccessContent( + nftSendUM = nftSendUM, + nftDetailsBlockComponent = nftDetailsBlockComponent, + destinationBlockComponent = destinationBlockComponent, + modifier = Modifier.fillMaxHeight(), + ) + Fade( + modifier = Modifier.align(Alignment.BottomCenter), + backgroundColor = TangemTheme.colors.background.tertiary, ) } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt index 83701052e9..7dd0e5fcdb 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt @@ -1,13 +1,11 @@ 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.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.ui.state.ConfirmUM internal data class NFTSendUM( val destinationUM: DestinationUM, val feeSelectorUM: FeeSelectorUM, val confirmUM: ConfirmUM, - val navigationUM: NavigationUM, ) \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt new file mode 100644 index 0000000000..46eb6327b7 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt @@ -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(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, + ) + } +} \ No newline at end of file From e271cce581239100a59d02906f492ea169336dfb Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 12:01:06 +0500 Subject: [PATCH 4/6] Updated on 2026-08-14 --- features/swap-v2/impl/build.gradle.kts | 2 + .../v2/impl/amount/SwapAmountComponent.kt | 53 +++++- .../impl/amount/SwapAmountComponentParams.kt | 3 +- .../v2/impl/amount/model/SwapAmountModel.kt | 48 +----- .../DefaultSendWithSwapComponent.kt | 47 +++--- .../confirm/SendWithSwapConfirmComponent.kt | 150 ++++++++++++++++- .../confirm/model/SendWithSwapConfirmModel.kt | 77 ++++----- .../impl/sendviaswap/entity/SendWithSwapUM.kt | 4 +- .../sendviaswap/model/SendWithSwapModel.kt | 31 ++-- .../success/SendWithSwapSuccessComponent.kt | 66 +++++++- .../success/model/SendWithSwapSuccessModel.kt | 45 +----- .../success/ui/SendWithSwapSuccessContent.kt | 43 +---- .../sendviaswap/ui/SendWithSwapContent.kt | 152 +----------------- .../model/SendWithSwapModelNavigationTest.kt | 138 ++++++++++++++++ 14 files changed, 464 insertions(+), 395 deletions(-) create mode 100644 features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModelNavigationTest.kt 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 From ee18d4395427a0d6a46ce959ffea37364d8d88ac Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 15:06:28 +0500 Subject: [PATCH 5/6] Updated on 2026-08-14 --- .../features/send/send/SendModelTestBase.kt | 50 +++-- .../confirm/model/SendConfirmModelTest.kt | 73 ++++--- .../features/send/send/model/SendModelTest.kt | 31 ++- .../confirm/model/NFTSendConfirmModelTest.kt | 58 +++--- .../send/sendnft/model/NFTSendModelTest.kt | 41 ++-- .../amount/model/SendAmountModelTest.kt | 59 +++--- .../model/SendDestinationModelTest.kt | 181 ++++++++++++------ 7 files changed, 292 insertions(+), 201 deletions(-) diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt index 8658c5b023..317c4fdebb 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt @@ -1,16 +1,21 @@ package com.tangem.features.send.send import arrow.core.Either +import arrow.core.right import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee 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.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.ParamsContainer 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.GetFeePaidCryptoCurrencyStatusSyncUseCase 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.model.AppCurrency 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.wallet.UserWallet 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.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.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase 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.GetFeeForTokenUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.SendFeatureToggles import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents 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.FeeSelectorCheckReloadTrigger 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.SendNotificationsUpdateTrigger import com.tangem.features.send.common.SendBalanceUpdater 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.confirm.SendConfirmComponent 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 io.mockk.MockKAnnotations -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk +import com.tangem.features.send.send.ui.state.SendUM +import com.tangem.features.send.testDispatcherProvider +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -89,7 +84,8 @@ internal abstract class SendModelTestBase { protected val router: Router = mockk(relaxed = true) protected val appRouter: AppRouter = 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 listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true) protected val parseQrCodeUseCase: ParseQrCodeUseCase = mockk(relaxed = true) @@ -260,7 +256,6 @@ internal abstract class SendModelTestBase { destinationUM = DestinationUM.Empty(), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, confirmData = null, ), cryptoCurrencyStatus: CryptoCurrencyStatus = testCryptoCurrencyStatus, @@ -278,7 +273,6 @@ internal abstract class SendModelTestBase { isAccountModeFlow = kotlinx.coroutines.flow.MutableStateFlow(false), appCurrency = AppCurrency.Default, callback = mockk(relaxed = true), - currentRoute = kotlinx.coroutines.flow.flowOf(), isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false), predefinedValues = PredefinedValues.Empty, onLoadFee = { Either.Right(mockk(relaxed = true)) }, diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt index 2ee87f066a..370ac09ac5 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt @@ -7,36 +7,25 @@ import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee 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.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.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.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.ui.state.SendUM import com.tangem.test.core.ProvideTestModels -import io.mockk.coEvery -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 io.mockk.* import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach -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.api.* import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal @@ -73,10 +62,30 @@ internal class SendConfirmModelTest : SendModelTestBase() { // Assert if (model.expectedSendInitiated) { - coVerify(exactly = 1) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { + createTransferTransactionUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } } else { - coVerify(exactly = 0) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { + createTransferTransactionUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } } } @@ -108,9 +117,29 @@ internal class SendConfirmModelTest : SendModelTestBase() { // Assert if (model.expectedSendInitiated) { - coVerify(exactly = 1) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { + createTransferTransactionUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } } else { - coVerify(exactly = 0) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { + createTransferTransactionUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } } } @@ -161,7 +190,8 @@ internal class SendConfirmModelTest : SendModelTestBase() { fun `GIVEN successful send WHEN verifyAndSend THEN notify onSendTransaction`() = runTest { // Arrange val onSendTransaction = mockk<() -> Unit>(relaxed = true) - val callback = mockk(relaxed = true) + val callback = + mockk(relaxed = true) val resultFlow = MutableSharedFlow(extraBufferCapacity = 1) every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right() @@ -256,7 +286,6 @@ internal class SendConfirmModelTest : SendModelTestBase() { destinationUM = destination, feeSelectorUM = feeSelector, confirmUM = mockk(relaxed = true), - navigationUM = NavigationUM.Empty, confirmData = null, ) } diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt index 0615266589..e9b708db9e 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt @@ -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.common.CommonSendRoute import com.tangem.features.send.common.ui.state.ConfirmUM -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.features.send.send.SendModelTestBase import io.mockk.coEvery 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 { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false) model.predefinedValues = PredefinedValues.Content.QrCode( amount = "1.0", address = "addr123", @@ -42,7 +40,7 @@ internal class SendModelTest : SendModelTestBase() { ) // Act - model.onNextClick() + model.onNextClick(CommonSendRoute.Amount(isEditMode = false)) // Assert 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 { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false) model.predefinedValues = PredefinedValues.Empty // Act - model.onNextClick() + model.onNextClick(CommonSendRoute.Amount(isEditMode = false)) // Assert 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 { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Destination(isEditMode = false) // Act - model.onNextClick() + model.onNextClick(CommonSendRoute.Destination(isEditMode = false)) // Assert 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 { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Amount(isEditMode = true) // Act - model.onNextClick() + model.onNextClick(CommonSendRoute.Amount(isEditMode = true)) // Assert verify(exactly = 1) { router.pop(any()) } @@ -90,19 +85,18 @@ internal class SendModelTest : SendModelTestBase() { } @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 { // Arrange - // CommonSendRoute.Confirm.isEditMode == true, so onNextClick short-circuits to onBackClick(). + // CommonSendRoute.Confirm.isEditMode == false, so onNextClick pushes ConfirmSuccess. val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Confirm // Act - model.onNextClick() + model.onNextClick(CommonSendRoute.Confirm) // Assert - verify(exactly = 1) { router.pop(any()) } - verify(exactly = 0) { router.push(CommonSendRoute.ConfirmSuccess, any()) } + verify(exactly = 1) { 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 { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false) // Act - model.onBackClick() + model.onBackClick(CommonSendRoute.Amount(isEditMode = false)) // Assert verify(exactly = 1) { analyticsEventHandler.send(any()) } @@ -224,10 +217,9 @@ internal class SendModelTest : SendModelTestBase() { fun `GIVEN destination route edit WHEN onBackClick THEN pop without analytics`() = runTest { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Destination(isEditMode = true) // Act - model.onBackClick() + model.onBackClick(CommonSendRoute.Destination(isEditMode = true)) // Assert verify(exactly = 0) { analyticsEventHandler.send(any()) } @@ -251,7 +243,6 @@ internal class SendModelTest : SendModelTestBase() { assertThat(state.feeSelectorUM).isEqualTo(FeeSelectorUMRedesigned.Loading) assertThat(state.confirmUM).isEqualTo(ConfirmUM.Empty) assertThat(state.confirmData).isNull() - assertThat(state.navigationUM).isEqualTo(NavigationUM.Empty) verify(exactly = 1) { router.popTo(CommonSendRoute.Amount(isEditMode = false), any()) } } } diff --git a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt index 0d88f78693..e6e57ae776 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt @@ -6,9 +6,7 @@ import arrow.core.right import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.TransactionData 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.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer 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.features.nft.entity.NFTSendSuccessTrigger 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.feeSelector.FeeSelectorCheckReloadListener 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.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.SendNotificationsUpdateTrigger import com.tangem.features.send.common.SendBalanceUpdater import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.ui.state.ConfirmUM 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.confirm.NFTSendConfirmComponent import com.tangem.features.send.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.testDispatcherProvider import com.tangem.test.core.ProvideTestModels -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.mockkObject -import io.mockk.mockkStatic -import io.mockk.unmockkObject -import io.mockk.unmockkStatic -import io.mockk.verify +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf 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.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.* import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal +import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset @OptIn(ExperimentalCoroutinesApi::class) internal class NFTSendConfirmModelTest { @@ -166,10 +150,30 @@ internal class NFTSendConfirmModelTest { // Assert 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() } } 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() } } } @@ -303,7 +307,6 @@ internal class NFTSendConfirmModelTest { account = null, isAccountsMode = false, callback = mockk(relaxed = true), - currentRoute = flowOf(), isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false), onLoadFee = { mockk(relaxed = true).right() }, onSendTransaction = {}, @@ -328,7 +331,6 @@ internal class NFTSendConfirmModelTest { destinationUM = destination, feeSelectorUM = feeSelector, confirmUM = mockk(relaxed = true), - navigationUM = NavigationUM.Empty, ) } diff --git a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt index a70fcd7035..c3dda73985 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt @@ -102,31 +102,41 @@ internal class NFTSendModelTest { // Arrange val sut = buildModel() advanceUntilIdle() - sut.currentRouteFlow.value = model.route // Act - sut.onNextClick() + sut.onNextClick(model.route) advanceUntilIdle() // Assert - if (model.expectPushConfirm) { - verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } - verify(exactly = 0) { router.pop(any()) } - } else { - verify(exactly = 1) { router.pop(any()) } - verify(exactly = 0) { router.push(any(), any()) } - // Confirm.isEditMode == true, so the `Confirm -> replaceAll(ConfirmSuccess)` branch is unreachable - verify(exactly = 0) { router.replaceAll(CommonSendRoute.ConfirmSuccess, onComplete = any()) } + when (model.expectedAction) { + NextClickAction.PushConfirm -> { + verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } + verify(exactly = 0) { router.pop(any()) } + verify(exactly = 0) { router.replaceAll(*anyVararg(), onComplete = any()) } + } + NextClickAction.ReplaceAllConfirmSuccess -> { + // 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( - NextClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectPushConfirm = true), - NextClickModel(route = CommonSendRoute.Destination(isEditMode = true), expectPushConfirm = false), - NextClickModel(route = CommonSendRoute.Confirm, expectPushConfirm = false), + NextClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectedAction = NextClickAction.PushConfirm), + NextClickModel(route = CommonSendRoute.Destination(isEditMode = true), expectedAction = NextClickAction.Pop), + NextClickModel(route = CommonSendRoute.Confirm, expectedAction = NextClickAction.ReplaceAllConfirmSuccess), ) } + enum class NextClickAction { PushConfirm, ReplaceAllConfirmSuccess, Pop } + @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class OnBackClick { @@ -138,10 +148,9 @@ internal class NFTSendModelTest { // Arrange val sut = buildModel() advanceUntilIdle() - sut.currentRouteFlow.value = model.route // Act - sut.onBackClick() + sut.onBackClick(model.route) advanceUntilIdle() // 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) diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt index 19312d7771..a327f4b0a9 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt @@ -1,8 +1,11 @@ package com.tangem.features.send.subcomponents.amount.model 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.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.model.AppCurrency 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.wallets.usecase.GetUserWalletUseCase 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.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents 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.SendAmountComponentParams 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.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.google.common.truth.Truth.assertThat -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 io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -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.api.* import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal @@ -79,7 +67,14 @@ internal class SendAmountModelTest { fun setUp() { MockKAnnotations.init(this) // 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()) coEvery { getMinimumTransactionAmountSyncUseCase(any(), any()) } returns BigDecimal.ONE.right() coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() @@ -107,12 +102,7 @@ internal class SendAmountModelTest { PredefinedValues.Empty } // Start off an Amount route so the navigation combine stays idle until the wallet is loaded. - val currentRoute = MutableStateFlow(CommonSendRoute.Confirm) - 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) + val sut = buildModel(predefinedValues = predefined, route = CommonSendRoute.Amount(false)) advanceUntilIdle() // Assert @@ -138,7 +128,7 @@ internal class SendAmountModelTest { fun `WHEN onConvertToAnotherToken THEN reset-alert in edit mode else convert directly`(model: ConvertModel) = runTest { // Arrange - val sut = buildModel(currentRoute = MutableStateFlow(CommonSendRoute.Amount(isEditMode = model.isEditMode))) + val sut = buildModel(route = CommonSendRoute.Amount(isEditMode = model.isEditMode)) advanceUntilIdle() // Act @@ -248,7 +238,10 @@ internal class SendAmountModelTest { } 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), ) } @@ -257,7 +250,7 @@ internal class SendAmountModelTest { private fun TestScope.buildModel( predefinedValues: PredefinedValues = PredefinedValues.Empty, - currentRoute: MutableStateFlow = MutableStateFlow(CommonSendRoute.Amount(isEditMode = false)), + route: CommonSendRoute.Amount = CommonSendRoute.Amount(isEditMode = false), cryptoCurrencyStatusFlow: MutableStateFlow = MutableStateFlow(loadedStatus(cryptoCurrency, balance = BigDecimal.TEN)), state: AmountState = AmountState.Empty, @@ -275,7 +268,7 @@ internal class SendAmountModelTest { accountFlow = MutableStateFlow(null), isAccountModeFlow = MutableStateFlow(false), callback = callback, - currentRoute = currentRoute.filterIsInstance(), + route = route, ) return SendAmountModel( paramsContainer = MutableParamsContainer(params), diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt index e1017e7b10..2b7811ea97 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt @@ -2,6 +2,8 @@ package com.tangem.features.send.subcomponents.destination.model import arrow.core.left 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.decompose.model.MutableParamsContainer 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.usecase.GetBackupProblematicWalletForAddressUseCase 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.models.currency.CryptoCurrency 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.AddressValidationResult 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.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase 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.entity.PredefinedValues 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.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.SendDestinationAnalyticEvents import com.tangem.features.send.testDispatcherProvider -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 com.tangem.test.core.ProvideTestModels +import io.mockk.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -64,7 +60,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested 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`() = runTest { // Arrange - coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + coEvery { + validateWalletAddressUseCase( + any(), + any(), + any(), + any>(), + any() + ) + } returns AddressValidation.Success.Valid.right() val sut = buildModel() advanceUntilIdle() @@ -144,14 +147,22 @@ internal class SendDestinationModelTest { } verify(exactly = 0) { sendDestinationAlertFactory.showRecipientBackupErrorAlert(any()) } // 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 fun `GIVEN valid backup-problematic address WHEN address entered THEN show recipient backup error alert`() = runTest { // Arrange - coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + coEvery { + validateWalletAddressUseCase( + any(), + any(), + any(), + any>(), + any() + ) + } returns AddressValidation.Success.Valid.right() coEvery { getBackupProblematicWalletForAddressUseCase(any()) } returns testUserWalletId val sut = buildModel() @@ -172,7 +183,15 @@ internal class SendDestinationModelTest { @Test fun `GIVEN invalid address WHEN address entered THEN send invalid analytics`() = runTest { // Arrange - coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + coEvery { + validateWalletAddressUseCase( + any(), + any(), + any(), + any>(), + any() + ) + } returns AddressValidation.Error.InvalidAddress.left() val sut = buildModel() 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`() = runTest { // Arrange - coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + coEvery { + validateWalletAddressUseCase( + any(), + any(), + any(), + any>(), + any() + ) + } returns AddressValidation.Success.Valid.right() val sut = buildModel() advanceUntilIdle() @@ -206,7 +233,7 @@ internal class SendDestinationModelTest { verify(exactly = 0) { analyticsEventHandler.send(any()) } - verify(exactly = 0) { callback.onNextClick() } + verify(exactly = 0) { callback.onNextClick(CommonSendRoute.Destination(false)) } } } @@ -231,7 +258,7 @@ internal class SendDestinationModelTest { advanceUntilIdle() // Assert - verify(exactly = model.expectedNextClicks) { callback.onNextClick() } + verify(exactly = model.expectedNextClicks) { callback.onNextClick(CommonSendRoute.Destination(false)) } } private fun provideTestModels() = listOf( @@ -256,7 +283,15 @@ internal class SendDestinationModelTest { advanceUntilIdle() // Assert - coVerify(exactly = 0) { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } + coVerify(exactly = 0) { + validateWalletAddressUseCase( + any(), + any(), + any(), + any>(), + any() + ) + } } } @@ -286,25 +321,38 @@ internal class SendDestinationModelTest { } @Test - fun `GIVEN a contact is set WHEN route switches to edit mode THEN the contact is reset`() = runTest { - // Arrange - coEvery { - validateWalletAddressUseCase(any(), any(), any(), any>(), any()) - } returns AddressValidation.Success.Valid.right() - val currentRoute = MutableStateFlow(CommonSendRoute.Destination(isEditMode = false)) - val sut = buildModel(currentRoute = currentRoute) - advanceUntilIdle() - sut.applySelectedContact(selectedContact(name = "Dave", address = "0xDave")) - advanceUntilIdle() - assertThat(content(sut).addressTextField.contactName).isEqualTo("Dave") + fun `GIVEN a contact is pre-set in state AND route is edit mode WHEN model initializes THEN the contact is reset`() = + runTest { + // Arrange — build initial state with a contact name already set and isInitialized = true + // so the InitialStateTransformer does NOT overwrite it, leaving resetContactOnEdit() to clear it. + val stateWithContact = DestinationUM.Content( + isPrimaryButtonEnabled = false, + isInitialized = true, + addressTextField = DestinationTextFieldUM.RecipientAddress( + value = "0xDave", + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Text), + 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 - currentRoute.value = CommonSendRoute.Destination(isEditMode = true) - advanceUntilIdle() + // Act — init with isEditMode = true triggers resetContactOnEdit() + val sut = buildModel( + currentRoute = CommonSendRoute.Destination(isEditMode = true), + initialState = stateWithContact, + ) + advanceUntilIdle() - // Assert - assertThat(content(sut).addressTextField.contactName).isNull() - } + // Assert + assertThat(content(sut).addressTextField.contactName).isNull() + } } @Nested @@ -335,9 +383,19 @@ internal class SendDestinationModelTest { private fun provideTestModels() = listOf( // 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 - 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( // 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 - AddContactModel(isAddContactAvailable = true, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = true), + AddContactModel( + isAddContactAvailable = true, + savedAddresses = emptyList(), + enteredAddress = "0xFresh", + expectedShown = true + ), // 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 private fun TestScope.buildModel( - currentRoute: MutableStateFlow = - MutableStateFlow(CommonSendRoute.Destination(isEditMode = false)), + currentRoute: DestinationRoute = CommonSendRoute.Destination(isEditMode = false), + initialState: DestinationUM = DestinationUM.Empty(), ): SendDestinationModel { val params = SendDestinationComponentParams.DestinationParams( - state = DestinationUM.Empty(), + state = initialState, analyticsCategoryName = "test_send", analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, cryptoCurrency = cryptoCurrency, userWalletId = testUserWalletId, title = stringReference("Send to"), isBalanceHidingFlow = MutableStateFlow(false), - currentRoute = currentRoute, + route = currentRoute, callback = callback, isAllowSelfSend = false, ) From 97f2810845115b5328b361f8654f2bfa70ce78d9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 15:12:22 +0300 Subject: [PATCH 6/6] Updated on 2026-08-14 --- .../storybook/entity/StoryBookPage.kt | 18 ++ .../page/ds/DsComponentsListScreen.kt | 2 + .../storybook/page/ds/textstyle/Build.kt | 21 ++ .../page/ds/textstyle/TextStyleStory.kt | 192 ++++++++++++++++++ .../storybook/ui/StoryBookScreen.kt | 2 + 5 files changed, 235 insertions(+) create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/textstyle/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/textstyle/TextStyleStory.kt diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index c74dce4d36..e3bb4e61e4 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -388,6 +388,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( val variant: TangemBadge.Variant, val status: TangemBadge.Status, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index 8fb85b511b..88d7b8883f 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt @@ -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.search.tangemSearchStoryFactory 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 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 = "💫 TangemGlowRing", factory = tangemGlowRingStoryFactory), DsStoryItem(title = "📢 TangemMessageBanner", factory = tangemMessageBannerStoryFactory), + DsStoryItem(title = "🔤 Text styles", factory = textStyleStoryFactory), ) @Composable diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/textstyle/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/textstyle/Build.kt new file mode 100644 index 0000000000..1a8d4152ea --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/textstyle/Build.kt @@ -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.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::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/textstyle/TextStyleStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/textstyle/TextStyleStory.kt new file mode 100644 index 0000000000..0a66cadfc4 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/textstyle/TextStyleStory.kt @@ -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 +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index 79dd299759..721a0b4433 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -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.search.TangemSearchStory 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.headerrow.TangemHeaderRowStory 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 TangemTopNavigationStory -> TangemTopNavigationStory(state = storyState) is TangemMessageBannerStory -> TangemMessageBannerStory(state = storyState) + is TextStyleStory -> TextStyleStory(state = storyState) } } } \ No newline at end of file