diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt index 3833714e62..9f714127da 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt @@ -24,6 +24,7 @@ sealed class AmountState { * @param appCurrencyCode app currency code * @param isEditingDisabled indicated whether amount is editable * @param reduceAmountBy reduces amount to be sent by specified value + * @param isIgnoreReduce ignores reduce amount value */ data class Data( override val isPrimaryButtonEnabled: Boolean, @@ -37,6 +38,7 @@ sealed class AmountState { val appCurrencyCode: String, val isEditingDisabled: Boolean = false, val reduceAmountBy: BigDecimal = BigDecimal.ZERO, + val isIgnoreReduce: Boolean = false, ) : AmountState() data class Empty( diff --git a/features/send-v2/impl/build.gradle.kts b/features/send-v2/impl/build.gradle.kts index 245cf3a903..eff04c059b 100644 --- a/features/send-v2/impl/build.gradle.kts +++ b/features/send-v2/impl/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(deps.compose.material3) implementation(deps.decompose.ext.compose) implementation(deps.androidx.activity.compose) + implementation(deps.androidx.paging.runtime) /** Other dependencies */ implementation(deps.kotlin.immutable.collections) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt index 60d45d9aa5..8bdd0be817 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt @@ -2,6 +2,7 @@ package com.tangem.features.send.v2.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.send.confirm.model.SendConfirmModel import com.tangem.features.send.v2.send.model.SendModel import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel @@ -35,4 +36,9 @@ internal interface SendModelModule { @IntoMap @ClassKey(SendFeeModel::class) fun provideSendFeeModel(model: SendFeeModel): Model + + @Binds + @IntoMap + @ClassKey(SendConfirmModel::class) + fun provideSendConfirmModel(model: SendConfirmModel): Model } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt index 07d650a412..aca97719f1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt @@ -1,6 +1,9 @@ package com.tangem.features.send.v2.send.analytics import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE +import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM /** @@ -23,6 +26,18 @@ internal sealed class SendAnalyticEvents( /** Confirmation screen opened */ data object ConfirmationScreenOpened : SendAnalyticEvents(event = "Confirm Screen Opened") + /** Transaction send screen opened */ + data class TransactionScreenOpened( + val token: String, + val feeType: AnalyticsParam.FeeType, + ) : SendAnalyticEvents( + event = "Transaction Sent Screen Opened", + params = mapOf( + TOKEN_PARAM to token, + FEE_TYPE to feeType.value, + ), + ) + /** If transaction delays notification is present */ data class NoticeTransactionDelays( val token: String, @@ -37,7 +52,40 @@ internal sealed class SendAnalyticEvents( params = mapOf(TOKEN_PARAM to token), ) + /** Close button clicked */ + data class CloseButtonClicked( + val source: SendScreenSource, + val isFromSummary: Boolean, + val isValid: Boolean, + ) : SendAnalyticEvents( + event = "Button - Close", + params = mapOf( + SOURCE to source.name, + "FromSummary" to if (isFromSummary) "Yes" else "No", + "isValid" to if (isValid) "Yes" else "No", + ), + ) + + /** Share button clicked */ + data object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share") + + /** Expore button clicked */ + data object ExploreButtonClicked : SendAnalyticEvents(event = "Button - Explore") + + /** Screen reopened from confirmation screen */ + data class ScreenReopened(val source: SendScreenSource) : SendAnalyticEvents( + event = "Screen Reopened", + params = mapOf(SOURCE to source.name), + ) + companion object { const val SEND_CATEGORY = "Token / Send" } + + internal enum class SendScreenSource { + Address, + Amount, + Fee, + Confirm, + } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt new file mode 100644 index 0000000000..73f04ff63b --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt @@ -0,0 +1,53 @@ +package com.tangem.features.send.v2.send.analytics + +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.domain.tokens.model.CryptoCurrency +import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationTextFieldUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import javax.inject.Inject + +@ModelScoped +internal class SendAnalyticHelper @Inject constructor( + private val analyticsEventHandler: AnalyticsEventHandler, +) { + + fun sendSuccessAnalytics(cryptoCurrency: CryptoCurrency, sendUM: SendUM) { + val destinationUM = sendUM.destinationUM as? DestinationUM.Content + val feeUM = sendUM.feeUM as? FeeUM.Content + val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content ?: return + val feeType = feeSelectorUM.selectedType.toAnalyticType(feeSelectorUM) + analyticsEventHandler.send( + SendAnalyticEvents.TransactionScreenOpened( + token = cryptoCurrency.symbol, + feeType = feeType, + ), + ) + analyticsEventHandler.send( + Basic.TransactionSent( + sentFrom = AnalyticsParam.TxSentFrom.Send( + blockchain = cryptoCurrency.network.name, + token = cryptoCurrency.symbol, + feeType = feeType, + ), + memoType = getSendTransactionMemoType(destinationUM?.memoTextField), + ), + ) + } + + private fun getSendTransactionMemoType( + recipientMemo: DestinationTextFieldUM.RecipientMemo?, + ): Basic.TransactionSent.MemoType { + val memo = recipientMemo?.value + return when { + memo?.isBlank() == true -> Basic.TransactionSent.MemoType.Empty + memo?.isNotBlank() == true -> Basic.TransactionSent.MemoType.Full + else -> Basic.TransactionSent.MemoType.Null + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt new file mode 100644 index 0000000000..52c4fd8737 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -0,0 +1,160 @@ +package com.tangem.features.send.v2.send.confirm + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.send.confirm.model.SendConfirmModel +import com.tangem.features.send.v2.send.confirm.ui.SendConfirmContent +import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.features.send.v2.subcomponents.amount.SendAmountBlockComponent +import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams +import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import com.tangem.utils.extensions.orZero +import kotlinx.coroutines.flow.* + +internal class SendConfirmComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: SendConfirmModel = getOrCreateModel(params = params) + + private val blockClickEnableFlow = MutableStateFlow(false) + + // private val destinationBlockComponent = + // SendDestinationBlockComponent( + // appComponentContext = child("sendConfirmDestinationBlock"), + // params = SendDestinationComponentParams.DestinationBlockParams( + // state = model.uiState.value.destinationUM, + // analyticsCategoryName = params.analyticsCategoryName, + // userWallet = params.userWallet, + // cryptoCurrency = params.cryptoCurrencyStatus.currency, + // blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + // isPredefinedValues = params.predefinedValues is Params.PredefinedValues.Content, + // predefinedAddressValue = (params.predefinedValues as? Params.PredefinedValues.Content)?.address, + // predefinedMemoValue = (params.predefinedValues as? Params.PredefinedValues.Content)?.tag, + // ), + // onResult = model::onDestinationResult, + // onClick = model::showEditDestination, + // ) + + private val amountBlockComponent = SendAmountBlockComponent( + appComponentContext = child("sendConfirmAmountBlock"), + params = SendAmountComponentParams.AmountBlockParams( + state = model.uiState.value.amountUM, + analyticsCategoryName = params.analyticsCategoryName, + userWallet = params.userWallet, + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + appCurrency = params.appCurrency, + blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + blockEditDisabledFlow = TODO(), + // isPredefinedValues = params.predefinedValues is Params.PredefinedValues.Content, + // predefinedAmountValue = (params.predefinedValues as? Params.PredefinedValues.Content)?.amount, + ), + onResult = model::onAmountResult, + onClick = model::showEditAmount, + ) + + private val feeBlockComponent = SendFeeBlockComponent( + appComponentContext = child("sendConfirmFeeBlock"), + params = SendFeeComponentParams.FeeBlockParams( + state = model.uiState.value.feeUM, + analyticsCategoryName = params.analyticsCategoryName, + userWallet = params.userWallet, + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + appCurrency = params.appCurrency, + sendAmount = model.enteredAmount.orZero(), + destinationAddress = model.enteredDestination.orEmpty(), + blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + ), + onResult = model::onFeeResult, + onClick = model::showEditFee, + ) + + private val notificationsComponent = NotificationsComponent( + appComponentContext = child("sendConfirmNotifications"), + params = NotificationsComponent.Params( + analyticsCategoryName = params.analyticsCategoryName, + userWalletId = params.userWallet.walletId, + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + appCurrency = params.appCurrency, + destinationAddress = model.enteredDestination.orEmpty(), + amountValue = model.enteredAmount.orZero(), + reduceAmountBy = model.reduceAmountBy.orZero(), + isIgnoreReduce = model.isIgnoreReduce, + fee = model.fee, + feeError = model.feeError, + ), + ) + + init { + model.uiState.onEach { state -> + val confirmUM = state.confirmUM as? ConfirmUM.Content + blockClickEnableFlow.value = confirmUM?.isSending == false + }.launchIn(componentScope) + } + + fun updateState(state: SendUM) { + // todo + // destinationBlockComponent.updateState(state.destinationUM) + amountBlockComponent.updateState(state.amountUM) + feeBlockComponent.updateState(state.feeUM) + model.updateState(state) + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val notificationState by notificationsComponent.state.collectAsStateWithLifecycle() + + SendConfirmContent( + sendUM = state, + // destinationBlockComponent = destinationBlockComponent, + amountBlockComponent = amountBlockComponent, + feeBlockComponent = feeBlockComponent, + notificationsComponent = notificationsComponent, + notificationsUM = notificationState, + ) + } + + data class Params( + val state: SendUM, + val analyticsCategoryName: String, + val userWallet: UserWallet, + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + val appCurrency: AppCurrency, + val callback: ModelCallback, + val currentRoute: Flow, + val isBalanceHidingFlow: StateFlow, + val predefinedValues: PredefinedValues, + ) { + sealed class PredefinedValues { + data object Empty : PredefinedValues() + data class Content( + val transactionId: String, + val amount: String, + val address: String, + val tag: String?, + ) : PredefinedValues() + } + } + + interface ModelCallback { + fun onResult(sendUM: SendUM) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmAlertFactory.kt new file mode 100644 index 0000000000..7eb09a280d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmAlertFactory.kt @@ -0,0 +1,63 @@ +package com.tangem.features.send.v2.send.confirm.model + +import com.tangem.common.ui.alerts.TransactionErrorAlertConverter +import com.tangem.common.ui.alerts.models.AlertDemoModeUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.features.send.v2.impl.R +import javax.inject.Inject + +@ModelScoped +internal class SendConfirmAlertFactory @Inject constructor( + private val messageSender: UiMessageSender, +) { + + fun getGenericErrorState(onFailedTxEmailClick: () -> Unit) { + messageSender.send( + DialogMessage( + title = resourceReference(id = R.string.send_alert_transaction_failed_title), + message = resourceReference(id = R.string.common_unknown_error), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_support), + onClick = onFailedTxEmailClick, + ), + ), + ) + } + + fun getSendTransactionErrorState( + error: SendTransactionError?, + popBack: () -> Unit, + onFailedTxEmailClick: (String) -> Unit, + ) { + val transactionErrorAlertConverter = TransactionErrorAlertConverter( + popBackStack = popBack, + onFailedTxEmailClick = onFailedTxEmailClick, + ) + + val errorAlert = error?.let { transactionErrorAlertConverter.convert(error) } ?: return + val onConfirmClick = errorAlert.onConfirmClick ?: return + + messageSender.send( + DialogMessage( + title = errorAlert.title, + message = errorAlert.message, + firstActionBuilder = { + EventMessageAction( + title = errorAlert.confirmButtonText, + onClick = onConfirmClick, + ) + }, + secondActionBuilder = if (errorAlert !is AlertDemoModeUM) { + { cancelAction() } + } else { + null + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmClickIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmClickIntents.kt new file mode 100644 index 0000000000..929fae6819 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmClickIntents.kt @@ -0,0 +1,18 @@ +package com.tangem.features.send.v2.send.confirm.model + +internal interface SendConfirmClickIntents { + + fun showEditDestination() + + fun showEditAmount() + + fun showEditFee() + + fun onSendClick() + + fun onExploreClick() + + fun onShareClick() + + fun onFailedTxEmailClick(errorMessage: String) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt new file mode 100644 index 0000000000..8dd8330471 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -0,0 +1,554 @@ +package com.tangem.features.send.v2.send.confirm.model + +import android.os.SystemClock +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.blockchain.common.AmountType +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.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.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.BlockchainErrorInfo +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase +import com.tangem.domain.settings.NeverShowTapHelpUseCase +import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import com.tangem.domain.tokens.FetchPendingTransactionsUseCase +import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase +import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.usecase.CreateTransactionUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource +import com.tangem.features.send.v2.send.analytics.SendAnalyticHelper +import com.tangem.features.send.v2.send.confirm.SendConfirmComponent +import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmInitialStateTransformer +import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSendingStateTransformer +import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSentStateTransformer +import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmationNotificationsTransformer +import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger +import com.tangem.features.send.v2.subcomponents.fee.model.checkAndCalculateSubtractedAmount +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData +import com.tangem.features.txhistory.TxHistoryFeatureToggles +import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.DelayedWork +import com.tangem.utils.extensions.orZero +import com.tangem.utils.extensions.stripZeroPlainString +import com.tangem.utils.transformer.update +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import timber.log.Timber +import java.math.BigDecimal +import javax.inject.Inject + +@Suppress("LongParameterList", "LargeClass") +@Stable +@ModelScoped +internal class SendConfirmModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val appRouter: AppRouter, + private val router: Router, + private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase, + private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase, + private val createTransactionUseCase: CreateTransactionUseCase, + private val sendTransactionUseCase: SendTransactionUseCase, + private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, + private val getCardInfoUseCase: GetCardInfoUseCase, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase, + private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, + private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, + private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, + private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger, + private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, + private val notificationsUpdateTrigger: NotificationsUpdateTrigger, + private val alertFactory: SendConfirmAlertFactory, + private val sendAnalyticHelper: SendAnalyticHelper, + private val txHistoryFeatureToggles: TxHistoryFeatureToggles, + @DelayedWork private val coroutineScope: CoroutineScope, + private val urlOpener: UrlOpener, + private val shareManager: ShareManager, +) : Model(), SendConfirmClickIntents { + + private val params: SendConfirmComponent.Params = paramsContainer.require() + + private val userWallet = params.userWallet + private val appCurrency = params.appCurrency + private val cryptoCurrencyStatus = params.cryptoCurrencyStatus + private val cryptoCurrency = cryptoCurrencyStatus.currency + + private val _uiState = MutableStateFlow(params.state) + val uiState = _uiState.asStateFlow() + + private val amountState + get() = uiState.value.amountUM as? AmountState.Data + private val destinationUM + get() = uiState.value.destinationUM as? DestinationUM.Content + private val feeUM + get() = uiState.value.feeUM as? FeeUM.Content + private val feeSelectorUM + get() = feeUM?.feeSelectorUM as? FeeSelectorUM.Content + + val enteredAmount: BigDecimal? + get() = amountState?.amountTextField?.cryptoAmount?.value + val reduceAmountBy: BigDecimal + get() = amountState?.reduceAmountBy.orZero() + val isIgnoreReduce: Boolean + get() = amountState?.isIgnoreReduce == true + val enteredDestination: String? + get() = destinationUM?.addressTextField?.value + val fee: Fee? + get() = feeSelectorUM?.selectedFee + val feeError: GetFeeError? + get() = (feeUM?.feeSelectorUM as? FeeSelectorUM.Error)?.error + + private var sendIdleTimer: Long = 0L + private var isAmountSubtractAvailable = false + + init { + modelScope.launch { + isAmountSubtractAvailable = + isAmountSubtractAvailableUseCase(userWallet.walletId, cryptoCurrency).getOrElse { false } + } + configConfirmNavigation() + subscribeOnNotificationsUpdateTrigger() + subscribeOnCheckFeeResultUpdates() + initialState() + } + + fun updateState(state: SendUM) { + _uiState.value = state + updateConfirmNotifications() + } + + fun onFeeResult(feeUM: FeeUM) { + sendIdleTimer = SystemClock.elapsedRealtime() + _uiState.update { it.copy(feeUM = feeUM) } + updateConfirmNotifications() + } + + fun onAmountResult(amountUM: AmountState) { + _uiState.update { it.copy(amountUM = amountUM) } + updateConfirmNotifications() + } + + fun onDestinationResult(destinationUM: DestinationUM) { + _uiState.update { it.copy(destinationUM = destinationUM) } + updateConfirmNotifications() + } + + override fun showEditDestination() { + modelScope.launch { + neverShowTapHelpUseCase() + _uiState.update { + val confirmUM = it.confirmUM as? ConfirmUM.Content + it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) + } + analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address)) + router.push(SendRoute.Destination(isEditMode = true)) + } + } + + override fun showEditAmount() { + modelScope.launch { + neverShowTapHelpUseCase() + _uiState.update { + val confirmUM = it.confirmUM as? ConfirmUM.Content + it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) + } + analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount)) + router.push(SendRoute.Amount(isEditMode = true)) + } + } + + override fun showEditFee() { + modelScope.launch { + neverShowTapHelpUseCase() + _uiState.update { + val confirmUM = it.confirmUM as? ConfirmUM.Content + it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) + } + analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee)) + router.push(SendRoute.Fee()) + } + } + + override fun onSendClick() { + _uiState.update(SendConfirmSendingStateTransformer(isSending = true)) + if (SystemClock.elapsedRealtime() - sendIdleTimer < CHECK_FEE_UPDATE_DELAY) { + verifyAndSendTransaction() + } else { + modelScope.launch { + sendFeeCheckReloadTrigger.triggerCheckUpdate() + } + } + } + + override fun onExploreClick() { + val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success ?: return + analyticsEventHandler.send(SendAnalyticEvents.ExploreButtonClicked) + urlOpener.openUrl(confirmUM.txUrl) + } + + override fun onShareClick() { + val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success ?: return + analyticsEventHandler.send(SendAnalyticEvents.ShareButtonClicked) + shareManager.shareText(confirmUM.txUrl) + } + + override fun onFailedTxEmailClick(errorMessage: String) { + val amountValue = amountState?.amountTextField?.cryptoAmount?.value + val feeValue = fee?.amount?.value + + val receivingAmount = if (amountValue != null && feeValue != null) { + checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = enteredAmount.orZero(), + feeValue = feeValue, + reduceAmountBy = reduceAmountBy, + ) + } else { + null + } + + val amount = receivingAmount?.convertToSdkAmount(cryptoCurrency) + + saveBlockchainErrorUseCase( + error = BlockchainErrorInfo( + errorMessage = errorMessage, + blockchainId = cryptoCurrency.network.id.value, + derivationPath = cryptoCurrency.network.derivationPath.value, + destinationAddress = enteredDestination.orEmpty(), + tokenSymbol = if (amount?.type is AmountType.Token) { + amount.currencySymbol + } else { + "" + }, + amount = amount?.value?.stripZeroPlainString() ?: "unknown", + fee = feeValue?.convertToSdkAmount(cryptoCurrency) + ?.value?.stripZeroPlainString() ?: "unknown", + ), + ) + + val cardInfo = getCardInfoUseCase(userWallet.scanResponse).getOrNull() ?: return + + modelScope.launch { + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + } + } + + private fun initialState() { + val confirmUM = uiState.value.confirmUM + val amountUM = uiState.value.amountUM + val feeUM = uiState.value.feeUM + + modelScope.launch { + val isShowTapHelp = isSendTapHelpEnabledUseCase().getOrElse { false } + if (confirmUM is ConfirmUM.Empty || feeUM is FeeUM.Empty) { + _uiState.update { + it.copy( + confirmUM = SendConfirmInitialStateTransformer( + appCurrency = appCurrency, + feeUM = feeUM, + amountUM = amountUM, + isShowTapHelp = isShowTapHelp, + isSubtracted = false, + ).transform(uiState.value.confirmUM), + ) + } + updateConfirmNotifications() + } + } + } + + private fun subscribeOnNotificationsUpdateTrigger() { + notificationsUpdateTrigger.hasErrorFlow + .onEach { hasError -> + _uiState.update { + val feeUM = it.feeUM as? FeeUM.Content + val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content + it.copy( + confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy( + isPrimaryButtonEnabled = !hasError && feeSelectorUM != null, + ) ?: it.confirmUM, + ) + } + } + .launchIn(modelScope) + } + + private fun verifyAndSendTransaction() { + val amountValue = amountState?.amountTextField?.cryptoAmount?.value ?: return + val destination = destinationUM?.addressTextField?.value ?: return + val memo = destinationUM?.memoTextField?.value + val fee = feeSelectorUM?.selectedFee + val feeValue = fee?.amount?.value ?: return + + val receivingAmount = checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = amountValue, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy.orZero(), + ) + + modelScope.launch { + createTransactionUseCase( + amount = receivingAmount.convertToSdkAmount(cryptoCurrency), + fee = fee, + memo = memo, + destination = destination, + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ).fold( + ifLeft = { error -> + Timber.e(error) + _uiState.update(SendConfirmSendingStateTransformer(isSending = false)) + alertFactory.getGenericErrorState { + onFailedTxEmailClick(error.localizedMessage.orEmpty()) + } + }, + ifRight = { txData -> + sendTransaction(txData) + }, + ) + } + } + + private suspend fun sendTransaction(txData: TransactionData.Uncompiled) { + val result = sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = cryptoCurrency.network, + ) + + _uiState.update(SendConfirmSendingStateTransformer(isSending = false)) + + result.fold( + ifLeft = { error -> + alertFactory.getSendTransactionErrorState( + error = error, + popBack = appRouter::pop, + onFailedTxEmailClick = ::onFailedTxEmailClick, + ) + analyticsEventHandler.send(SendAnalyticEvents.TransactionError(cryptoCurrency.symbol)) + }, + ifRight = { + updateTransactionStatus(txData) + addTokenToWalletIfNeeded() + scheduleUpdates() + sendAnalyticHelper.sendSuccessAnalytics(cryptoCurrency, uiState.value) + }, + ) + } + + private fun addTokenToWalletIfNeeded() { + if (cryptoCurrency !is CryptoCurrency.Token) return + val wallets = destinationUM?.wallets ?: return + + val receivingUserWallet = wallets + .firstOrNull { it.address == enteredDestination } + ?: return + + val userWalletId = receivingUserWallet.userWalletId ?: return + val network = receivingUserWallet.network ?: return + + modelScope.launch { + addCryptoCurrenciesUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + network = network, + ) + } + } + + private suspend fun updateTransactionStatus(txData: TransactionData.Uncompiled) { + val txUrl = getExplorerTransactionUrlUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ).getOrElse { "" } + _uiState.update(SendConfirmSentStateTransformer(txData, txUrl)) + } + + private fun scheduleUpdates() { + coroutineScope.launch { + listOf( + // we should update network to find pending tx after 1 sec + async { + fetchPendingTransactionsUseCase(userWallet.walletId, setOf(cryptoCurrency.network)) + }, + // we should update tx history and network for new balance + async { + updateTxHistory() + }, + async { + updateDelayedCurrencyStatusUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + delayMillis = BALANCE_UPDATE_DELAY, + refresh = true, + ) + }, + ).awaitAll() + } + } + + private suspend fun updateTxHistory() { + delay(BALANCE_UPDATE_DELAY) + val txHistoryItemsCountEither = getTxHistoryItemsCountUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + ) + + txHistoryItemsCountEither.onRight { + if (txHistoryFeatureToggles.isFeatureEnabled) { + txHistoryContentUpdateEmitter.triggerUpdate() + } else { + getTxHistoryItemsUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + refresh = true, + ) + } + } + } + + private fun subscribeOnCheckFeeResultUpdates() { + sendFeeCheckReloadTrigger.checkReloadResultFlow.onEach { isFeeResultSuccess -> + if (isFeeResultSuccess) { + sendIdleTimer = SystemClock.elapsedRealtime() + _uiState.update(SendConfirmSendingStateTransformer(isSending = true)) + verifyAndSendTransaction() + } else { + _uiState.update(SendConfirmSendingStateTransformer(isSending = false)) + } + }.launchIn(modelScope) + } + + private fun updateConfirmNotifications() { + modelScope.launch { + notificationsUpdateTrigger.triggerUpdate( + data = NotificationData( + destinationAddress = enteredDestination.orEmpty(), + amountValue = enteredAmount.orZero(), + reduceAmountBy = reduceAmountBy.orZero(), + isIgnoreReduce = isIgnoreReduce, + fee = fee, + feeError = feeError, + ), + ) + } + _uiState.update { + it.copy( + confirmUM = SendConfirmationNotificationsTransformer( + feeUM = uiState.value.feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrencyStatus.currency, + ).transform(uiState.value.confirmUM), + ) + } + } + + fun configConfirmNavigation() { + combine( + flow = uiState, + flow2 = params.currentRoute, + transform = { state, route -> state to route }, + ).onEach { (state, _) -> + // todo + // val amountUM = state.amountUM as? AmountState.Data + // val confirmUM = state.confirmUM + // params.callback.onResult( + // state.copy( + // navigationUM = NavigationUM.Content( + // title = resourceReference( + // id = R.string.send_summary_title, + // formatArgs = wrappedList(params.cryptoCurrencyStatus.currency.name), + // ), + // subtitle = amountUM?.title, + // backIconRes = R.drawable.ic_close_24, + // backIconClick = { + // analyticsEventHandler.send( + // SendAnalyticEvents.CloseButtonClicked( + // source = SendScreenSource.Confirm, + // isFromSummary = true, + // isValid = confirmUM.isPrimaryButtonEnabled, + // ), + // ) + // appRouter.pop() + // }, + // primaryButton = ButtonsUM.PrimaryButtonUM( + // text = when (confirmUM) { + // is ConfirmUM.Success -> resourceReference(R.string.common_close) + // is ConfirmUM.Content -> if (confirmUM.isSending) { + // resourceReference(R.string.send_sending) + // } else { + // resourceReference(R.string.common_send) + // } + // else -> resourceReference(R.string.common_send) + // }, + // iconResId = R.drawable.ic_tangem_24.takeIf { confirmUM is ConfirmUM.Content }, + // isEnabled = confirmUM.isPrimaryButtonEnabled, + // isHapticClick = confirmUM is ConfirmUM.Content && !confirmUM.isSending, + // onClick = { + // when (confirmUM) { + // is ConfirmUM.Success -> appRouter.pop() + // is ConfirmUM.Content -> if (confirmUM.isSending) { + // return@PrimaryButtonUM + // } else { + // onSendClick() + // } + // else -> return@PrimaryButtonUM + // } + // }, + // ), + // prevButton = null, + // secondaryPairButtonsUM = ButtonsUM.SecondaryPairButtonsUM( + // leftText = resourceReference(R.string.common_explore), + // leftIconResId = R.drawable.ic_web_24, + // onLeftClick = ::onExploreClick, + // rightText = resourceReference(R.string.common_share), + // rightIconResId = R.drawable.ic_share_24, + // onRightClick = ::onShareClick, + // ).takeIf { confirmUM is ConfirmUM.Success }, + // ), + // ), + // ) + }.launchIn(modelScope) + } + + private companion object { + const val CHECK_FEE_UPDATE_DELAY = 10_000L + const val BALANCE_UPDATE_DELAY = 11_000L + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt new file mode 100644 index 0000000000..a3da5886b9 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt @@ -0,0 +1,137 @@ +package com.tangem.features.send.v2.send.confirm.model.transformers + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fee +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf + +internal class SendConfirmInitialStateTransformer( + private val appCurrency: AppCurrency, + private val feeUM: FeeUM, + private val amountUM: AmountState, + private val isShowTapHelp: Boolean, + private val isSubtracted: Boolean, +) : Transformer { + override fun transform(prevState: ConfirmUM): ConfirmUM { + return ConfirmUM.Content( + isSending = false, + showTapHelp = isShowTapHelp, + sendingFooter = getSendingFooterText(), + notifications = persistentListOf(), + ) + } + + private fun getSendingFooterText(): TextReference { + val feeUM = feeUM as? FeeUM.Content + val amountUM = amountUM as? AmountState.Data + + if (feeUM == null || amountUM == null) return TextReference.EMPTY + + val fee = (feeUM.feeSelectorUM as? FeeSelectorUM.Content)?.selectedFee + val fiatAmountValue = amountUM.amountTextField.fiatAmount.value + val fiatFeeValue = feeUM.rate?.let { fee?.amount?.value?.multiply(it) } + + val fiatSendingValue = if (isSubtracted) { + fiatAmountValue + } else { + if (feeUM.isFeeConvertibleToFiat) { + fiatFeeValue?.let { fiatAmountValue?.plus(it) } + } else { + fiatAmountValue + } + } + + val fiatSending = fiatSendingValue.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + val fiatFee = formatFiatFee( + amount = fee?.amount, + isFeeConvertibleToFiat = feeUM.isFeeConvertibleToFiat, + isFeeApproximate = feeUM.isFeeApproximate, + ) + + return if (feeUM.isTronToken && fee is Fee.Tron) { + getTokenFeeSendingText( + fee = fee, + fiatFee = fiatFee, + fiatSending = fiatSending, + ) + } else { + resourceReference( + id = if (feeUM.isFeeConvertibleToFiat) { + R.string.send_summary_transaction_description + } else { + R.string.send_summary_transaction_description_no_fiat_fee + }, + formatArgs = wrappedList(fiatSending, fiatFee), + ) + } + } + + private fun formatFiatFee(amount: Amount?, isFeeConvertibleToFiat: Boolean, isFeeApproximate: Boolean): String { + return if (isFeeConvertibleToFiat) { + amount?.value.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + } else { + amount?.value.format { + crypto( + decimals = amount?.decimals ?: 0, + symbol = amount?.currencySymbol.orEmpty(), + ).fee( + canBeLower = isFeeApproximate, + ) + } + } + } + + private fun getTokenFeeSendingText(fee: Fee.Tron, fiatFee: String, fiatSending: String): TextReference { + val suffix = when { + fee.remainingEnergy == 0L -> { + resourceReference( + R.string.send_summary_transaction_description_suffix_including, + wrappedList(fiatFee), + ) + } + fee.feeEnergy <= fee.remainingEnergy -> { + resourceReference( + R.string.send_summary_transaction_description_suffix_fee_covered, + wrappedList(fee.feeEnergy), + ) + } + else -> { + resourceReference( + R.string.send_summary_transaction_description_suffix_fee_reduced, + wrappedList(fee.remainingEnergy), + ) + } + } + val prefix = resourceReference( + R.string.send_summary_transaction_description_prefix, + wrappedList(fiatSending), + ) + + return combinedReference(prefix, COMMA_SEPARATOR, suffix) + } + + companion object { + private val COMMA_SEPARATOR = stringReference(", ") + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt new file mode 100644 index 0000000000..e632f7b86f --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.send.v2.send.confirm.model.transformers + +import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.utils.transformer.Transformer + +internal class SendConfirmSendingStateTransformer( + val isSending: Boolean, +) : Transformer { + override fun transform(prevState: SendUM): SendUM { + val confirmUM = prevState.confirmUM as? ConfirmUM.Content ?: return prevState + return prevState.copy( + confirmUM = confirmUM.copy( + isPrimaryButtonEnabled = !isSending, + isSending = isSending, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt new file mode 100644 index 0000000000..c08c566fc2 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt @@ -0,0 +1,20 @@ +package com.tangem.features.send.v2.send.confirm.model.transformers + +import com.tangem.blockchain.common.TransactionData +import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.utils.transformer.Transformer + +internal class SendConfirmSentStateTransformer( + val txData: TransactionData.Uncompiled, + val txUrl: String, +) : Transformer { + override fun transform(prevState: SendUM): SendUM { + return prevState.copy( + confirmUM = ConfirmUM.Success( + transactionDate = txData.date?.timeInMillis ?: System.currentTimeMillis(), + txUrl = txUrl, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt new file mode 100644 index 0000000000..0b7ea0f46f --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt @@ -0,0 +1,55 @@ +package com.tangem.features.send.v2.send.confirm.model.transformers + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents +import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.subcomponents.fee.model.checkIfFeeTooHigh +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class SendConfirmationNotificationsTransformer( + private val feeUM: FeeUM, + private val analyticsEventHandler: AnalyticsEventHandler, + private val cryptoCurrency: CryptoCurrency, +) : Transformer { + override fun transform(prevState: ConfirmUM): ConfirmUM { + val state = prevState as? ConfirmUM.Content ?: return prevState + val feeUM = feeUM as? FeeUM.Content ?: return prevState + return state.copy( + notifications = buildList { + addTooHighNotification(feeUM.feeSelectorUM) + addTooLowNotification(feeUM) + }.toPersistentList(), + ) + } + + private fun MutableList.addTooLowNotification(feeUM: FeeUM.Content) { + val feeSelectorUM = feeUM.feeSelectorUM as? FeeSelectorUM.Content ?: return + val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return + val minimumValue = multipleFees.minimum.amount.value ?: return + val customAmount = feeSelectorUM.customValues.firstOrNull() ?: return + val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals) + if (feeSelectorUM.selectedType == FeeType.Custom && minimumValue > customValue) { + add(NotificationUM.Warning.FeeTooLow) + analyticsEventHandler.send( + SendAnalyticEvents.NoticeTransactionDelays(cryptoCurrency.symbol), + ) + } + } + + private fun MutableList.addTooHighNotification(feeSelectorUM: FeeSelectorUM) { + if (feeSelectorUM !is FeeSelectorUM.Content) return + + val (isFeeTooHigh, diff) = checkIfFeeTooHigh(feeSelectorUM) + if (isFeeTooHigh) { + add(NotificationUM.Warning.TooHigh(diff)) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt new file mode 100644 index 0000000000..9b602a5c66 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt @@ -0,0 +1,194 @@ +package com.tangem.features.send.v2.send.confirm.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.Keyboard +import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.components.transactions.TransactionDoneTitle +import com.tangem.core.ui.extensions.resolveAnnotatedReference +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.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.features.send.v2.subcomponents.amount.SendAmountBlockComponent +import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent +import com.tangem.features.send.v2.subcomponents.notifications +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay + +private const val TAP_HELP_KEY = "TAP_HELP_KEY" +private const val BLOCKS_KEY = "BLOCKS_KEY" +private const val TAP_HELP_ANIMATION_DELAY = 500L + +@Suppress("LongParameterList") +@Composable +internal fun SendConfirmContent( + sendUM: SendUM, + // destinationBlockComponent: SendDestinationBlockComponent, + amountBlockComponent: SendAmountBlockComponent, + feeBlockComponent: SendFeeBlockComponent, + notificationsComponent: NotificationsComponent, + notificationsUM: ImmutableList, +) { + val confirmUM = sendUM.confirmUM as? ConfirmUM.Content + + Column { + LazyColumn( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) { + blocks( + uiState = sendUM, + // destinationBlockComponent = destinationBlockComponent, + amountBlockComponent = amountBlockComponent, + feeBlockComponent = feeBlockComponent, + ) + if (confirmUM != null) { + tapHelp(isDisplay = confirmUM.showTapHelp) + with(notificationsComponent) { + content( + state = notificationsUM, + isClickDisabled = confirmUM.isSending, + ) + } + notifications( + notifications = confirmUM.notifications, + isClickDisabled = confirmUM.isSending, + ) + } + } + SendingText(confirmUM = confirmUM) + } +} + +@Composable +private fun SendingText(confirmUM: ConfirmUM.Content?, modifier: Modifier = Modifier) { + var isVisibleProxy by remember { mutableStateOf(confirmUM != null) } + val keyboard by keyboardAsState() + + // the text should appear when the keyboard is closed + LaunchedEffect(confirmUM != null, keyboard) { + if (confirmUM != null && keyboard is Keyboard.Opened) { + return@LaunchedEffect + } + isVisibleProxy = confirmUM != null + } + + AnimatedVisibility( + visible = isVisibleProxy, + modifier = modifier, + enter = slideInVertically() + fadeIn(), + exit = fadeOut(tween(durationMillis = 300)), + label = "Animate show sending state text", + ) { + val wrappedConfirmUM = remember(this) { requireNotNull(confirmUM?.sendingFooter) } + Text( + text = wrappedConfirmUM.resolveAnnotatedReference(), + textAlign = TextAlign.Center, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) + } +} + +private fun LazyListScope.blocks( + uiState: SendUM, + // destinationBlockComponent: SendDestinationBlockComponent, + amountBlockComponent: SendAmountBlockComponent, + feeBlockComponent: SendFeeBlockComponent, +) { + item(key = BLOCKS_KEY) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AnimatedVisibility( + visible = uiState.confirmUM is ConfirmUM.Success, + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), + ) { + val wrappedConfirmUM = remember(this) { uiState.confirmUM as ConfirmUM.Success } + TransactionDoneTitle( + title = resourceReference(R.string.sent_transaction_sent_title), + subtitle = resourceReference( + R.string.send_date_format, + wrappedList( + wrappedConfirmUM.transactionDate.toTimeFormat(DateTimeFormatters.dateFormatter), + wrappedConfirmUM.transactionDate.toTimeFormat(), + ), + ), + modifier = Modifier.padding(vertical = 12.dp), + ) + } + // todo + // destinationBlockComponent.Content(modifier = Modifier) + amountBlockComponent.Content(modifier = Modifier) + feeBlockComponent.Content(modifier = Modifier) + } + } +} + +private fun LazyListScope.tapHelp(isDisplay: Boolean, modifier: Modifier = Modifier) { + item(key = TAP_HELP_KEY) { + var wrappedIsDisplay by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = isDisplay) { + delay(TAP_HELP_ANIMATION_DELAY) + wrappedIsDisplay = isDisplay + } + + if (wrappedIsDisplay) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .animateItem() + .padding(top = TangemTheme.dimens.spacing20), + ) { + val background = TangemTheme.colors.button.secondary + Icon( + painter = painterResource(id = R.drawable.ic_send_hint_shape_12), + tint = TangemTheme.colors.button.secondary, + contentDescription = null, + modifier = Modifier, + ) + Text( + text = stringResourceSafe(id = R.string.send_summary_tap_hint), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(background) + .padding( + horizontal = TangemTheme.dimens.spacing14, + vertical = TangemTheme.dimens.spacing12, + ), + ) + } + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/state/ConfirmUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/state/ConfirmUM.kt new file mode 100644 index 0000000000..ae6bc45add --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/state/ConfirmUM.kt @@ -0,0 +1,31 @@ +package com.tangem.features.send.v2.send.confirm.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class ConfirmUM { + + abstract val isPrimaryButtonEnabled: Boolean + + data class Content( + override val isPrimaryButtonEnabled: Boolean = false, + val isSending: Boolean, + val showTapHelp: Boolean, + val sendingFooter: TextReference, + val notifications: ImmutableList, + ) : ConfirmUM() + + data class Success( + val transactionDate: Long, + val txUrl: String, + ) : ConfirmUM() { + override val isPrimaryButtonEnabled: Boolean = true + } + + data object Empty : ConfirmUM() { + override val isPrimaryButtonEnabled: Boolean = false + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index b122c64efd..93df96b047 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -7,6 +7,8 @@ import com.tangem.core.decompose.model.Model import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.send.v2.send.confirm.SendConfirmComponent +import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent @@ -28,7 +30,8 @@ internal class SendModel @Inject constructor( ) : Model(), SendDestinationComponent.ModelCallback, SendAmountComponent.ModelCallback, - SendFeeComponent.ModelCallback { + SendFeeComponent.ModelCallback, + SendConfirmComponent.ModelCallback { private val _uiState = MutableStateFlow(initialState()) val uiState = _uiState.asStateFlow() @@ -39,6 +42,10 @@ internal class SendModel @Inject constructor( var appCurrency: AppCurrency = AppCurrency.Default var predefinedAmountValue: String? = null + override fun onResult(sendUM: SendUM) { + _uiState.value = sendUM + } + override fun onDestinationResult(destinationUM: DestinationUM) { _uiState.update { it.copy(destinationUM = destinationUM) } } @@ -55,5 +62,6 @@ internal class SendModel @Inject constructor( amountUM = AmountState.Empty(), destinationUM = DestinationUM.Empty(), feeUM = FeeUM.Empty(), + confirmUM = ConfirmUM.Empty, ) } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt index 49a4f918a7..13fc77a18f 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt @@ -1,6 +1,7 @@ package com.tangem.features.send.v2.send.ui.state import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM @@ -8,4 +9,5 @@ internal data class SendUM( val amountUM: AmountState, val destinationUM: DestinationUM, val feeUM: FeeUM, + val confirmUM: ConfirmUM, ) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt index b5a67be3a4..26c543ba8d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt @@ -25,6 +25,6 @@ internal class SendFeeComponent( } interface ModelCallback { - fun onFeeResult(state: FeeUM) + fun onFeeResult(feeUM: FeeUM) } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt index 84e193f53d..9ff6f21955 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -55,7 +55,7 @@ import javax.inject.Inject @Suppress("LongParameterList") @Stable @ModelScoped -class NotificationsModel @Inject constructor( +internal class NotificationsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val appRouter: AppRouter, diff --git a/features/send-v2/impl/src/main/res/drawable/ic_send_hint_shape_12.xml b/features/send-v2/impl/src/main/res/drawable/ic_send_hint_shape_12.xml new file mode 100644 index 0000000000..5a336de030 --- /dev/null +++ b/features/send-v2/impl/src/main/res/drawable/ic_send_hint_shape_12.xml @@ -0,0 +1,9 @@ + + +