Updated on 2026-08-14

This commit is contained in:
Tangem 2024-03-05 09:12:03 +00:00
commit 3f386274ec
286 changed files with 4852 additions and 2134 deletions

View file

@ -0,0 +1,6 @@
package com.tangem.features.managetokens.navigation
enum class ExpandableState {
EXPANDED,
COLLAPSED,
}

View file

@ -1,10 +1,12 @@
package com.tangem.features.managetokens.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.unit.Dp
interface ManageTokensUi {
@Suppress("TopLevelComposableFunctions")
@Composable
fun Content(onHeaderSizeChange: (Dp) -> Unit)
fun Content(onHeaderSizeChange: (Dp) -> Unit, state: State<ExpandableState>)
}

View file

@ -1,13 +1,22 @@
package com.tangem.managetokens.presentation.addcustomtoken.ui
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetDefaults
import androidx.compose.material3.ModalBottomSheetProperties
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@ -15,8 +24,18 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -29,6 +48,7 @@ import com.tangem.managetokens.presentation.addcustomtoken.router.AddCustomToken
import com.tangem.managetokens.presentation.addcustomtoken.router.AddCustomTokenRouter
import com.tangem.managetokens.presentation.addcustomtoken.viewmodels.AddCustomTokenViewModel
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -46,14 +66,14 @@ fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig) {
onDispose { viewModel.onDispose() }
}
// FIXME: handle back presses after updating material3 to 1.2.0
ModalBottomSheet(
ModalBottomSheetWithBackHandling(
onDismissRequest = config.onDismissRequest,
sheetState = sheetState,
containerColor = TangemTheme.colors.background.tertiary,
shape = TangemTheme.shapes.bottomSheetLarge,
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top),
dragHandle = { TangemBottomSheetDraggableHeader(color = TangemTheme.colors.background.tertiary) },
properties = ModalBottomSheetDefaults.properties(shouldDismissOnBackPress = false),
) {
Content(onDismissRequest = config.onDismissRequest, viewModel = viewModel)
}
@ -68,6 +88,68 @@ fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ModalBottomSheetWithBackHandling(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
containerColor: Color = BottomSheetDefaults.ContainerColor,
shape: Shape = BottomSheetDefaults.ExpandedShape,
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
sheetState: SheetState = rememberModalBottomSheetState(),
properties: ModalBottomSheetProperties = ModalBottomSheetDefaults.properties(),
content: @Composable ColumnScope.() -> Unit,
) {
val scope = rememberCoroutineScope()
BackHandler(enabled = sheetState.targetValue != SheetValue.Hidden) {
// Always catch back here, but only let it dismiss if shouldDismissOnBackPress.
// If not, it will have no effect.
if (properties.shouldDismissOnBackPress) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) {
onDismissRequest()
}
}
}
}
val requester = remember { FocusRequester() }
val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
ModalBottomSheet(
onDismissRequest = onDismissRequest,
containerColor = containerColor,
shape = shape,
windowInsets = windowInsets,
dragHandle = dragHandle,
sheetState = sheetState,
modifier = modifier
.focusRequester(requester)
.focusable()
.onPreviewKeyEvent {
if (it.key == Key.Back && it.type == KeyEventType.KeyUp && !it.nativeKeyEvent.isCanceled) {
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
return@onPreviewKeyEvent true
}
return@onPreviewKeyEvent false
},
properties = ModalBottomSheetDefaults.properties(
securePolicy = properties.securePolicy,
isFocusable = properties.isFocusable,
// Set false otherwise the onPreviewKeyEvent doesn't work at all.
// The functionality of shouldDismissOnBackPress is achieved by the BackHandler.
shouldDismissOnBackPress = false,
),
content = content,
)
LaunchedEffect(Unit) {
requester.requestFocus()
}
}
@SuppressLint("RestrictedApi")
@Composable
private fun Content(viewModel: AddCustomTokenViewModel, onDismissRequest: () -> Unit) {
@ -82,6 +164,10 @@ private fun Content(viewModel: AddCustomTokenViewModel, onDismissRequest: () ->
}
}
BackHandler(true) {
navController.popBackStack()
}
val router = remember(navController) { AddCustomTokenRouter(navController) }
viewModel.router = router

View file

@ -5,7 +5,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.getOrElse
@ -105,10 +104,6 @@ internal class AddCustomTokenViewModel @Inject constructor(
uiState = stateFactory.getInitialState()
}
override fun onDestroy(owner: LifecycleOwner) {
uiState = stateFactory.getInitialState()
}
private suspend fun selectSuitableWallet(suitableUserWallets: List<UserWallet>): UserWalletId? {
val selectedWallet = getSelectedWalletSyncUseCase().getOrNull()
val selectedWalletId = if (walletSupportsAddingTokens(selectedWallet) && suitableUserWallets.isNotEmpty()) {
@ -424,7 +419,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
selectedWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
): Boolean {
val currenciesList = getCurrenciesUseCase(selectedWallet.walletId).getOrElse { emptyList() }
val currenciesList = getCurrenciesUseCase.getSync(selectedWallet.walletId).getOrElse { emptyList() }
return when (cryptoCurrency) {
is CryptoCurrency.Coin -> {
currenciesList.any {

View file

@ -1,5 +1,6 @@
package com.tangem.managetokens.presentation.managetokens.state
import com.tangem.core.ui.components.marketprice.PriceChangeType
import kotlinx.collections.immutable.ImmutableList
internal sealed class QuotesState {
@ -10,8 +11,4 @@ internal sealed class QuotesState {
val changeType: PriceChangeType,
val chartData: ImmutableList<Float>,
) : QuotesState()
}
enum class PriceChangeType {
UP, DOWN
}

View file

@ -1,8 +1,9 @@
package com.tangem.managetokens.presentation.managetokens.state.factory
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.tokens.model.Quote
import com.tangem.managetokens.presentation.managetokens.state.PriceChangeType
import com.tangem.managetokens.presentation.managetokens.state.QuotesState
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
@ -16,19 +17,18 @@ internal class QuotesToQuotesStateConverter : Converter<Quote, QuotesState> {
priceChange = BigDecimalFormatter.formatPercent(
percent = priceChange.movePointLeft(2),
useAbsoluteValue = true,
maxFractionDigits = 1,
minFractionDigits = 1,
),
changeType = priceChange.getPriceChangeType(),
chartData = // TODO (in [REDACTED_TASK_KEY] when endpoint is ready)
when (priceChange.getPriceChangeType()) {
PriceChangeType.UP -> persistentListOf(0f, 5f, 10f, 30f)
PriceChangeType.DOWN -> persistentListOf(15f, 12f, 13f, 18f, 10f, 3f)
PriceChangeType.NEUTRAL -> persistentListOf(0f, 0f, 0f, 0f)
},
)
}
private fun BigDecimal.getPriceChangeType(): PriceChangeType {
return if (this >= BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN
return PriceChangeConverter.fromBigDecimal(value = this)
}
}

View file

@ -5,7 +5,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.managetokens.presentation.common.state.previewdata.ChooseWalletStatePreviewData
import com.tangem.managetokens.presentation.managetokens.state.AddCustomTokenButton
import com.tangem.managetokens.presentation.managetokens.state.*
import com.tangem.managetokens.presentation.managetokens.state.ManageTokensState
import com.tangem.managetokens.presentation.managetokens.state.SearchBarState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
@ -32,6 +32,7 @@ internal object ManageTokensStatePreviewData {
get() = listOf(
TokenItemStatePreviewData.loadedPriceDown,
TokenItemStatePreviewData.loadedPriceUp,
TokenItemStatePreviewData.loadedPriceNeutral,
)
private val searchState: SearchBarState

View file

@ -2,7 +2,11 @@ package com.tangem.managetokens.presentation.managetokens.state.previewdata
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import com.tangem.managetokens.presentation.managetokens.state.*
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.managetokens.presentation.managetokens.state.QuotesState
import com.tangem.managetokens.presentation.managetokens.state.TokenButtonType
import com.tangem.managetokens.presentation.managetokens.state.TokenIconState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import kotlinx.collections.immutable.persistentListOf
internal object TokenItemStatePreviewData {
@ -46,6 +50,24 @@ internal object TokenItemStatePreviewData {
chooseNetworkState = ChooseNetworkStatePreviewData.state,
)
val loadedPriceNeutral: TokenItemState
get() = TokenItemState.Loaded(
id = "BTC",
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
tokenId = "BTC",
currencySymbol = "BTC",
tokenIcon = tokenIconState,
quotes = QuotesState.Content(
priceChange = "0.00%",
changeType = PriceChangeType.NEUTRAL,
chartData = persistentListOf(10f, 2f, 5f, 3f, 4f, 8f, 9f, 7f, 10f),
),
rate = "31 285.72$",
availableAction = mutableStateOf(TokenButtonType.ADD),
onButtonClick = {},
chooseNetworkState = ChooseNetworkStatePreviewData.state,
)
private val tokenIconState: TokenIconState
get() = TokenIconState(
iconReference = null,

View file

@ -120,4 +120,14 @@ private fun Chart_Negative_Preview() {
persistentListOf(10f, 2f, 4f, 1f, 5f),
)
}
}
@Preview(widthDp = 150, heightDp = 150, showBackground = true)
@Composable
private fun Chart_Neutral_Preview() {
TangemTheme(isDark = true) {
PriceChangesChart(
persistentListOf(5f, 2f, 4f, 1f, 5f),
)
}
}

View file

@ -11,9 +11,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.managetokens.impl.R
import com.tangem.managetokens.presentation.managetokens.state.PriceChangeType
import com.tangem.managetokens.presentation.managetokens.state.QuotesState
@Composable
@ -48,11 +48,13 @@ private fun PriceChangeIcon(type: PriceChangeType?) {
id = when (animatedType) {
PriceChangeType.UP -> R.drawable.ic_arrow_up_8
PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8
PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8
},
),
tint = when (animatedType) {
PriceChangeType.UP -> TangemTheme.colors.icon.accent
PriceChangeType.DOWN -> TangemTheme.colors.icon.warning
PriceChangeType.NEUTRAL -> TangemTheme.colors.icon.inactive
},
contentDescription = null,
)
@ -69,6 +71,7 @@ private fun PriceChangeText(type: PriceChangeType?, text: String?) {
color = when (type) {
PriceChangeType.UP -> TangemTheme.colors.text.accent
PriceChangeType.DOWN -> TangemTheme.colors.text.warning
PriceChangeType.NEUTRAL -> TangemTheme.colors.text.disabled
null -> TangemTheme.colors.text.primary1
},
overflow = TextOverflow.Ellipsis,

View file

@ -180,7 +180,8 @@ private fun BaseSurface(modifier: Modifier = Modifier, content: @Composable () -
}
}
@Preview()
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun Preview_Tokens_LightTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) {
TangemTheme(isDark = false) {
@ -188,7 +189,7 @@ private fun Preview_Tokens_LightTheme(@PreviewParameter(TokenConfigProvider::cla
}
}
@Preview
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun Preview_Tokens_DarkTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) {
TangemTheme(isDark = true) {
@ -201,5 +202,7 @@ private class TokenConfigProvider : CollectionPreviewParameterProvider<TokenItem
TokenItemStatePreviewData.tokenLoading,
TokenItemStatePreviewData.loadedPriceDown,
TokenItemStatePreviewData.loadedPriceUp,
TokenItemStatePreviewData.loadedPriceNeutral,
),
)
)
// endregion Preview

View file

@ -1,8 +1,6 @@
package com.tangem.managetokens.presentation.managetokens.viewmodels
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
@ -21,6 +19,7 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.domain.wallets.usecase.SelectWalletUseCase
import com.tangem.features.managetokens.navigation.ExpandableState
import com.tangem.managetokens.presentation.common.analytics.ManageTokens
import com.tangem.managetokens.presentation.common.state.AlertState
import com.tangem.managetokens.presentation.common.state.Event
@ -33,11 +32,15 @@ import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import com.tangem.utils.coroutines.Debouncer.Companion.DEFAULT_WAIT_TIME_MS
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
import kotlin.collections.set
import kotlin.properties.Delegates
@ -62,8 +65,6 @@ internal class ManageTokensViewModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel(), ManageTokensClickIntents, ManageTokensUiEvents {
private val debouncer = Debouncer()
private val stateFactory = ManageTokensStateFactory(
currentStateProvider = Provider { uiState },
clickIntents = this,
@ -73,11 +74,19 @@ internal class ManageTokensViewModel @Inject constructor(
var uiState: ManageTokensState by mutableStateOf(stateFactory.getInitialState(flowOf(PagingData.from(emptyList()))))
private set
private var allAddedCurrencies: MutableList<CryptoCurrency> = mutableListOf()
private var expandableState: ExpandableState = ExpandableState.COLLAPSED
private var wallets: List<UserWallet> by Delegates.notNull()
private val currenciesListJobHolder: JobHolder = JobHolder()
private var addedCurrenciesByWallet: MutableMap<UserWallet, MutableList<CryptoCurrency>> = mutableMapOf()
private val debouncer = Debouncer()
private var allAddedCurrencies: MutableList<CryptoCurrency> = Collections.synchronizedList(
mutableListOf<CryptoCurrency>(),
)
private var wallets: CopyOnWriteArrayList<UserWallet> by Delegates.notNull()
private var addedCurrenciesByWallet: MutableMap<UserWallet, MutableList<CryptoCurrency>> = ConcurrentHashMap()
private var selectedWallet: UserWallet? = null
@ -114,34 +123,59 @@ internal class ManageTokensViewModel @Inject constructor(
getWalletsUseCase()
.distinctUntilChanged()
.collectLatest { userWallets ->
wallets = userWallets.filter { it.isMultiCurrency && !it.isLocked }
wallets.map { wallet ->
val currencies = getCurrenciesUseCase(wallet.walletId).fold(
ifLeft = { emptyList() },
ifRight = { it },
)
allAddedCurrencies += currencies
addedCurrenciesByWallet[wallet] = currencies.toMutableList()
}
withContext(dispatchers.main) {
uiState = uiState.copy(tokens = getInitialTokensList())
}
selectedWallet = getSelectedWalletSyncUseCase().fold(
ifLeft = { null },
ifRight = { if (!it.isMultiCurrency || it.isLocked) null else it },
)
if (selectedWallet == null && wallets.isNotEmpty()) {
selectWalletUseCase(wallets.first().walletId)
selectedWallet = wallets.first()
}
updateDerivationNotificationState()
withContext(dispatchers.main) {
uiState = stateFactory.updateChooseWalletState(wallets, userWallets, selectedWallet)
}
launch {
subscribeToCurrencies(userWallets)
}.saveIn(currenciesListJobHolder)
}
}
}
fun setExpandableState(state: State<ExpandableState>) {
expandableState = state.value
}
private suspend fun subscribeToCurrencies(userWallets: List<UserWallet>) {
wallets = CopyOnWriteArrayList(userWallets.filter { it.isMultiCurrency && !it.isLocked })
combine(wallets.map { getCurrenciesUseCase.invoke(it.walletId).distinctUntilChanged() }) {
if (expandableState == ExpandableState.EXPANDED) return@combine
allAddedCurrencies.clear()
addedCurrenciesByWallet.clear()
val walletsWithCurrencies = wallets.zip(
it.map { currencyList ->
currencyList.getOrElse {
Timber.e("Couldn't retrieve currency list")
emptyList()
}
},
)
allAddedCurrencies = walletsWithCurrencies.flatMap { it.second }.toMutableList()
walletsWithCurrencies.forEach { (wallet, currencies) ->
addedCurrenciesByWallet[wallet] = currencies.toMutableList()
}
withContext(dispatchers.main) {
uiState = uiState.copy(tokens = getInitialTokensList())
}
selectedWallet = getSelectedWalletSyncUseCase().fold(
ifLeft = { null },
ifRight = { if (!it.isMultiCurrency || it.isLocked) null else it },
)
if (selectedWallet == null && wallets.isNotEmpty()) {
selectWalletUseCase(wallets.first().walletId)
selectedWallet = wallets.first()
}
updateDerivationNotificationState()
withContext(dispatchers.main) {
uiState = stateFactory.updateChooseWalletState(wallets, userWallets, selectedWallet)
}
}.collect()
}
private fun getInitialTokensList(searchText: String = ""): Flow<PagingData<TokenItemState>> {
return getGlobalTokenListUseCase(searchText = searchText).map {
it.map { token -> tokenConverter.convert(token) }

View file

@ -1,8 +1,10 @@
package com.tangem.managetokens.presentation.router
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.unit.Dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.tangem.features.managetokens.navigation.ExpandableState
import com.tangem.features.managetokens.navigation.ManageTokensUi
import com.tangem.managetokens.presentation.managetokens.ui.ManageTokensScreen
import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensViewModel
@ -11,8 +13,9 @@ import javax.inject.Inject
internal class ManageTokensUiImpl @Inject constructor() : ManageTokensUi {
@Composable
override fun Content(onHeaderSizeChange: (Dp) -> Unit) {
override fun Content(onHeaderSizeChange: (Dp) -> Unit, state: State<ExpandableState>) {
val viewModel = hiltViewModel<ManageTokensViewModel>()
viewModel.setExpandableState(state)
ManageTokensScreen(
state = viewModel.uiState,

View file

@ -7,6 +7,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.onboarding"
}
dependencies {
/** Core modules */
implementation(project(":common"))

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.onboarding" />

View file

@ -20,6 +20,7 @@ dependencies {
implementation(deps.androidx.fragment.ktx)
implementation(deps.androidx.activity.compose)
implementation(deps.lifecycle.compose)
/** Camera */
implementation(deps.camera.camera2)

View file

@ -2,6 +2,7 @@ package com.tangem.feature.qrscanning
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
@ -13,14 +14,16 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.mlkit.vision.common.InputImage
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningContent
import com.tangem.feature.qrscanning.viewmodel.QrScanningViewModel
import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import dagger.hilt.android.AndroidEntryPoint
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@ -49,12 +52,19 @@ internal class QrScanningFragment : ComposeFragment() {
}
private val cameraPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (!it) parentFragmentManager.popBackStack()
if (!it) viewModel.onCameraDeniedState()
}
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) {
val selectedImage = it ?: Uri.EMPTY
if (selectedImage != Uri.EMPTY) {
val image = InputImage.fromFilePath(requireContext(), selectedImage)
analyzer.analyze(image)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.router = innerRouter
viewModel.setRouter(innerRouter, galleryLauncher)
cameraExecutor = Executors.newSingleThreadExecutor()
requestCameraPermission()
}
@ -71,7 +81,7 @@ internal class QrScanningFragment : ComposeFragment() {
QrScanningContent(
executor = { cameraExecutor },
analyzer = { analyzer },
uiState = viewModel.uiState,
uiState = viewModel.uiState.collectAsStateWithLifecycle().value,
)
}

View file

@ -0,0 +1,8 @@
package com.tangem.feature.qrscanning.presentation
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
data class CameraDeniedBottomSheetConfig(
val onGalleryClick: () -> Unit,
val onCancelClick: () -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,82 @@
package com.tangem.feature.qrscanning.presentation
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import com.tangem.core.ui.components.SimpleSettingsRow
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.qrscanning.impl.R
@Composable
fun CameraDeniedBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(config) { content: CameraDeniedBottomSheetConfig ->
CameraDeniedBottomSheet(content = content)
}
}
@Composable
private fun CameraDeniedBottomSheet(content: CameraDeniedBottomSheetConfig) {
val context = LocalContext.current
Column {
CameraDeniedBottomSheetHeader()
SimpleSettingsRow(
title = stringResource(id = R.string.qr_scanner_camera_denied_settings_button),
icon = R.drawable.ic_settings_24,
onItemsClick = {
val intent: Intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
ContextCompat.startActivity(context, intent, null)
},
)
SimpleSettingsRow(
title = stringResource(id = R.string.qr_scanner_camera_denied_gallery_button),
icon = R.drawable.ic_gallery_24,
onItemsClick = content.onGalleryClick,
)
SimpleSettingsRow(
title = stringResource(id = R.string.common_close),
icon = R.drawable.ic_close,
onItemsClick = content.onCancelClick,
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing16),
)
}
}
@Composable
private fun CameraDeniedBottomSheetHeader() {
Text(
text = stringResource(id = R.string.qr_scanner_camera_denied_title),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing20,
end = TangemTheme.dimens.spacing20,
top = TangemTheme.dimens.spacing16,
),
)
Text(
text = stringResource(id = R.string.qr_scanner_camera_denied_text),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing20,
end = TangemTheme.dimens.spacing20,
top = TangemTheme.dimens.spacing3,
bottom = TangemTheme.dimens.spacing16,
),
)
}

View file

@ -1,9 +1,10 @@
package com.tangem.feature.qrscanning.presentation
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -18,14 +19,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import com.google.mlkit.vision.common.InputImage
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIconContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
@ -44,15 +43,9 @@ internal fun QrScanningContent(
analyzer: () -> MLKitBarcodeAnalyzer,
uiState: QrScanningState,
) {
val context = LocalContext.current
var isFlash by remember { mutableStateOf(false) }
val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
val selectedImage = it ?: Uri.EMPTY
if (selectedImage != Uri.EMPTY) {
val image = InputImage.fromFilePath(context, selectedImage)
analyzer().analyze(image)
}
}
BackHandler(onBack = uiState.onBackClick)
Box(
modifier = Modifier
@ -101,13 +94,16 @@ internal fun QrScanningContent(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = { galleryLauncher.launch("image/*") },
onClick = uiState.onGalleryClick,
),
tint = TangemColorPalette.White,
)
}
},
)
if (uiState.bottomSheetConfig != null) {
CameraDeniedBottomSheet(uiState.bottomSheetConfig)
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.qrscanning.presentation
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
@Immutable
@ -8,5 +9,6 @@ data class QrScanningState(
val message: TextReference?,
val onQrScanned: (String) -> Unit,
val onBackClick: () -> Unit,
val onGalleryClicked: () -> Unit,
val onGalleryClick: () -> Unit,
val bottomSheetConfig: TangemBottomSheetConfig? = null,
)

View file

@ -0,0 +1,35 @@
package com.tangem.feature.qrscanning.presentation
import com.tangem.feature.qrscanning.presentation.transformers.QrScanningTransformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class QrScanningStateController @Inject constructor() {
val uiState: StateFlow<QrScanningState> get() = mutableUiState
val value: QrScanningState get() = uiState.value
private val mutableUiState: MutableStateFlow<QrScanningState> = MutableStateFlow(value = getInitialState())
fun update(function: (QrScanningState) -> QrScanningState) {
mutableUiState.update(function = function)
}
fun update(transformer: QrScanningTransformer) {
mutableUiState.update(function = transformer::transform)
}
private fun getInitialState(): QrScanningState {
return QrScanningState(
message = null,
onBackClick = {},
onQrScanned = {},
onGalleryClick = {},
)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.feature.qrscanning.presentation.QrScanningState
internal class DismissBottomSheetTransformer : QrScanningTransformer {
override fun transform(prevState: QrScanningState): QrScanningState {
return prevState.copy(
bottomSheetConfig = null,
)
}
}

View file

@ -1,16 +1,19 @@
package com.tangem.feature.qrscanning.presentation
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.impl.R
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents
internal class QrScanningStateFactory(
val clickIntents: QrScanningClickIntents,
) {
internal class InitializeQrScanningStateTransformer(
private val clickIntents: QrScanningClickIntents,
private val source: SourceType,
private val network: String?,
) : QrScanningTransformer {
fun getInitialState(source: SourceType, network: String?): QrScanningState {
override fun transform(prevState: QrScanningState): QrScanningState {
val message = when (source) {
SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) }
else -> null
@ -20,7 +23,7 @@ internal class QrScanningStateFactory(
message = message,
onBackClick = clickIntents::onBackClick,
onQrScanned = clickIntents::onQrScanned,
onGalleryClicked = clickIntents::onGalleryClicked,
onGalleryClick = clickIntents::onGalleryClicked,
)
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.feature.qrscanning.presentation.QrScanningState
internal interface QrScanningTransformer {
fun transform(prevState: QrScanningState): QrScanningState
}

View file

@ -0,0 +1,24 @@
package com.tangem.feature.qrscanning.presentation.transformers
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.feature.qrscanning.presentation.CameraDeniedBottomSheetConfig
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents
internal class ShowCameraDeniedBottomSheetTransformer(
private val clickIntents: QrScanningClickIntents,
) : QrScanningTransformer {
override fun transform(prevState: QrScanningState): QrScanningState {
return prevState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
isShow = true,
onDismissRequest = clickIntents::onBackClick,
content = CameraDeniedBottomSheetConfig(
onCancelClick = clickIntents::onBackClick,
onGalleryClick = clickIntents::onGalleryClicked,
),
),
)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.feature.qrscanning.viewmodel
import androidx.activity.result.ActivityResultLauncher
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import kotlinx.coroutines.CoroutineScope
import kotlin.properties.Delegates
internal open class BaseQrScanningClickIntents {
protected val router: QrScanningInnerRouter get() = _router
protected val viewModelScope: CoroutineScope get() = _viewModelScope
protected val source: SourceType get() = _source
protected val galleryLauncher: ActivityResultLauncher<String> get() = _galleryLauncher
private var _router: QrScanningInnerRouter by Delegates.notNull()
private var _viewModelScope: CoroutineScope by Delegates.notNull()
private var _source: SourceType by Delegates.notNull()
private var _galleryLauncher: ActivityResultLauncher<String> by Delegates.notNull()
open fun initialize(
router: QrScanningInnerRouter,
source: SourceType,
galleryLauncher: ActivityResultLauncher<String>,
coroutineScope: CoroutineScope,
) {
_router = router
_viewModelScope = coroutineScope
_source = source
_galleryLauncher = galleryLauncher
}
}

View file

@ -1,5 +1,13 @@
package com.tangem.feature.qrscanning.viewmodel
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.DismissBottomSheetTransformer
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.launch
import javax.inject.Inject
interface QrScanningClickIntents {
fun onBackClick()
@ -7,4 +15,39 @@ interface QrScanningClickIntents {
fun onQrScanned(qrCode: String)
fun onGalleryClicked()
}
@ViewModelScoped
internal class QrScanningClickIntentsImplementor @Inject constructor(
private val stateHolder: QrScanningStateController,
private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase,
private val dispatcher: CoroutineDispatcherProvider,
) : BaseQrScanningClickIntents(), QrScanningClickIntents {
private var isScanned = false
override fun onBackClick() = router.popBackStack()
override fun onQrScanned(qrCode: String) {
if (qrCode.isNotBlank()) {
if (!isScanned) {
router.popBackStack()
isScanned = true
}
viewModelScope.launch(dispatcher.main) {
emitQrScannedEventUseCase.invoke(source, qrCode)
}
}
}
override fun onGalleryClicked() {
galleryLauncher.launch(GALLERY_IMAGE_FILTER)
if (stateHolder.value.bottomSheetConfig != null) {
stateHolder.update(DismissBottomSheetTransformer())
}
}
companion object {
private const val GALLERY_IMAGE_FILTER = "image/*"
}
}

View file

@ -1,8 +1,6 @@
package com.tangem.feature.qrscanning.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -11,50 +9,38 @@ import com.tangem.feature.qrscanning.QrScanningRouter.Companion.SOURCE_KEY
import com.tangem.feature.qrscanning.SourceType
import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter
import com.tangem.feature.qrscanning.presentation.QrScanningState
import com.tangem.feature.qrscanning.presentation.QrScanningStateFactory
import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.feature.qrscanning.presentation.QrScanningStateController
import com.tangem.feature.qrscanning.presentation.transformers.ShowCameraDeniedBottomSheetTransformer
import com.tangem.feature.qrscanning.presentation.transformers.InitializeQrScanningStateTransformer
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import kotlin.properties.Delegates
@HiltViewModel
internal class QrScanningViewModel @Inject constructor(
private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase,
private val dispatcher: CoroutineDispatcherProvider,
private val stateHolder: QrScanningStateController,
private val clickIntents: QrScanningClickIntentsImplementor,
savedStateHandle: SavedStateHandle,
) : ViewModel(), QrScanningClickIntents {
) : ViewModel() {
private val source: SourceType = savedStateHandle[SOURCE_KEY] ?: error("Source is mandatory")
private val network: String? = savedStateHandle[NETWORK_KEY]
private val factory = QrScanningStateFactory(
clickIntents = this,
)
val uiState: StateFlow<QrScanningState> = stateHolder.uiState
var router: QrScanningInnerRouter by Delegates.notNull()
var uiState: QrScanningState by mutableStateOf(factory.getInitialState(source, network))
private set
private var isScanned = false
override fun onBackClick() = router.popBackStack()
override fun onQrScanned(qrCode: String) {
if (qrCode.isNotBlank()) {
if (!isScanned) {
router.popBackStack()
isScanned = true
}
viewModelScope.launch(dispatcher.main) {
emitQrScannedEventUseCase.invoke(source, qrCode)
}
}
fun setRouter(router: QrScanningInnerRouter, galleryLauncher: ActivityResultLauncher<String>) {
clickIntents.initialize(
router = router,
source = source,
galleryLauncher = galleryLauncher,
coroutineScope = viewModelScope,
)
stateHolder.update(InitializeQrScanningStateTransformer(clickIntents, source, network))
}
override fun onGalleryClicked() {
// [REDACTED_JIRA]
fun onQrScanned(qrCode: String) = clickIntents.onQrScanned(qrCode)
fun onCameraDeniedState() {
stateHolder.update(ShowCameraDeniedBottomSheetTransformer(clickIntents))
}
}

View file

@ -6,6 +6,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.referral.data"
}
dependencies {
/** Project */

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.referral.data" />

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.referral.domain" />

View file

@ -6,6 +6,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.referral.presentation"
}
dependencies {
/** Core modules */
implementation(project(":core:analytics"))

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.referral.presentation" />

View file

@ -73,7 +73,7 @@ internal class SendFragment : ComposeFragment() {
SystemBarsEffect {
setSystemBarsColor(systemBarsColor)
}
SendScreen(viewModel.uiState)
SendScreen(viewModel.uiState, viewModel.stateRouter.currentState)
}
override fun onDestroy() {

View file

@ -1,9 +1,12 @@
package com.tangem.features.send.impl.presentation.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.VALIDATION
import com.tangem.core.analytics.models.AnalyticsParam.OnOffState
/**
* Send screen analytics
@ -13,24 +16,9 @@ internal sealed class SendAnalyticEvents(
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Token / Send", event = event, params = params) {
/** Send screen opened */
object SendOpened : SendAnalyticEvents(event = "Send Screen Opened")
/** Next button clicked */
data class NextButtonClicked(val source: SendScreenSource) : SendAnalyticEvents(
event = "Button - Next",
params = mapOf(SOURCE to source.name),
)
/** Back button clicked */
data class BackButtonClicked(val source: SendScreenSource) : SendAnalyticEvents(
event = "Button - Back",
params = mapOf(SOURCE to source.name),
)
// region Address
/** Recipient address screen opened */
object AddressScreenOpened : SendAnalyticEvents(event = "Address Screen Opened")
data object AddressScreenOpened : SendAnalyticEvents(event = "Address Screen Opened")
/** Address to send entered */
data class AddressEntered(val source: EnterAddressSource, val isValid: Boolean) : SendAnalyticEvents(
@ -41,21 +29,13 @@ internal sealed class SendAnalyticEvents(
),
)
/** Paste from clipboard button clicked */
data class PasteButtonClicked(val type: PasteType) : SendAnalyticEvents(
event = "Button - Paste",
params = mapOf(
TYPE to type.name,
),
)
/** Qr Code button clicked */
object QrCodeButtonClicked : SendAnalyticEvents(event = "Button - QR Code")
data object QrCodeButtonClicked : SendAnalyticEvents(event = "Button - QR Code")
// endregion
// region Amount
/** Amount screen opened */
object AmountScreenOpened : SendAnalyticEvents(event = "Amount Screen Opened")
data object AmountScreenOpened : SendAnalyticEvents(event = "Amount Screen Opened")
/** Selected currency */
data class SelectedCurrency(val type: SelectedCurrencyType) : SendAnalyticEvents(
@ -63,36 +43,36 @@ internal sealed class SendAnalyticEvents(
params = mapOf(TYPE to type.value),
)
/** Currency selector button clicked */
object SwapCurrencyButtonClicked : SendAnalyticEvents(event = "Button - Swap Currency")
/** Max amount button clicked */
data object MaxAmountButtonClicked : SendAnalyticEvents(event = "Max Amount Taped")
// endregion
// region Fee
/** Fee screen opened */
object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened")
data object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened")
/** Selected fee (send after next screen opened) */
data class SelectedFee(val fee: String) : SendAnalyticEvents(
data class SelectedFee(val feeType: SelectedFeeType) : SendAnalyticEvents(
event = "Fee Selected",
params = mapOf("Commission" to fee),
params = mapOf("Fee Type" to feeType.name),
)
/** Custom fee selected */
object CustomFeeButtonClicked : SendAnalyticEvents(event = "Custom Fee Clicked")
data object CustomFeeButtonClicked : SendAnalyticEvents(event = "Custom Fee Clicked")
/** Custom fee edited */
object GasPriceInserter : SendAnalyticEvents(event = "Gas Price Inserted")
data object GasPriceInserter : SendAnalyticEvents(event = "Gas Price Inserted")
/** Subtract from amount selector switched (send after next screen opened) */
object SubtractFromAmount : SendAnalyticEvents(event = "Subtract from Amount")
data class SubtractFromAmount(val status: Boolean) : SendAnalyticEvents(
event = "Subtract from Amount",
params = mapOf("Status" to if (status) OnOffState.On.value else OnOffState.Off.value),
)
// endregion
// region Confirmation
/** Confirmation screen opened */
object ConfirmationScreenOpened : SendAnalyticEvents(event = "Confirm Screen Opened")
/** Send transaction button clicked */
object SendButtonClicked : SendAnalyticEvents(event = "Button - Send")
data object ConfirmationScreenOpened : SendAnalyticEvents(event = "Confirm Screen Opened")
/** Screen reopened from confirmation screen */
data class ScreenReopened(val source: SendScreenSource) : SendAnalyticEvents(
@ -103,13 +83,31 @@ internal sealed class SendAnalyticEvents(
// region Transaction Result
/** Transaction send screen opened */
object TransactionScreenOpened : SendAnalyticEvents(event = "Transaction Sent Screen Opened")
data object TransactionScreenOpened : SendAnalyticEvents(event = "Transaction Sent Screen Opened")
/** Share button clicked */
object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share")
data object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share")
/** Expore button clicked */
object ExploreButtonClicked : SendAnalyticEvents(event = "Button - Explore")
data object ExploreButtonClicked : SendAnalyticEvents(event = "Button - Explore")
/** If not enough fee notification is present */
data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SendAnalyticEvents(
event = "Notice - Not Enough Fee",
params = mapOf(TOKEN to token, BLOCKCHAIN to blockchain),
)
/** If transaction delays notification is present */
data class NoticeTransactionDelays(val token: String) : SendAnalyticEvents(
event = "Notice - Transaction Delays Are Possible",
params = mapOf(TOKEN to token),
)
/** If error occurs during send transactions */
data class TransactionError(val token: String) : SendAnalyticEvents(
event = "Error - Transaction Rejected",
params = mapOf(TOKEN to token),
)
// endregion
}
@ -125,12 +123,15 @@ internal enum class EnterAddressSource {
RecentAddress,
}
internal enum class PasteType {
Address,
Memo,
}
internal enum class SelectedCurrencyType(val value: String) {
Token("Token"),
AppCurrency("App Currency"),
}
internal enum class SelectedFeeType {
Min,
Max,
Fixed,
Normal,
Custom,
}

View file

@ -1,7 +1,9 @@
package com.tangem.features.send.impl.presentation.analytics.utils
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.features.send.impl.presentation.analytics.SelectedCurrencyType
import com.tangem.features.send.impl.presentation.analytics.SelectedFeeType
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
@ -21,15 +23,12 @@ internal class SendOnNextScreenAnalyticSender(
if (selectedFee == FeeType.Custom && isCustomFeeEdited) {
analyticsEventHandler.send(SendAnalyticEvents.GasPriceInserter)
}
analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(selectedFee.name))
}
if (feeState.isSubtract) {
analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount)
sendSelectedFeeAnalytics(feeSelectorState)
}
}
SendUiStateType.Amount -> {
val isFiatSelected = state.amountState?.amountTextField?.isFiatValue ?: return
val selectedCurrency = if (isFiatSelected) {
val selectedCurrency = if (!isFiatSelected) {
SelectedCurrencyType.Token
} else {
SelectedCurrencyType.AppCurrency
@ -41,4 +40,17 @@ internal class SendOnNextScreenAnalyticSender(
else -> Unit
}
}
private fun sendSelectedFeeAnalytics(feeSelectorState: FeeSelectorState.Content) {
val type = when (feeSelectorState.fees) {
is TransactionFee.Single -> SelectedFeeType.Fixed
is TransactionFee.Choosable -> when (feeSelectorState.selectedFee) {
FeeType.Slow -> SelectedFeeType.Min
FeeType.Market -> SelectedFeeType.Normal
FeeType.Fast -> SelectedFeeType.Max
FeeType.Custom -> SelectedFeeType.Custom
}
}
analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(type))
}
}

View file

@ -1,26 +0,0 @@
package com.tangem.features.send.impl.presentation.analytics.utils
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.analytics.PasteType
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
internal class SendRecipientAnalyticsSender(
private val analyticsEventHandler: AnalyticsEventHandler,
) {
fun sendAddressAnalytics(type: EnterAddressSource?, isValidAddress: Boolean) {
type?.let {
if (type == EnterAddressSource.PasteButton) {
analyticsEventHandler.send(SendAnalyticEvents.PasteButtonClicked(PasteType.Address))
}
analyticsEventHandler.send(SendAnalyticEvents.AddressEntered(it, isValidAddress))
}
}
fun sendMemoAnalytics(isPasted: Boolean) {
if (isPasted) {
analyticsEventHandler.send(SendAnalyticEvents.PasteButtonClicked(PasteType.Memo))
}
}
}

View file

@ -1,23 +1,14 @@
package com.tangem.features.send.impl.presentation.domain
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.PersistentList
@Immutable
internal sealed class SendRecipientListContent {
data class Item(
val id: String,
val title: TextReference,
val subtitle: TextReference,
val timestamp: TextReference? = null,
val subtitleEndOffset: Int = 0,
@DrawableRes val subtitleIconRes: Int? = null,
) : SendRecipientListContent()
data class Wallets(
val list: PersistentList<Item>,
val isWalletsOnly: Boolean,
) : SendRecipientListContent()
}
data class SendRecipientListContent(
val id: String,
val title: TextReference,
val subtitle: TextReference,
val timestamp: TextReference? = null,
val subtitleEndOffset: Int = 0,
@DrawableRes val subtitleIconRes: Int? = null,
val isVisible: Boolean = true,
)

View file

@ -20,7 +20,7 @@ internal sealed class SendAlertState {
) : SendAlertState() {
override val message: TextReference = resourceReference(R.string.common_unknown_error)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.send_alert_button_request_support)
resourceReference(id = R.string.common_support)
}
data class TransactionError(
@ -35,7 +35,7 @@ internal sealed class SendAlertState {
formatArgs = wrappedList(causeTextReference ?: cause.orEmpty(), code),
)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.send_alert_button_request_support)
resourceReference(id = R.string.common_support)
}
data class DemoMode(
@ -45,11 +45,28 @@ internal sealed class SendAlertState {
override val message: TextReference = resourceReference(id = R.string.warning_demo_mode_message)
}
object FeeIncreased : SendAlertState() {
data object FeeIncreased : SendAlertState() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_notification_high_fee_title)
}
data class FeeTooLow(
override val onConfirmClick: () -> Unit,
) : SendAlertState() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_alert_fee_too_low_text)
override val confirmButtonText: TextReference = resourceReference(R.string.common_continue)
}
data class FeeCoverage(
override val onConfirmClick: (() -> Unit),
) : SendAlertState() {
override val title: TextReference? = null
override val message: TextReference = resourceReference(id = R.string.send_alert_fee_coverage_title)
override val confirmButtonText: TextReference =
resourceReference(id = R.string.send_alert_fee_coverage_subract_text)
}
data class ReserveAmount(val amount: String) : SendAlertState() {
override val title: TextReference =
resourceReference(id = R.string.send_notification_invalid_reserve_amount_title, wrappedList(amount))

View file

@ -43,6 +43,20 @@ internal class SendEventStateFactory(
)
}
fun getFeeCoverageAlert(onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.FeeCoverage(
onConfirmClick = clickIntents::onSubtractSelect,
),
),
onConsume = onConsume,
),
)
}
fun getFeeUpdatedAlert(fee: TransactionFee, onConsume: () -> Unit, onFeeNotIncreased: () -> Unit): SendUiState {
val state = currentStateProvider()
val feeSelector = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return state
@ -74,6 +88,20 @@ internal class SendEventStateFactory(
}
}
fun getFeeTooLowAlert(onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(
event = triggeredEvent(
data = SendEvent.ShowAlert(
SendAlertState.FeeTooLow(
onConfirmClick = clickIntents::showSend,
),
),
onConsume = onConsume,
),
)
}
fun getGenericErrorState(error: Throwable? = null, onConsume: () -> Unit): SendUiState {
val state = currentStateProvider()
return state.copy(

View file

@ -12,12 +12,14 @@ internal sealed class SendNotification(val config: NotificationConfig) {
title: TextReference,
subtitle: TextReference,
buttonState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : SendNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.ic_alert_24,
buttonsState = buttonState,
onCloseClick = onCloseClick,
),
) {
@ -45,12 +47,17 @@ internal sealed class SendNotification(val config: NotificationConfig) {
val cryptoCurrency: String,
val utxoLimit: String,
val amountLimit: String,
val onConfirmClick: () -> Unit,
) : Error(
title = resourceReference(R.string.send_notifiaction_transaction_limit_title),
subtitle = resourceReference(
R.string.send_notifiaction_transaction_limit_text,
R.string.send_notification_transaction_limit_text,
wrappedList(cryptoCurrency, utxoLimit, amountLimit),
),
buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_to, wrappedList(amountLimit)),
onClick = onConfirmClick,
),
)
}
@ -58,32 +65,49 @@ internal sealed class SendNotification(val config: NotificationConfig) {
title: TextReference,
subtitle: TextReference,
buttonsState: NotificationConfig.ButtonsState? = null,
onCloseClick: (() -> Unit)? = null,
) : SendNotification(
config = NotificationConfig(
title = title,
subtitle = subtitle,
iconResId = R.drawable.img_attention_20,
buttonsState = buttonsState,
onCloseClick = onCloseClick,
),
) {
data class HighFeeError(
val amount: String,
val onConfirmClick: () -> Unit,
val onDismissClick: () -> Unit,
val onCloseClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.send_notification_high_fee_title),
subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)),
buttonsState = NotificationConfig.ButtonsState.PairButtonsConfig(
primaryText = resourceReference(R.string.send_notification_fee_too_high_accept, wrappedList(amount)),
onPrimaryClick = onConfirmClick,
secondaryText = resourceReference(R.string.send_notification_fee_too_high_ignore),
onSecondaryClick = onDismissClick,
buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig(
text = resourceReference(R.string.send_notification_reduce_by, wrappedList(amount)),
onClick = onConfirmClick,
),
onCloseClick = onCloseClick,
)
data class ExistentialDeposit(val deposit: String) : Warning(
title = resourceReference(R.string.send_notification_existential_deposit_title),
subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)),
)
data class NetworkCoverage(
val amountReducedBy: String,
val amountReduced: String,
) : Warning(
title = resourceReference(id = R.string.send_network_fee_warning_title),
subtitle = resourceReference(
id = R.string.send_network_fee_warning_content,
formatArgs = wrappedList(amountReducedBy, amountReduced),
),
)
data object FeeTooLow : Warning(
title = resourceReference(id = R.string.send_notification_transaction_delay_title),
subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text),
)
}
}

View file

@ -1,11 +1,17 @@
package com.tangem.features.send.impl.presentation.state
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
@ -17,40 +23,48 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class SendNotificationFactory(
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val currencyChecksRepository: CurrencyChecksRepository,
private val stateRouterProvider: Provider<StateRouter>,
private val clickIntents: SendClickIntents,
private val analyticsEventHandler: AnalyticsEventHandler,
) {
fun create(): Flow<ImmutableList<SendNotification>> = currentStateProvider().currentState
.filter { it == SendUiStateType.Send }
fun create(): Flow<ImmutableList<SendNotification>> = stateRouterProvider().currentState
.filter { it.type == SendUiStateType.Send }
.map {
val state = currentStateProvider()
val sendState = state.sendState
val feeState = state.feeState ?: return@map persistentListOf()
val feeAmount = feeState.fee?.amount?.value ?: BigDecimal.ZERO
val amountValue = state.amountState?.amountTextField?.value?.toBigDecimalOrNull() ?: BigDecimal.ZERO
val sendAmount = if (feeState.isSubtract) feeState.receivedAmountValue else amountValue
val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO
val sendAmount = if (sendState.isSubtract) amountValue.minus(feeAmount) else amountValue
buildList {
// errors
addExceedBalanceNotification(feeAmount, sendAmount)
addInvalidAmountNotification(feeState.isSubtract, sendAmount)
addInvalidAmountNotification(sendState.isSubtract, sendAmount)
addMinimumAmountErrorNotification(feeAmount, sendAmount)
addDustWarningNotification(feeAmount, sendAmount)
addTransactionLimitErrorNotification(feeAmount, sendAmount)
// warnings
addFeeCoverageNotification(sendState.isSubtract, sendAmount)
addExistentialWarningNotification(feeAmount, sendAmount)
addHighFeeWarningNotification(amountValue, state.sendState.ignoreAmountReduce)
addHighFeeWarningNotification(sendAmount, sendState.ignoreAmountReduce)
addTooLowNotification(feeState)
}.toImmutableList()
}
fun dismissHighFeeWarningState(): SendUiState {
fun dismissNotificationState(clazz: Class<out SendNotification>): SendUiState {
val state = currentStateProvider()
val sendState = state.sendState
val updatedNotifications = sendState.notifications.filterNot { it is SendNotification.Warning.HighFeeError }
val notificationsToRemove = sendState.notifications.filterIsInstance(clazz)
val updatedNotifications = sendState.notifications.toMutableList()
updatedNotifications.removeAll(notificationsToRemove)
return state.copy(
sendState = sendState.copy(
ignoreAmountReduce = true,
@ -158,6 +172,13 @@ internal class SendNotificationFactory(
cryptoAmount = utxoLimit.maxAmount,
cryptoCurrency = cryptoCurrency,
),
onConfirmClick = {
val reduceTo = utxoLimit.maxAmount.toPlainString()
clickIntents.onAmountReduceClick(
reduceTo,
SendNotification.Error.TransactionLimitError::class.java,
)
},
),
)
}
@ -203,9 +224,11 @@ internal class SendNotificationFactory(
amount = TEZOS_FEE_THRESHOLD.toPlainString(),
onConfirmClick = {
val reduceTo = sendAmount.minus(TEZOS_FEE_THRESHOLD).toPlainString()
clickIntents.onAmountReduceClick(reduceTo)
clickIntents.onAmountReduceClick(reduceTo, SendNotification.Warning.HighFeeError::class.java)
},
onCloseClick = {
clickIntents.onNotificationCancel(SendNotification.Warning.HighFeeError::class.java)
},
onDismissClick = clickIntents::onAmountReduceIgnoreClick,
),
)
}
@ -234,6 +257,45 @@ internal class SendNotificationFactory(
}
}
private fun MutableList<SendNotification>.addFeeCoverageNotification(
isSubtract: Boolean,
amountValue: BigDecimal,
) {
val state = currentStateProvider()
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
val feeAmount = state.feeState?.fee?.amount?.value ?: BigDecimal.ZERO
val amountReducedValue = amountValue.minus(feeAmount)
val amountReducedByValue = amountValue.minus(amountReducedValue)
val amountReducedBy = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amountReducedByValue,
cryptoCurrency = cryptoCurrency,
)
val amountReduced = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = amountReducedValue,
cryptoCurrency = cryptoCurrency,
)
if (isSubtract) {
add(SendNotification.Warning.NetworkCoverage(amountReducedBy, amountReduced))
}
}
private fun MutableList<SendNotification>.addTooLowNotification(feeState: SendStates.FeeState) {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return
val minimumValue = multipleFees.minimum.amount.value ?: return
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
if (feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue) {
add(SendNotification.Warning.FeeTooLow)
analyticsEventHandler.send(
SendAnalyticEvents.NoticeTransactionDelays(
cryptoCurrencyStatusProvider().currency.symbol,
),
)
}
}
companion object {
private const val CARDANO_MINIMUM = "1"
private const val DOGECOIN_MINIMUM = "0.01"

View file

@ -1,6 +1,5 @@
package com.tangem.features.send.impl.presentation.state
import androidx.paging.PagingData
import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
@ -23,7 +22,6 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import timber.log.Timber
@Suppress("LongParameterList")
@ -78,7 +76,6 @@ internal class SendStateFactory(
// region UI states
fun getInitialState(): SendUiState = SendUiState(
clickIntents = clickIntents,
currentState = MutableStateFlow(SendUiStateType.None),
event = consumedEvent(),
isEditingDisabled = false,
isBalanceHidden = false,
@ -109,17 +106,11 @@ internal class SendStateFactory(
//endregion
//region recipient
fun onLoadedRecipientList(
wallets: List<AvailableWallet?>,
txHistory: PagingData<TxHistoryItem>,
txHistoryCount: Int,
) {
fun onLoadedRecipientList(wallets: List<AvailableWallet?>, txHistory: List<TxHistoryItem>): SendUiState =
recipientListStateConverter.convert(
wallets = wallets,
txHistory = txHistory,
txHistoryCount = txHistoryCount,
)
}
fun onRecipientAddressValueChange(value: String, isXAddress: Boolean = false): SendUiState {
val state = currentStateProvider()
@ -223,6 +214,13 @@ internal class SendStateFactory(
//endregion
//region send
fun onSubtractSelect(isSubtract: Boolean): SendUiState {
val state = currentStateProvider()
return state.copy(
sendState = state.sendState.copy(isSubtract = isSubtract),
)
}
fun getSendingStateUpdate(isSending: Boolean): SendUiState {
val state = currentStateProvider()
return state.copy(sendState = state.sendState.copy(isSending = isSending))

View file

@ -2,7 +2,6 @@ package com.tangem.features.send.impl.presentation.state
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.paging.PagingData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
import com.tangem.core.ui.event.StateEvent
@ -17,8 +16,6 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.math.BigDecimal
/**
@ -32,8 +29,6 @@ internal data class SendUiState(
val recipientState: SendStates.RecipientState? = null,
val feeState: SendStates.FeeState? = null,
val sendState: SendStates.SendState = SendStates.SendState(),
val recipientList: MutableStateFlow<PagingData<SendRecipientListContent>> = MutableStateFlow(PagingData.empty()),
val currentState: StateFlow<SendUiStateType>,
val isBalanceHidden: Boolean,
val event: StateEvent<SendEvent>,
)
@ -64,7 +59,8 @@ internal sealed class SendStates {
override val isPrimaryButtonEnabled: Boolean,
val addressTextField: SendTextField.RecipientAddress,
val memoTextField: SendTextField.RecipientMemo?,
val recipients: MutableStateFlow<PagingData<SendRecipientListContent>> = MutableStateFlow(PagingData.empty()),
val recent: ImmutableList<SendRecipientListContent>,
val wallets: ImmutableList<SendRecipientListContent>,
val network: String,
val isValidating: Boolean = false,
) : SendStates()
@ -75,12 +71,7 @@ internal sealed class SendStates {
override val type: SendUiStateType = SendUiStateType.Fee,
override val isPrimaryButtonEnabled: Boolean = false,
val feeSelectorState: FeeSelectorState,
val isSubtractAvailable: Boolean,
val isSubtract: Boolean,
val isUserSubtracted: Boolean,
val fee: Fee?,
val receivedAmountValue: BigDecimal,
val receivedAmount: String,
val rate: BigDecimal?,
val appCurrency: AppCurrency,
val isFeeApproximate: Boolean,
@ -94,17 +85,24 @@ internal sealed class SendStates {
override val isPrimaryButtonEnabled: Boolean = true,
val isSending: Boolean = false,
val isSuccess: Boolean = false,
val isSubtract: Boolean = false,
val transactionDate: Long = 0L,
val txUrl: String = "",
val ignoreAmountReduce: Boolean = false,
val isFromConfirmation: Boolean = true,
val notifications: ImmutableList<SendNotification> = persistentListOf(),
) : SendStates()
}
data class SendUiCurrentScreen(
val type: SendUiStateType,
val isFromConfirmation: Boolean,
)
enum class SendUiStateType {
None,
Amount,
Recipient,
Amount,
Fee,
Send,
}

View file

@ -3,7 +3,6 @@ package com.tangem.features.send.impl.presentation.state
import androidx.fragment.app.FragmentManager
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.analytics.SendScreenSource
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@ -14,106 +13,91 @@ internal class StateRouter(
private val analyticsEventsHandler: AnalyticsEventHandler,
private val isEditingDisabled: Boolean,
) {
private var mutableCurrentState: MutableStateFlow<SendUiStateType> = MutableStateFlow(
if (isEditingDisabled) {
SendUiStateType.None
} else {
SendUiStateType.Recipient
},
)
private var mutableCurrentState: MutableStateFlow<SendUiCurrentScreen> = MutableStateFlow(getInitState())
val currentState: StateFlow<SendUiStateType> = mutableCurrentState
val currentState: StateFlow<SendUiCurrentScreen>
get() = mutableCurrentState
fun clear() {
mutableCurrentState.update { getInitState() }
}
fun popBackStack() {
fragmentManager.get()?.popBackStack()
}
fun onBackClick(isSuccess: Boolean = false) {
val type = currentState.value.type
when {
isSuccess -> popBackStack()
isEditingDisabled -> when (currentState.value) {
SendUiStateType.Send -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee))
showFee()
}
isEditingDisabled -> when (type) {
SendUiStateType.Send -> showFee()
else -> popBackStack()
}
else -> when (currentState.value) {
SendUiStateType.Amount -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Address))
showRecipient()
}
SendUiStateType.Fee -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount))
showAmount()
}
SendUiStateType.Send -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee))
showFee()
}
else -> popBackStack()
else -> when (type) {
SendUiStateType.Amount -> continueToSend(::showRecipient)
SendUiStateType.Fee -> continueToSend(::showAmount)
SendUiStateType.Send -> continueToSend(::showFee)
else -> continueToSend(::popBackStack)
}
}
}
fun onNextClick(): SendUiStateType {
val prevState = currentState.value
when (currentState.value) {
SendUiStateType.Recipient -> {
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Amount))
showAmount()
}
SendUiStateType.Amount -> {
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee))
showFee()
}
SendUiStateType.Fee -> {
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee))
showSend()
}
SendUiStateType.Send -> {
onBackClick()
}
fun onNextClick() {
when (currentState.value.type) {
SendUiStateType.Recipient -> continueToSend(::showAmount)
SendUiStateType.Amount -> continueToSend(::showFee)
SendUiStateType.Fee -> showSend()
SendUiStateType.Send -> onBackClick()
else -> popBackStack()
}
return prevState
}
fun onPrevClick() {
if (isEditingDisabled) {
popBackStack()
} else {
when (currentState.value) {
SendUiStateType.Amount -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount))
showRecipient()
}
SendUiStateType.Fee -> {
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee))
showAmount()
}
when (currentState.value.type) {
SendUiStateType.Amount -> showRecipient()
SendUiStateType.Fee -> showAmount()
else -> popBackStack()
}
}
}
fun showAmount() {
fun showAmount(isFromConfirmation: Boolean = false) {
analyticsEventsHandler.send(SendAnalyticEvents.AmountScreenOpened)
mutableCurrentState.update { SendUiStateType.Amount }
mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Amount, isFromConfirmation) }
}
fun showRecipient() {
fun showRecipient(isFromConfirmation: Boolean = false) {
analyticsEventsHandler.send(SendAnalyticEvents.AddressScreenOpened)
mutableCurrentState.update { SendUiStateType.Recipient }
mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Recipient, isFromConfirmation) }
}
fun showFee() {
fun showFee(isFromConfirmation: Boolean = false) {
analyticsEventsHandler.send(SendAnalyticEvents.FeeScreenOpened)
mutableCurrentState.update { SendUiStateType.Fee }
mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Fee, isFromConfirmation) }
}
private fun showSend() {
fun showSend() {
analyticsEventsHandler.send(SendAnalyticEvents.ConfirmationScreenOpened)
mutableCurrentState.update { SendUiStateType.Send }
mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Send, isFromConfirmation = false) }
}
private fun continueToSend(show: () -> Unit) {
if (currentState.value.isFromConfirmation) showSend() else show()
}
private fun getInitState() = if (isEditingDisabled) {
SendUiCurrentScreen(
type = SendUiStateType.None,
isFromConfirmation = false,
)
} else {
SendUiCurrentScreen(
type = SendUiStateType.Recipient,
isFromConfirmation = false,
)
}
}

View file

@ -45,7 +45,7 @@ internal class SendAmountStateConverter(
),
SendAmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconState = iconStateConverter.convert(status),
iconUrl = appCurrency.iconSmallUrl,
isFiat = true,
),
),

View file

@ -1,14 +1,29 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import java.math.BigDecimal
/**
* Calculate receiving amount when fee is subtracted from sending amount
* Check if sending amount with fee is greater than balance
*/
internal fun calculateReceiveAmount(state: SendUiState, feeAmount: Fee): BigDecimal {
val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO
val fee = feeAmount.amount.value ?: return BigDecimal.ZERO
return amountValue.minus(fee)
internal fun checkFeeCoverage(state: SendUiState, cryptoCurrencyStatus: CryptoCurrencyStatus): Boolean {
val balance = cryptoCurrencyStatus.value.amount ?: return false
val fee = state.feeState?.fee?.amount?.value ?: return false
val amount = state.amountState?.amountTextField?.cryptoAmount?.value ?: return false
return balance <= amount + fee
}
/**
* Check if custom fee is too low
*/
internal fun checkIfFeeTooLow(state: SendUiState): Boolean {
val feeSelectorState = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return false
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return false
val minimumValue = multipleFees.minimum.amount.value ?: return false
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return false
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
return feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue
}

View file

@ -1,17 +1,21 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.state.StateRouter
import com.tangem.features.send.impl.presentation.state.fields.SendTextField
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
@ -22,17 +26,20 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class FeeNotificationFactory(
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val currentStateProvider: Provider<SendUiState>,
private val userWalletProvider: Provider<UserWallet>,
private val stateRouterProvider: Provider<StateRouter>,
private val clickIntents: SendClickIntents,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) {
fun create() = currentStateProvider().currentState
.filter { it == SendUiStateType.Fee }
fun create() = stateRouterProvider().currentState
.filter { it.type == SendUiStateType.Fee }
.map {
val state = currentStateProvider()
val feeState = state.feeState ?: return@map persistentListOf()
@ -45,9 +52,7 @@ internal class FeeNotificationFactory(
is FeeSelectorState.Content -> {
val customFee = feeSelectorState.customValues
val selectedFee = feeSelectorState.selectedFee
addTooLowNotification(feeSelectorState.fees, selectedFee, customFee)
addTooHighNotification(feeSelectorState.fees, selectedFee, customFee)
addFeeCoverageNotification(feeState, state.amountState)
addExceedsBalanceNotification(feeState.fee)
}
}
@ -60,20 +65,6 @@ internal class FeeNotificationFactory(
}
}
private fun MutableList<SendFeeNotification>.addTooLowNotification(
transactionFee: TransactionFee,
selectedFee: FeeType,
customFee: List<SendTextField.CustomFee>,
) {
val multipleFees = transactionFee as? TransactionFee.Choosable ?: return
val minimumValue = multipleFees.minimum.amount.value ?: return
val customAmount = customFee.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
if (selectedFee == FeeType.Custom && minimumValue > customValue) {
add(SendFeeNotification.Warning.TooLow)
}
}
private fun MutableList<SendFeeNotification>.addTooHighNotification(
transactionFee: TransactionFee,
selectedFee: FeeType,
@ -89,20 +80,6 @@ internal class FeeNotificationFactory(
}
}
private fun MutableList<SendFeeNotification>.addFeeCoverageNotification(
feeState: SendStates.FeeState,
amountState: SendStates.AmountState?,
) {
if (!feeState.isSubtractAvailable) return
val cryptoAmount = coinCryptoCurrencyStatusProvider().value.amount ?: return
val feeValue = feeState.fee?.amount?.value ?: return
val value = amountState?.amountTextField?.cryptoAmount?.value ?: return
if (cryptoAmount <= value + feeValue && feeState.isSubtract && !feeState.isUserSubtracted) {
add(SendFeeNotification.Warning.NetworkCoverage)
}
}
private suspend fun MutableList<SendFeeNotification>.addExceedsBalanceNotification(fee: Fee?) {
val feeValue = fee?.amount?.value ?: BigDecimal.ZERO
val userWalletId = userWalletProvider().walletId
@ -118,15 +95,17 @@ internal class FeeNotificationFactory(
ifRight = { it },
) ?: return
val mergeFeeNetworkName = cryptoCurrencyStatus.shouldMergeFeeNetworkName()
when (warning) {
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> {
add(
SendFeeNotification.Error.ExceedsBalance(
warning.coinCurrency.networkIconResId,
networkIconId = warning.coinCurrency.networkIconResId,
networkName = warning.coinCurrency.name,
currencyName = cryptoCurrencyStatus.currency.name,
feeName = warning.coinCurrency.name,
feeSymbol = warning.coinCurrency.symbol,
mergeFeeNetworkName = mergeFeeNetworkName,
onClick = {
clickIntents.onTokenDetailsClick(
userWalletId = userWalletId,
@ -135,6 +114,12 @@ internal class FeeNotificationFactory(
},
),
)
analyticsEventHandler.send(
SendAnalyticEvents.NoticeNotEnoughFee(
token = cryptoCurrencyStatus.currency.symbol,
blockchain = cryptoCurrencyStatus.currency.network.name,
),
)
}
is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> {
val currency = warning.feeCurrency
@ -145,6 +130,7 @@ internal class FeeNotificationFactory(
feeName = warning.feeCurrencyName,
feeSymbol = warning.feeCurrencySymbol,
networkName = warning.networkName,
mergeFeeNetworkName = mergeFeeNetworkName,
onClick = currency?.let {
{
clickIntents.onTokenDetailsClick(
@ -155,11 +141,22 @@ internal class FeeNotificationFactory(
},
),
)
analyticsEventHandler.send(
SendAnalyticEvents.NoticeNotEnoughFee(
token = warning.currency.symbol,
blockchain = warning.networkName,
),
)
}
else -> Unit
}
}
// workaround for networks that users have misunderstanding
private fun CryptoCurrencyStatus.shouldMergeFeeNetworkName(): Boolean {
return Blockchain.fromNetworkId(this.currency.network.backendId) == Blockchain.Arbitrum
}
companion object {
private val FEE_MAX_DIFF = BigDecimal(5)
private const val HIGH_FEE_DIFF_DECIMALS = 0

View file

@ -3,7 +3,6 @@ package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -14,7 +13,6 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
/**
* Factory to produce fee state for [SendUiState]
@ -22,7 +20,6 @@ import java.math.BigDecimal
internal class FeeStateFactory(
private val clickIntents: SendClickIntents,
private val currentStateProvider: Provider<SendUiState>,
private val coinCryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
@ -55,56 +52,26 @@ internal class FeeStateFactory(
)
}
fun onFeeOnLoadedState(fees: TransactionFee, isSubtractAvailable: Boolean): SendUiState {
fun onFeeOnLoadedState(fees: TransactionFee): SendUiState {
val state = currentStateProvider()
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val feeState = state.feeState ?: return state
val feeSelectorState = (feeState.feeSelectorState as? FeeSelectorState.Content)?.copy(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
) ?: FeeSelectorState.Content(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
val fee = feeConverter.convert(feeSelectorState)
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
isSubtractAvailable = isSubtractAvailable,
feeSelectorState = feeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = isSubtractAvailable && checkAutoSubtract(state, fee, balance),
isFeeApproximate = isFeeApproximate(fee),
),
)
}
fun onFeeOnLoadedState(fees: TransactionFee): SendUiState {
val state = currentStateProvider()
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelector = feeSelectorState.copy(
fees = fees,
customValues = customFeeFieldConverter.convert(fees.normal),
)
val fee = feeConverter.convert(updatedFeeSelector)
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
feeSelectorState = updatedFeeSelector,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
),
)
}
fun onFeeOnErrorState(): SendUiState {
val state = currentStateProvider()
return state.copy(
@ -118,18 +85,13 @@ internal class FeeStateFactory(
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val updatedFeeSelectorState = feeSelectorState.copy(selectedFee = feeType)
val fee = feeConverter.convert(updatedFeeSelectorState)
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
fee = fee,
feeSelectorState = updatedFeeSelectorState,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
),
)
}
@ -139,34 +101,12 @@ internal class FeeStateFactory(
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val updatedFeeSelectorState = customFeeFieldConverter.onValueChange(feeSelectorState, index, value)
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val fee = feeConverter.convert(updatedFeeSelectorState)
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
feeSelectorState = updatedFeeSelectorState,
fee = fee,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
isSubtract = checkAutoSubtract(state, fee, balance),
),
)
}
fun onSubtractSelect(value: Boolean): SendUiState {
val state = currentStateProvider()
val feeState = state.feeState ?: return state
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state
val fee = feeConverter.convert(feeSelectorState)
val receivedAmount = calculateReceiveAmount(state, fee)
return state.copy(
feeState = feeState.copy(
isSubtract = value,
isUserSubtracted = true,
receivedAmountValue = receivedAmount,
receivedAmount = getFormattedValue(receivedAmount),
fee = fee,
),
)
}
@ -187,9 +127,6 @@ internal class FeeStateFactory(
): Boolean {
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return false
val customValue = feeSelectorState.customValues.firstOrNull()
val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO
val fee = feeConverter.convert(feeSelectorState)
val feeValue = fee.amount.value ?: BigDecimal.ZERO
val isNotCustom = feeSelectorState.selectedFee != FeeType.Custom
val isNotEmptyCustom = if (customValue != null) {
@ -198,24 +135,8 @@ internal class FeeStateFactory(
false
}
val noErrors = notifications.none { it is SendFeeNotification.Error }
val isSubtractRequired = when {
!feeState.isSubtractAvailable -> true // current currency is not fee currency
feeValue + feeState.receivedAmountValue >= balance -> feeState.isSubtract
else -> feeValue + feeState.receivedAmountValue <= balance
}
return noErrors && isSubtractRequired && (isNotEmptyCustom || isNotCustom)
}
private fun checkAutoSubtract(state: SendUiState, fee: Fee, balance: BigDecimal): Boolean {
val feeState = state.feeState ?: return false
val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO
val feeAmount = fee.amount.value ?: BigDecimal.ZERO
return if (feeState.isUserSubtracted) {
feeState.isSubtract
} else {
amountValue + feeAmount >= balance
}
return noErrors && (isNotEmptyCustom || isNotCustom)
}
private fun isFeeApproximate(fee: Fee): Boolean {
@ -225,13 +146,4 @@ internal class FeeStateFactory(
amountType = fee.amount.type,
)
}
private fun getFormattedValue(value: BigDecimal): String {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
return BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = value,
cryptoCurrency = cryptoCurrency.symbol,
decimals = cryptoCurrency.decimals,
)
}
}

View file

@ -20,11 +20,6 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
buttonsState = buttonsState,
),
) {
object TooLow : Warning(
title = resourceReference(id = R.string.send_notification_transaction_delay_title),
subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text),
)
data class TooHigh(
val value: String,
) : Warning(
@ -32,11 +27,6 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
subtitle = resourceReference(id = R.string.send_notification_fee_too_high_text, wrappedList(value)),
)
object NetworkCoverage : Warning(
title = resourceReference(id = R.string.send_network_fee_warning_title),
subtitle = resourceReference(id = R.string.send_network_fee_warning_content),
)
data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning(
title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(R.string.send_fee_unreachable_error_text),
@ -66,6 +56,7 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
val feeName: String,
val feeSymbol: String,
val networkName: String,
val mergeFeeNetworkName: Boolean = false,
val onClick: (() -> Unit)? = null,
) : Error(
title = resourceReference(
@ -79,7 +70,16 @@ sealed class SendFeeNotification(val config: NotificationConfig) {
iconResId = networkIconId,
buttonsState = onClick?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_buy_currency, wrappedList(feeName)),
text = resourceReference(
R.string.common_buy_currency,
wrappedList(
if (mergeFeeNetworkName) {
"$currencyName ($feeSymbol)"
} else {
feeName
},
),
),
onClick = onClick,
)
},

View file

@ -6,7 +6,6 @@ import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
internal class SendFeeStateConverter(
private val appCurrencyProvider: Provider<AppCurrency>,
@ -17,12 +16,7 @@ internal class SendFeeStateConverter(
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
return SendStates.FeeState(
feeSelectorState = FeeSelectorState.Loading,
isSubtractAvailable = false,
isSubtract = false,
isUserSubtracted = false,
fee = null,
receivedAmountValue = BigDecimal.ZERO,
receivedAmount = "",
notifications = persistentListOf(),
rate = cryptoCurrencyStatus.value.fiatRate,
appCurrency = appCurrencyProvider(),

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -31,9 +32,10 @@ internal class EthereumCustomFeeConverter(
) : Converter<Fee.Ethereum, ImmutableList<SendTextField.CustomFee>> {
override fun convert(value: Fee.Ethereum): ImmutableList<SendTextField.CustomFee> {
val feeValue = value.amount.value
return persistentListOf(
SendTextField.CustomFee(
value = value.amount.value?.parseBigDecimal(value.amount.decimals).orEmpty(),
value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(),
decimals = value.amount.decimals,
symbol = value.amount.currencySymbol,
onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT, it) },
@ -43,7 +45,7 @@ internal class EthereumCustomFeeConverter(
),
title = resourceReference(R.string.send_max_fee),
footer = resourceReference(R.string.send_max_fee_footer),
label = getFeeFormatted(value.amount.value),
label = getFeeFormatted(feeValue),
keyboardActions = KeyboardActions(),
),
SendTextField.CustomFee(
@ -67,7 +69,7 @@ internal class EthereumCustomFeeConverter(
footer = resourceReference(R.string.send_gas_limit_footer),
onValueChange = { clickIntents.onCustomFeeValueChange(GAS_LIMIT, it) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
imeAction = if (checkExceedBalance(feeValue)) ImeAction.None else ImeAction.Done,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(onDone = { clickIntents.onNextClick() }),
@ -133,7 +135,16 @@ internal class EthereumCustomFeeConverter(
label = getFeeFormatted(newFeeAmount),
),
)
set(index, this[index].copy(value = value))
set(
index,
this[index].copy(
value = value,
keyboardOptions = KeyboardOptions(
imeAction = if (!checkExceedBalance(newFeeAmount)) ImeAction.None else ImeAction.Done,
keyboardType = KeyboardType.Number,
),
),
)
}
}
}.toImmutableList()
@ -152,6 +163,13 @@ internal class EthereumCustomFeeConverter(
)
}
private fun checkExceedBalance(feeAmount: BigDecimal?): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val currencyCryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
return feeAmount == null || feeAmount.isZero() || feeAmount > currencyCryptoAmount
}
companion object {
private const val ETHEREUM_GAS_UNIT = "GWEI"
private const val ETHEREUM_GAS_DECIMALS = 18

View file

@ -1,9 +1,11 @@
package com.tangem.features.send.impl.presentation.state.fields
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.Provider
@ -19,7 +21,6 @@ internal class SendAmountFieldChangeConverter(
val state = currentStateProvider()
val amountState = state.amountState ?: return state
val amountTextField = amountState.amountTextField
val feeState = state.feeState ?: return state
if (value.isEmpty()) return state.emptyState()
val cryptoDecimals = amountTextField.cryptoAmount.decimals
@ -33,21 +34,22 @@ internal class SendAmountFieldChangeConverter(
val checkValue = if (amountTextField.isFiatValue) fiatValue else cryptoValue
val isExceedBalance = checkValue.checkExceedBalance(amountTextField)
val isMaxAmount = checkValue.checkMaxAmount(amountTextField)
val isZero = if (amountTextField.isFiatValue) decimalFiatValue.isZero() else decimalCryptoValue.isZero()
return state.copy(
amountState = amountState.copy(
isPrimaryButtonEnabled = !isExceedBalance,
isPrimaryButtonEnabled = !isExceedBalance && !isZero,
amountTextField = amountTextField.copy(
value = cryptoValue,
fiatValue = fiatValue,
isError = isExceedBalance,
cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
keyboardOptions = KeyboardOptions(
imeAction = if (!isExceedBalance) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
),
),
feeState = feeState.copy(
isSubtract = isMaxAmount,
),
)
}
@ -92,24 +94,9 @@ internal class SendAmountFieldChangeConverter(
val fiatDecimal = parseToBigDecimal(amountTextField.fiatAmount.decimals)
val cryptoDecimal = parseToBigDecimal(amountTextField.cryptoAmount.decimals)
return if (amountTextField.isFiatValue) {
fiatDecimal > currencyFiatAmount || fiatDecimal.isZero()
fiatDecimal > currencyFiatAmount
} else {
cryptoDecimal > currencyCryptoAmount || cryptoDecimal.isZero()
}
}
private fun String.checkMaxAmount(amountTextField: SendTextField.AmountField): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
// If current currency is Token
if (cryptoCurrencyStatus.currency is CryptoCurrency.Token) return false
val currencyCryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val currencyFiatAmount = cryptoCurrencyStatus.value.fiatAmount ?: BigDecimal.ZERO
return if (amountTextField.isFiatValue) {
parseToBigDecimal(amountTextField.fiatAmount.decimals) == currencyFiatAmount
} else {
parseToBigDecimal(amountTextField.cryptoAmount.decimals) == currencyCryptoAmount
cryptoDecimal > currencyCryptoAmount
}
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.common.extensions.isZero
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
@ -36,12 +37,13 @@ internal class SendAmountFieldConverter(
val fiatDecimal = cryptoCurrencyStatus.value.fiatRate?.multiply(cryptoDecimal) ?: BigDecimal.ZERO
fiatDecimal.parseBigDecimal(FIAT_DECIMALS)
}
val isDoneActionEnabled = !cryptoDecimal.isZero()
return SendTextField.AmountField(
value = value,
fiatValue = fiatValue,
onValueChange = clickIntents::onAmountValueChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
keyboardActions = KeyboardActions(onDone = { clickIntents.onNextClick() }),

View file

@ -1,5 +1,8 @@
package com.tangem.features.send.impl.presentation.state.fields
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
@ -17,7 +20,6 @@ internal class SendAmountFieldMaxAmountConverter(
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val amountState = state.amountState ?: return state
val amountTextField = amountState.amountTextField
val feeState = state.feeState ?: return state
val cryptoDecimals = amountTextField.cryptoAmount.decimals
val fiatDecimals = amountTextField.fiatAmount.decimals
@ -26,6 +28,7 @@ internal class SendAmountFieldMaxAmountConverter(
if (decimalCryptoValue.isNullOrZero()) return state
val isDoneActionEnabled = !decimalCryptoValue.isNullOrZero()
val cryptoValue = decimalCryptoValue?.parseBigDecimal(cryptoDecimals).orEmpty()
val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals).orEmpty()
return state.copy(
@ -37,11 +40,12 @@ internal class SendAmountFieldMaxAmountConverter(
isError = false,
cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue),
fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue),
keyboardOptions = KeyboardOptions(
imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None,
keyboardType = KeyboardType.Number,
),
),
),
feeState = feeState.copy(
isSubtract = true,
),
)
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.features.send.impl.presentation.state.recipient
import androidx.paging.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
@ -17,77 +16,66 @@ import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.utils.Provider
import com.tangem.utils.toFormattedCurrencyString
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.update
internal class SendRecipientListConverter(
private val currentStateProvider: Provider<SendUiState>,
private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus>,
) {
fun convert(wallets: List<AvailableWallet?>, txHistory: PagingData<TxHistoryItem>, txHistoryCount: Int) {
val filteredWallets = wallets.filterNotNull()
.groupBy { item -> item.name }
.values.flatten()
.mapIndexed { index, item ->
item.copy(
name = "${item.name} ${index.inc()}",
)
}
val walletsItem = getWalletItems(filteredWallets, txHistoryCount)
fun convert(wallets: List<AvailableWallet?>, txHistory: List<TxHistoryItem>): SendUiState {
val cryptoCurrency = cryptoCurrencyStatusProvider().currency
currentStateProvider().recipientList.update {
if (txHistoryCount == 0) {
PagingData.from(listOf(walletsItem))
} else {
txHistory.filter { item ->
val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer
val isNotContract = item.interactionAddressType is TxHistoryItem.InteractionAddressType.User
val isSingleAddress = if (item.isOutgoing) {
item.destinationType is TxHistoryItem.DestinationType.Single
} else {
item.sourceType is TxHistoryItem.SourceType.Single
}
isTransfer && isSingleAddress && isNotContract
}.map<TxHistoryItem, SendRecipientListContent> { tx ->
SendRecipientListContent.Item(
id = tx.txHash,
title = tx.extractAddress(),
subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()),
timestamp = tx.extractTimestamp(),
subtitleEndOffset = cryptoCurrency.symbol.length,
subtitleIconRes = tx.extractIconRes(),
)
}.insertWallets(walletsItem)
}
}
}
val state = currentStateProvider()
val recipientState = state.recipientState ?: return state
private fun PagingData<SendRecipientListContent>.insertWallets(
wallets: SendRecipientListContent.Wallets,
): PagingData<SendRecipientListContent> {
return insertSeparators(terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE) { before, after ->
return@insertSeparators when {
before == null && after is SendRecipientListContent.Item -> wallets
else -> null
}
}
}
private fun getWalletItems(wallets: List<AvailableWallet>, txHistoryCount: Int): SendRecipientListContent.Wallets {
return SendRecipientListContent.Wallets(
wallets.map {
SendRecipientListContent.Item(
id = it.address,
title = TextReference.Str(it.address),
subtitle = TextReference.Str(it.name),
)
}.toPersistentList(),
isWalletsOnly = txHistoryCount == 0,
return state.copy(
recipientState = recipientState.copy(
wallets = wallets.filterWallets(),
recent = txHistory.filterRecipients(cryptoCurrency),
),
)
}
private fun List<AvailableWallet?>.filterWallets() = this.filterNotNull()
.groupBy { item -> item.name }
.values.map {
it.mapIndexed { index, item ->
val name = if (it.size > 1) {
"${item.name} ${index.inc()}"
} else {
item.name
}
SendRecipientListContent(
id = item.address,
title = TextReference.Str(item.address),
subtitle = TextReference.Str(name),
)
}
}
.flatten()
.toPersistentList()
private fun List<TxHistoryItem>.filterRecipients(cryptoCurrency: CryptoCurrency) = this.filter { item ->
val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer
val isNotContract = item.interactionAddressType is TxHistoryItem.InteractionAddressType.User
val isSingleAddress = if (item.isOutgoing) {
item.destinationType is TxHistoryItem.DestinationType.Single
} else {
item.sourceType is TxHistoryItem.SourceType.Single
}
isTransfer && isSingleAddress && isNotContract
}
.take(RECENT_LIST_SIZE)
.map { tx ->
SendRecipientListContent(
id = tx.txHash,
title = tx.extractAddress(),
subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()),
timestamp = tx.extractTimestamp(),
subtitleEndOffset = cryptoCurrency.symbol.length,
subtitleIconRes = tx.extractIconRes(),
)
}.toPersistentList()
private fun TxHistoryItem.extractAddress(): TextReference = if (isOutgoing) {
when (val destination = destinationType) {
is TxHistoryItem.DestinationType.Multiple -> TextReference.Res(
@ -122,4 +110,8 @@ internal class SendRecipientListConverter(
val time = timestampInMillis.toTimeFormat()
return TextReference.Res(R.string.send_date_format, wrappedList(date, time))
}
companion object {
private const val RECENT_LIST_SIZE = 10
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
internal class SendRecipientStateConverter(
private val clickIntents: SendClickIntents,
@ -25,6 +26,8 @@ internal class SendRecipientStateConverter(
memoTextField = memoFieldConverter.convertOrNull(),
network = cryptoCurrencyStatusProvider().currency.network.name,
isPrimaryButtonEnabled = false,
wallets = persistentListOf(),
recent = persistentListOf(),
)
}
}

View file

@ -14,8 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -24,25 +22,29 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.extensions.shareText
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
@Composable
internal fun SendNavigationButtons(uiState: SendUiState) {
internal fun SendNavigationButtons(uiState: SendUiState, currentState: SendUiCurrentScreen) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = TangemTheme.dimens.spacing12),
) {
SendSecondaryNavigationButton(uiState)
SendSecondaryNavigationButton(
uiState = uiState,
currentState = currentState,
)
SendPrimaryNavigationButton(
uiState = uiState,
currentState = currentState,
modifier = Modifier
.weight(1f)
.padding(horizontal = TangemTheme.dimens.spacing16),
@ -51,12 +53,12 @@ internal fun SendNavigationButtons(uiState: SendUiState) {
}
@Composable
private fun SendSecondaryNavigationButton(uiState: SendUiState) {
val currentState = uiState.currentState.collectAsState()
private fun SendSecondaryNavigationButton(uiState: SendUiState, currentState: SendUiCurrentScreen) {
val isEditingDisabled = uiState.isEditingDisabled
val isCorrectScreen = currentState.value == SendUiStateType.Amount || currentState.value == SendUiStateType.Fee
val isFromConfirmation = currentState.isFromConfirmation
val isCorrectScreen = currentState.type == SendUiStateType.Amount || currentState.type == SendUiStateType.Fee
AnimatedVisibility(
visible = !isEditingDisabled && isCorrectScreen,
visible = !isEditingDisabled && isCorrectScreen && !isFromConfirmation,
enter = expandHorizontally(expandFrom = Alignment.End),
exit = shrinkHorizontally(shrinkTowards = Alignment.End),
) {
@ -77,8 +79,11 @@ private fun SendSecondaryNavigationButton(uiState: SendUiState) {
}
@Composable
private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier = Modifier) {
val currentState = uiState.currentState.collectAsStateWithLifecycle()
private fun SendPrimaryNavigationButton(
uiState: SendUiState,
currentState: SendUiCurrentScreen,
modifier: Modifier = Modifier,
) {
val isSuccess = uiState.sendState.isSuccess
val isSending = uiState.sendState.isSending
val txUrl = uiState.sendState.txUrl
@ -100,7 +105,7 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier
modifier = modifier,
) { textId ->
when {
currentState.value == SendUiStateType.Send && !isSuccess -> {
currentState.type == SendUiStateType.Send && !isSuccess -> {
val hapticFeedback = rememberHapticFeedback(state = currentState, onAction = buttonClick)
PrimaryButtonIconEnd(
text = stringResource(textId),
@ -110,11 +115,11 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier
showProgress = isSending,
)
}
currentState.value == SendUiStateType.Send && isSuccess -> {
currentState.type == SendUiStateType.Send && isSuccess -> {
PrimaryButtonsDone(
textRes = textId,
txUrl = txUrl,
onExploreClick = { uiState.clickIntents.onExploreClick(txUrl) },
onExploreClick = uiState.clickIntents::onExploreClick,
onShareClick = uiState.clickIntents::onShareClick,
onDoneClick = buttonClick,
modifier = Modifier,
@ -177,15 +182,19 @@ private fun PrimaryButtonsDone(
private fun getButtonData(
uiState: SendUiState,
currentState: State<SendUiStateType>,
currentState: SendUiCurrentScreen,
isSuccess: Boolean,
): Pair<Int, () -> Unit> {
return when (currentState.value) {
return when (currentState.type) {
SendUiStateType.None,
SendUiStateType.Amount,
SendUiStateType.Recipient,
SendUiStateType.Fee,
-> R.string.common_next to uiState.clickIntents::onNextClick
-> if (currentState.isFromConfirmation) {
R.string.common_continue to uiState.clickIntents::onNextClick
} else {
R.string.common_next to uiState.clickIntents::onNextClick
}
SendUiStateType.Send -> if (isSuccess) {
R.string.common_close
} else {
@ -194,8 +203,8 @@ private fun getButtonData(
}
}
private fun isButtonEnabled(currentState: State<SendUiStateType>, uiState: SendUiState): Boolean {
return when (currentState.value) {
private fun isButtonEnabled(currentState: SendUiCurrentScreen, uiState: SendUiState): Boolean {
return when (currentState.type) {
SendUiStateType.Amount -> uiState.amountState?.isPrimaryButtonEnabled ?: false
SendUiStateType.Recipient -> uiState.recipientState?.isPrimaryButtonEnabled ?: false
SendUiStateType.Fee -> uiState.feeState?.isPrimaryButtonEnabled ?: false

View file

@ -1,35 +1,34 @@
package com.tangem.features.send.impl.presentation.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.SendUiStateType
import com.tangem.features.send.impl.presentation.ui.amount.SendAmountContent
import com.tangem.features.send.impl.presentation.ui.fee.SendSpeedAndFeeContent
import com.tangem.features.send.impl.presentation.ui.recipient.SendRecipientContent
import com.tangem.features.send.impl.presentation.ui.send.SendContent
import kotlinx.coroutines.flow.StateFlow
@Composable
internal fun SendScreen(uiState: SendUiState) {
val currentState = uiState.currentState.collectAsStateWithLifecycle()
val isSuccess = uiState.sendState.isSuccess
internal fun SendScreen(uiState: SendUiState, currentStateFlow: StateFlow<SendUiCurrentScreen>) {
val currentState = currentStateFlow.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
BackHandler { uiState.clickIntents.onBackClick() }
Column(
@ -40,14 +39,14 @@ internal fun SendScreen(uiState: SendUiState) {
.background(color = TangemTheme.colors.background.tertiary),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val titleRes = when (currentState.value) {
val titleRes = when (currentState.value.type) {
SendUiStateType.Amount -> R.string.send_amount_label
SendUiStateType.Recipient -> R.string.send_recipient_label
SendUiStateType.Fee -> R.string.common_fee_selector_title
SendUiStateType.Send -> if (!isSuccess) R.string.send_confirm_label else null
SendUiStateType.Send -> if (!uiState.sendState.isSuccess) R.string.send_confirm_label else null
else -> null
}
val iconRes = if (currentState.value == SendUiStateType.Recipient) {
val iconRes = if (currentState.value.type == SendUiStateType.Recipient) {
R.drawable.ic_qrcode_scan_24
} else {
null
@ -63,11 +62,11 @@ internal fun SendScreen(uiState: SendUiState) {
)
SendScreenContent(
uiState = uiState,
currentState = currentState,
currentState = currentState.value,
modifier = Modifier
.weight(1f),
)
SendNavigationButtons(uiState)
SendNavigationButtons(uiState, currentState.value)
}
SendEventEffect(
@ -77,18 +76,26 @@ internal fun SendScreen(uiState: SendUiState) {
}
@Composable
private fun SendScreenContent(
uiState: SendUiState,
currentState: State<SendUiStateType>,
modifier: Modifier = Modifier,
) {
val recipientList = uiState.recipientList.collectAsLazyPagingItems()
private fun SendScreenContent(uiState: SendUiState, currentState: SendUiCurrentScreen, modifier: Modifier = Modifier) {
var lastState by remember { mutableIntStateOf(currentState.type.ordinal) }
val direction = remember(currentState.type.ordinal) {
if (lastState < currentState.type.ordinal) {
AnimatedContentTransitionScope.SlideDirection.Start
} else {
AnimatedContentTransitionScope.SlideDirection.End
}
}
AnimatedContent(
targetState = currentState.value,
targetState = currentState,
label = "Send Scree Navigation",
modifier = modifier,
transitionSpec = {
lastState = currentState.type.ordinal
slideIntoContainer(towards = direction, animationSpec = tween())
.togetherWith(slideOutOfContainer(towards = direction, animationSpec = tween()))
},
) { state ->
when (state) {
when (state.type) {
SendUiStateType.Amount -> SendAmountContent(
amountState = uiState.amountState,
isBalanceHiding = uiState.isBalanceHidden,
@ -97,7 +104,6 @@ private fun SendScreenContent(
SendUiStateType.Recipient -> SendRecipientContent(
uiState = uiState.recipientState,
clickIntents = uiState.clickIntents,
recipientList = recipientList,
)
SendUiStateType.Fee -> SendSpeedAndFeeContent(
state = uiState.feeState,

View file

@ -1,5 +1,6 @@
package com.tangem.features.send.impl.presentation.ui.fee
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -20,7 +21,10 @@ internal fun SendCustomFeeEthereum(
selectedFee: FeeType,
modifier: Modifier = Modifier,
) {
if (selectedFee == FeeType.Custom && customValues.isNotEmpty()) {
AnimatedVisibility(
visible = selectedFee == FeeType.Custom && customValues.isNotEmpty(),
label = "Custom Fee Selected Animation",
) {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
modifier = modifier,

View file

@ -15,7 +15,6 @@ import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
@ -41,8 +40,6 @@ internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: S
topNotifications(notifications)
customFee(feeSendState)
middleNotifications(notifications)
subtractButton(state, clickIntents)
bottomNotifications(notifications)
}
}
@ -77,21 +74,7 @@ private fun LazyListScope.middleNotifications(
modifier: Modifier = Modifier,
) {
notifications(
configs = configs.filter {
it is SendFeeNotification.Warning.TooLow ||
it is SendFeeNotification.Warning.TooHigh
}.toImmutableList(),
modifier = modifier,
)
}
private fun LazyListScope.bottomNotifications(
configs: ImmutableList<SendFeeNotification>,
modifier: Modifier = Modifier,
) {
notifications(
configs = configs.filterIsInstance<SendFeeNotification.Warning.NetworkCoverage>().toImmutableList(),
isLast = true,
configs = configs.filterIsInstance<SendFeeNotification.Warning.TooHigh>().toImmutableList(),
modifier = modifier,
)
}
@ -159,36 +142,4 @@ internal fun LazyListScope.customFee(feeSendState: FeeSelectorState, modifier: M
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.subtractButton(
state: SendStates.FeeState,
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
val receivedAmount = state.receivedAmount
val isSubtract = state.isSubtract
val isSubtractAvailable = state.isSubtractAvailable
val feeSendState = state.feeSelectorState
if (isSubtractAvailable) {
item {
val feeStateContent = feeSendState as? FeeSelectorState.Content
val isCustomAvailable = feeStateContent?.customValues.isNullOrEmpty().not()
val isCustomSelected = feeStateContent?.selectedFee == FeeType.Custom
val topPadding = if (isCustomSelected && isCustomAvailable) {
TangemTheme.dimens.spacing12
} else {
TangemTheme.dimens.spacing20
}
SendSpeedSubtract(
receivingAmount = receivedAmount,
isSubtract = isSubtract,
onSelectClick = clickIntents::onSubtractSelect,
modifier = modifier
.padding(top = topPadding)
.animateItemPlacement(),
)
}
}
}

View file

@ -10,18 +10,23 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
@ -43,7 +48,6 @@ import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import java.math.BigDecimal
@ -60,10 +64,7 @@ internal fun SendSpeedSelector(
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
FooterContainer(
footer = stringResource(R.string.common_fee_selector_footer),
modifier = modifier,
) {
Column(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
@ -116,10 +117,7 @@ internal fun SendSpeedSelector(
visible = fees.normal is Fee.Ethereum,
label = "Custom fee appearance animation",
) {
val showWarning = state.notifications.any {
it is SendFeeNotification.Warning.TooHigh ||
it is SendFeeNotification.Warning.TooLow
}
val showWarning = state.notifications.any { it is SendFeeNotification.Warning.TooHigh }
SendSpeedSelectorItem(
titleRes = R.string.common_fee_selector_option_custom,
iconRes = R.drawable.ic_edit_24,
@ -147,9 +145,43 @@ internal fun SendSpeedSelector(
}
}
}
FooterText(clickIntents::onReadMoreClick)
}
}
@Composable
private fun FooterText(onReadMoreClick: () -> Unit) {
val linkText = stringResource(R.string.common_read_more)
val fullString = stringResource(R.string.common_fee_selector_footer, linkText)
val linkTextPosition = fullString.length - linkText.length
val defaultStyle = TangemTheme.colors.text.tertiary
val linkStyle = TangemTheme.colors.text.accent
val annotatedString = remember(defaultStyle, linkStyle) {
buildAnnotatedString {
withStyle(SpanStyle(defaultStyle)) {
append(fullString.substring(0, linkTextPosition))
}
withStyle(SpanStyle(linkStyle)) {
append(fullString.substring(linkTextPosition, fullString.length))
}
}
}
val click = { i: Int ->
val readMoreStyle = requireNotNull(annotatedString.spanStyles.getOrNull(1))
if (i in readMoreStyle.start..readMoreStyle.end) {
onReadMoreClick()
}
}
ClickableText(
text = annotatedString,
style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Start),
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
onClick = click,
)
}
// todo remove after refactoring [REDACTED_JIRA]
private fun getCryptoReference(amount: Amount, isFeeApproximate: Boolean) = combinedReference(
if (isFeeApproximate) stringReference("$CAN_BE_LOWER_SIGN ") else TextReference.EMPTY,

View file

@ -1,61 +0,0 @@
package com.tangem.features.send.impl.presentation.ui.fee
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.TangemSwitch
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
@Composable
internal fun SendSpeedSubtract(
receivingAmount: String,
isSubtract: Boolean,
onSelectClick: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val footerText = if (isSubtract) {
stringResource(R.string.send_amount_substract_footer, receivingAmount)
} else {
null
}
FooterContainer(
footer = footerText,
modifier = modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.padding(
vertical = TangemTheme.dimens.spacing16,
horizontal = TangemTheme.dimens.spacing20,
),
) {
Text(
text = stringResource(R.string.send_amount_substract),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing12),
)
TangemSwitch(
checked = isSubtract,
onCheckedChange = onSelectClick,
)
}
}
}

View file

@ -3,9 +3,7 @@ package com.tangem.features.send.impl.presentation.ui.recipient
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
@ -18,9 +16,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.constraintlayout.compose.Visibility
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.icons.identicon.IdentIcon
@ -39,7 +34,6 @@ import com.tangem.features.send.impl.R
* @param subtitleEndOffset offset for subtitle ellipsis
* @param subtitleIconRes icon
*/
@Suppress("DestructuringDeclarationWithTooManyEntries", "LongMethod")
@Composable
fun ListItemWithIcon(
title: String,
@ -51,80 +45,60 @@ fun ListItemWithIcon(
@DrawableRes subtitleIconRes: Int? = null,
) {
val hapticFeedback = rememberHapticFeedback(state = title, onAction = onClick)
ConstraintLayout(
Row(
modifier = modifier
.fillMaxWidth()
.clickable { hapticFeedback() }
.padding(horizontal = TangemTheme.dimens.spacing12),
) {
val (iconRef, titleRef, subtitleRef, subtitleIconRef) = createRefs()
val spacing2 = TangemTheme.dimens.spacing2
val spacing8 = TangemTheme.dimens.spacing8
val spacing10 = TangemTheme.dimens.spacing10
val spacing12 = TangemTheme.dimens.spacing12
IdentIcon(
address = title,
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing8)
.size(TangemTheme.dimens.size40)
.clip(RoundedCornerShape(TangemTheme.dimens.radius20))
.constrainAs(iconRef) {
start.linkTo(parent.start)
top.linkTo(parent.top, margin = spacing8)
bottom.linkTo(parent.bottom, margin = spacing8)
},
.clip(RoundedCornerShape(TangemTheme.dimens.radius20)),
)
EllipsisText(
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Justify,
ellipsis = TextEllipsis.Middle,
Column(
modifier = Modifier
.constrainAs(titleRef) {
start.linkTo(iconRef.end, margin = spacing12)
end.linkTo(parent.end)
top.linkTo(parent.top, margin = spacing10)
width = Dimension.fillToConstraints
},
)
Icon(
painter = painterResource(id = subtitleIconRes ?: R.drawable.ic_arrow_down_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f), CircleShape)
.constrainAs(subtitleIconRef) {
start.linkTo(iconRef.end, margin = spacing12)
top.linkTo(titleRef.bottom)
bottom.linkTo(parent.bottom, margin = spacing10)
visibility = if (subtitleIconRes == null) Visibility.Gone else Visibility.Visible
},
)
val (text, offset) = remember(subtitle, info) {
if (info != null) {
val suffix = ", $info"
subtitle + suffix to suffix.length + subtitleEndOffset
} else {
subtitle to 0
.padding(vertical = TangemTheme.dimens.spacing10)
.padding(start = TangemTheme.dimens.spacing12),
) {
EllipsisText(
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Justify,
ellipsis = TextEllipsis.Middle,
modifier = Modifier,
)
Row {
if (subtitleIconRes != null) {
Icon(
painter = painterResource(id = subtitleIconRes),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.background(TangemTheme.colors.background.tertiary, CircleShape),
)
}
val (text, offset) = remember(subtitle, info) {
if (info != null) {
val suffix = ", $info"
subtitle + suffix to suffix.length + subtitleEndOffset
} else {
subtitle to 0
}
}
EllipsisText(
text = text,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset),
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
)
}
}
EllipsisText(
text = text,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset),
modifier = Modifier
.constrainAs(subtitleRef) {
start.linkTo(subtitleIconRef.end, margin = spacing2, goneMargin = spacing12)
end.linkTo(parent.end)
top.linkTo(titleRef.bottom)
bottom.linkTo(parent.bottom, margin = spacing10)
width = Dimension.fillToConstraints
},
)
}
}

View file

@ -1,8 +1,9 @@
package com.tangem.features.send.impl.presentation.ui.recipient
import androidx.annotation.StringRes
import androidx.compose.animation.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -17,9 +18,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import com.tangem.core.ui.components.inputrow.InputRowRecipient
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
@ -29,18 +27,17 @@ import com.tangem.features.send.impl.presentation.domain.SendRecipientListConten
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.ui.common.FooterContainer
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import kotlinx.collections.immutable.ImmutableList
private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY"
private const val MEMO_FIELD_KEY = "MEMO_FIELD_KEY"
private const val MY_WALLETS_HEADER_KEY = "MY_WALLETS_HEADER_KEY"
@Composable
internal fun SendRecipientContent(
uiState: SendStates.RecipientState?,
clickIntents: SendClickIntents,
recipientList: LazyPagingItems<SendRecipientListContent>,
) {
internal fun SendRecipientContent(uiState: SendStates.RecipientState?, clickIntents: SendClickIntents) {
if (uiState == null) return
val recipients = uiState.recent
val wallets = uiState.wallets
val memoField = uiState.memoTextField
val address = uiState.addressTextField
val isValidating by remember(uiState.isValidating) { derivedStateOf { uiState.isValidating } }
val isError by remember(address.isError) { derivedStateOf { address.isError } }
@ -72,7 +69,7 @@ internal fun SendRecipientContent(
)
}
}
uiState.memoTextField?.let { memoField ->
if (memoField != null) {
item(key = MEMO_FIELD_KEY) {
val placeholder = if (memoField.isEnabled) memoField.placeholder else memoField.disabledText
TextFieldWithPaste(
@ -81,7 +78,7 @@ internal fun SendRecipientContent(
placeholder = placeholder,
footer = stringResource(R.string.send_recipient_memo_footer),
onValueChange = memoField.onValueChange,
onPasteClick = { clickIntents.onRecipientMemoValueChange(it, isPasted = true) },
onPasteClick = clickIntents::onRecipientMemoValueChange,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing20),
isError = memoField.isError,
error = memoField.error,
@ -89,144 +86,115 @@ internal fun SendRecipientContent(
)
}
}
recipientListItem(
recipientList = recipientList,
clickIntents = clickIntents,
listHeaderItem(
titleRes = R.string.send_recipient_wallets_title,
isVisible = wallets.isNotEmpty() && wallets.first().isVisible,
isFirst = true,
)
listItem(wallets, clickIntents, isLast = recipients.isEmpty())
listHeaderItem(
titleRes = R.string.send_recent_transactions,
isVisible = recipients.isNotEmpty() && recipients.first().isVisible,
isFirst = wallets.isEmpty(),
)
listItem(recipients, clickIntents, isLast = true)
}
}
@Suppress("LongMethod")
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.recipientListItem(
recipientList: LazyPagingItems<SendRecipientListContent>,
private fun LazyListScope.listHeaderItem(@StringRes titleRes: Int, isVisible: Boolean, isFirst: Boolean) {
item(
key = titleRes,
) {
AnimatedVisibility(
visible = isVisible,
label = "Header Appearance Animation",
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier = Modifier
.animateItemPlacement()
.animateContentSize(),
) {
val (topPadding, paddingFromTop) = if (isFirst) {
TangemTheme.dimens.spacing20 to TangemTheme.dimens.spacing12
} else {
TangemTheme.dimens.spacing0 to TangemTheme.dimens.spacing8
}
val topRadius = if (isFirst) {
TangemTheme.dimens.radius12
} else {
TangemTheme.dimens.radius0
}
Text(
text = stringResource(titleRes),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(top = topPadding)
.clip(
RoundedCornerShape(
topEnd = topRadius,
topStart = topRadius,
),
)
.background(TangemTheme.colors.background.action)
.padding(
top = paddingFromTop,
bottom = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.listItem(
list: ImmutableList<SendRecipientListContent>,
clickIntents: SendClickIntents,
isLast: Boolean,
) {
items(
count = recipientList.itemCount,
key = recipientList.itemKey {
when (it) {
is SendRecipientListContent.Wallets -> MY_WALLETS_HEADER_KEY
is SendRecipientListContent.Item -> it.id
}
},
contentType = recipientList.itemContentType { it::class.java },
count = list.size,
key = { list[it].id },
contentType = { list[it]::class.java },
) { index ->
recipientList[index]?.let { item ->
when (item) {
is SendRecipientListContent.Wallets -> {
RecipientWalletListItem(
item = item,
clickIntents = clickIntents,
modifier = Modifier
.animateItemPlacement()
.padding(top = TangemTheme.dimens.spacing20)
.then(
if (index == 0) {
val bottomRadius = if (item.isWalletsOnly) {
TangemTheme.dimens.radius12
} else {
TangemTheme.dimens.radius0
}
Modifier.clip(
RoundedCornerShape(
topEnd = TangemTheme.dimens.radius12,
topStart = TangemTheme.dimens.radius12,
bottomStart = bottomRadius,
bottomEnd = bottomRadius,
),
)
} else {
Modifier
},
),
)
}
is SendRecipientListContent.Item -> {
val title = item.title.resolveReference()
ListItemWithIcon(
title = item.title.resolveReference(),
subtitle = item.subtitle.resolveReference(),
info = item.timestamp?.resolveReference(),
subtitleEndOffset = item.subtitleEndOffset,
subtitleIconRes = item.subtitleIconRes,
modifier = Modifier
.then(
if (index == recipientList.itemCount - 1) {
Modifier
.padding(bottom = TangemTheme.dimens.spacing20)
.clip(
RoundedCornerShape(
bottomEnd = TangemTheme.dimens.radius12,
bottomStart = TangemTheme.dimens.radius12,
),
)
} else {
Modifier
},
)
.background(TangemTheme.colors.background.action),
onClick = {
clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress)
val item = list[index]
val title = item.title.resolveReference()
AnimatedVisibility(
visible = item.isVisible,
label = "Header Appearance Animation",
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier = Modifier
.animateItemPlacement()
.animateContentSize(),
) {
ListItemWithIcon(
title = title,
subtitle = item.subtitle.resolveReference(),
info = item.timestamp?.resolveReference(),
subtitleEndOffset = item.subtitleEndOffset,
subtitleIconRes = item.subtitleIconRes,
onClick = { clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress) },
modifier = Modifier
.then(
if (isLast && index == list.lastIndex) {
Modifier
.padding(bottom = TangemTheme.dimens.spacing12)
.clip(
shape = RoundedCornerShape(
bottomStart = TangemTheme.dimens.radius16,
bottomEnd = TangemTheme.dimens.radius16,
),
)
} else {
Modifier
},
)
}
}
}
}
}
@Composable
private fun RecipientWalletListItem(
item: SendRecipientListContent.Wallets,
clickIntents: SendClickIntents,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.background(TangemTheme.colors.background.action)
.padding(top = TangemTheme.dimens.spacing12),
) {
if (item.list.isNotEmpty()) {
Text(
text = stringResource(R.string.send_recipient_wallets_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing8,
),
)
}
item.list.forEachIndexed { _, wallet ->
val title = wallet.title.resolveReference()
ListItemWithIcon(
title = wallet.title.resolveReference(),
subtitle = wallet.subtitle.resolveReference(),
onClick = { clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress) },
)
}
if (!item.isWalletsOnly) {
val topPadding = if (item.list.isNotEmpty()) {
TangemTheme.dimens.spacing8
} else {
TangemTheme.dimens.spacing0
}
Text(
text = stringResource(R.string.send_recent_transactions),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.fillMaxWidth()
.padding(
top = topPadding,
bottom = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
.background(TangemTheme.colors.background.action),
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.send.impl.presentation.viewmodel
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.state.SendNotification
import com.tangem.features.send.impl.presentation.state.fee.FeeType
@Suppress("TooManyFunctions")
@ -33,7 +34,7 @@ internal interface SendClickIntents {
// region Recipient
fun onRecipientAddressValueChange(value: String, type: EnterAddressSource? = null)
fun onRecipientMemoValueChange(value: String, isPasted: Boolean = false)
fun onRecipientMemoValueChange(value: String)
// endregion
// region Fee
@ -43,7 +44,9 @@ internal interface SendClickIntents {
fun onCustomFeeValueChange(index: Int, value: String)
fun onSubtractSelect(value: Boolean)
fun onSubtractSelect()
fun onReadMoreClick()
// endregion
// region Send
@ -55,12 +58,14 @@ internal interface SendClickIntents {
fun showFee()
fun onExploreClick(txUrl: String)
fun showSend()
fun onExploreClick()
fun onShareClick()
fun onAmountReduceClick(reducedAmount: String)
fun onAmountReduceClick(reducedAmount: String, clazz: Class<out SendNotification>)
fun onAmountReduceIgnoreClick()
fun onNotificationCancel(clazz: Class<out SendNotification>)
// endregion
}

View file

@ -4,7 +4,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import androidx.paging.PagingData
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchain.common.TransactionData
@ -27,8 +26,7 @@ import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.txhistory.models.TxHistoryItem
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.txhistory.usecase.GetFixedTxHistoryItemsUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
@ -39,23 +37,21 @@ import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource
import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.analytics.SendScreenSource
import com.tangem.features.send.impl.presentation.analytics.utils.SendOnNextScreenAnalyticSender
import com.tangem.features.send.impl.presentation.analytics.utils.SendRecipientAnalyticsSender
import com.tangem.features.send.impl.presentation.domain.AvailableWallet
import com.tangem.features.send.impl.presentation.state.*
import com.tangem.features.send.impl.presentation.state.amount.AmountStateFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeNotificationFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.*
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
import kotlin.properties.Delegates
@ -70,8 +66,7 @@ internal class SendViewModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase,
private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase,
private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val createTransactionUseCase: CreateTransactionUseCase,
@ -104,7 +99,8 @@ internal class SendViewModel @Inject constructor(
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
private var innerRouter: InnerSendRouter by Delegates.notNull()
private var stateRouter: StateRouter by Delegates.notNull()
var stateRouter: StateRouter by Delegates.notNull()
private set
private val stateFactory = SendStateFactory(
clickIntents = this,
@ -124,7 +120,6 @@ internal class SendViewModel @Inject constructor(
private val feeStateFactory = FeeStateFactory(
clickIntents = this,
currentStateProvider = Provider { uiState },
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
isFeeApproximateUseCase = isFeeApproximateUseCase,
@ -141,8 +136,10 @@ internal class SendViewModel @Inject constructor(
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
stateRouterProvider = Provider { stateRouter },
clickIntents = this,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
analyticsEventHandler = analyticsEventHandler,
)
private val sendNotificationFactory = SendNotificationFactory(
@ -150,16 +147,16 @@ internal class SendViewModel @Inject constructor(
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
currentStateProvider = Provider { uiState },
userWalletProvider = Provider { userWallet },
stateRouterProvider = Provider { stateRouter },
currencyChecksRepository = currencyChecksRepository,
clickIntents = this,
analyticsEventHandler = analyticsEventHandler,
)
private val sendOnNextScreenAnalyticSender by lazy(LazyThreadSafetyMode.NONE) {
SendOnNextScreenAnalyticSender(analyticsEventHandler)
}
private val sendRecipientAnalyticsSender by lazy(LazyThreadSafetyMode.NONE) {
SendRecipientAnalyticsSender(analyticsEventHandler)
}
// todo convert to StateFlow
var uiState: SendUiState by mutableStateOf(stateFactory.getInitialState())
private set
@ -170,6 +167,7 @@ internal class SendViewModel @Inject constructor(
private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull()
private var balanceJobHolder = JobHolder()
private var balanceHidingJobHolder = JobHolder()
private var recipientsJobHolder = JobHolder()
private var feeJobHolder = JobHolder()
private var addressValidationJobHolder = JobHolder()
@ -179,26 +177,34 @@ internal class SendViewModel @Inject constructor(
private var sendIdleTimer = 0L
init {
subscribeOnCurrencyStatusUpdates()
subscribeOnBalanceHidden()
}
override fun onCreate(owner: LifecycleOwner) {
subscribeOnCurrencyStatusUpdates(owner)
onStateActive()
subscribeOnBalanceHidden(owner)
analyticsEventHandler.send(SendAnalyticEvents.SendOpened)
}
override fun onCleared() {
super.onCleared()
balanceHidingJobHolder.cancel()
balanceJobHolder.cancel()
stateRouter.clear()
}
fun setRouter(router: InnerSendRouter, stateRouter: StateRouter) {
innerRouter = router
this.stateRouter = stateRouter
uiState = uiState.copy(currentState = stateRouter.currentState)
}
private fun subscribeOnCurrencyStatusUpdates(owner: LifecycleOwner) {
private fun subscribeOnCurrencyStatusUpdates() {
viewModelScope.launch(dispatchers.main) {
getUserWalletUseCase(userWalletId).fold(
ifRight = { wallet ->
userWallet = wallet
checkIfSubtractAvailable()
getCurrenciesStatusUpdates(owner, wallet)
getCurrenciesStatusUpdates(wallet)
},
ifLeft = {
uiState = eventStateFactory.getGenericErrorState(
@ -210,23 +216,22 @@ internal class SendViewModel @Inject constructor(
}
}
private fun subscribeOnBalanceHidden(owner: LifecycleOwner) {
private fun subscribeOnBalanceHidden() {
getBalanceHidingSettingsUseCase()
.flowWithLifecycle(owner.lifecycle)
.conflate()
.distinctUntilChanged()
.onEach {
uiState = stateFactory.getOnHideBalanceState(isBalanceHidden = it.isBalanceHidden)
}
.launchIn(viewModelScope)
.saveIn(balanceHidingJobHolder)
}
private fun getCurrenciesStatusUpdates(owner: LifecycleOwner, wallet: UserWallet) {
private fun getCurrenciesStatusUpdates(wallet: UserWallet) {
val isSingleWallet = wallet.scanResponse.walletData?.token != null && !wallet.isMultiCurrency
if (cryptoCurrency is CryptoCurrency.Coin) {
getCurrencyStatusUpdates(isSingleWallet = isSingleWallet)
.flowWithLifecycle(owner.lifecycle)
.onEach { currencyStatus ->
currencyStatus.onRight {
onDataLoaded(
@ -249,7 +254,7 @@ internal class SendViewModel @Inject constructor(
coinCurrencyStatus = coinStatus.getOrElse { error("Coin status is unreachable") },
)
}
}.flowWithLifecycle(owner.lifecycle)
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
.saveIn(balanceJobHolder)
@ -300,12 +305,10 @@ internal class SendViewModel @Inject constructor(
combine(
flow = getUserWallets().conflate(),
flow2 = getTxHistory().conflate(),
flow3 = getTxHistoryCount().conflate(),
) { wallets, txHistory, txHistoryCount ->
stateFactory.onLoadedRecipientList(
) { wallets, txHistory ->
uiState = stateFactory.onLoadedRecipientList(
wallets = wallets,
txHistory = txHistory,
txHistoryCount = txHistoryCount,
)
}
.flowOn(dispatchers.io)
@ -322,7 +325,7 @@ internal class SendViewModel @Inject constructor(
.filterNot { it.walletId == userWalletId || it.isLocked }
.map { wallet ->
async(dispatchers.io) {
getCryptoCurrenciesUseCase(wallet.walletId)
getCryptoCurrenciesUseCase.getSync(wallet.walletId)
.fold(
ifRight = { currencyItem ->
val walletCurrency = currencyItem.firstOrNull {
@ -347,34 +350,20 @@ internal class SendViewModel @Inject constructor(
}
}
private fun getTxHistory(): Flow<PagingData<TxHistoryItem>> {
return flow {
txHistoryItemsUseCase(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifRight = { emitAll(it.distinctUntilChanged()) },
ifLeft = {},
)
}
}
private fun getTxHistoryCount(): Flow<Int> {
return flow {
txHistoryItemsCountUseCase(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifRight = { emit(it) },
ifLeft = { emit(0) },
)
}
private fun getTxHistory(): Flow<List<TxHistoryItem>> {
return getFixedTxHistoryItemsUseCase(
userWalletId = userWalletId,
currency = cryptoCurrency,
).fold(
ifRight = { it.distinctUntilChanged() },
ifLeft = { emptyFlow() },
)
}
private fun onStateActive() {
uiState.currentState
stateRouter.currentState
.onEach {
when (it) {
when (it.type) {
SendUiStateType.Fee -> loadFee()
SendUiStateType.Send -> sendIdleTimer = System.currentTimeMillis()
else -> Unit
@ -407,8 +396,29 @@ internal class SendViewModel @Inject constructor(
override fun popBackStack() = stateRouter.popBackStack()
override fun onBackClick() = stateRouter.onBackClick(uiState.sendState.isSuccess)
override fun onNextClick() {
val prevScreen = stateRouter.onNextClick()
sendOnNextScreenAnalyticSender.send(prevScreen, uiState)
val currentState = stateRouter.currentState.value
val isCurrentFee = currentState.type == SendUiStateType.Fee
if (isCurrentFee) {
val isFeeCoverage = checkFeeCoverage(uiState, cryptoCurrencyStatus)
if (isAmountSubtractAvailable && isFeeCoverage) {
uiState = eventStateFactory.getFeeCoverageAlert(
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
return
} else {
uiState = stateFactory.onSubtractSelect(false)
analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount(false))
}
if (checkIfFeeTooLow(uiState)) {
uiState = eventStateFactory.getFeeTooLowAlert(
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
return
}
}
sendOnNextScreenAnalyticSender.send(currentState.type, uiState)
stateRouter.onNextClick()
}
override fun onPrevClick() = stateRouter.onPrevClick()
@ -428,7 +438,6 @@ internal class SendViewModel @Inject constructor(
// region amount state clicks
override fun onCurrencyChangeClick(isFiat: Boolean) {
analyticsEventHandler.send(SendAnalyticEvents.SwapCurrencyButtonClicked)
uiState = amountStateFactory.getOnCurrencyChangedState(isFiat)
}
@ -438,6 +447,7 @@ internal class SendViewModel @Inject constructor(
override fun onMaxValueClick() {
uiState = amountStateFactory.getOnMaxAmountClick()
analyticsEventHandler.send(SendAnalyticEvents.MaxAmountButtonClicked)
}
// endregion
@ -464,29 +474,30 @@ internal class SendViewModel @Inject constructor(
uiState = stateFactory.getOnRecipientAddressValidationStarted()
val isValidAddress = validateAddress(value)
uiState = stateFactory.getOnRecipientAddressValidState(value, isValidAddress)
sendRecipientAnalyticsSender.sendAddressAnalytics(type, isValidAddress)
type?.let { analyticsEventHandler.send(SendAnalyticEvents.AddressEntered(it, isValidAddress)) }
}
}.saveIn(addressValidationJobHolder)
}
override fun onRecipientMemoValueChange(value: String, isPasted: Boolean) {
override fun onRecipientMemoValueChange(value: String) {
viewModelScope.launch(dispatchers.main) {
if (!checkIfXrpAddressValue(value)) {
uiState = stateFactory.getOnRecipientMemoValueChange(value)
uiState = stateFactory.getOnRecipientAddressValidationStarted()
val isValidAddress = validateAddress(uiState.recipientState?.addressTextField?.value.orEmpty())
uiState = stateFactory.getOnRecipientMemoValidState(value, isValidAddress)
sendRecipientAnalyticsSender.sendMemoAnalytics(isPasted)
}
}.saveIn(addressValidationJobHolder)
}
private suspend fun validateAddress(value: String): Boolean {
return validateWalletAddressUseCase(
val isValidAddress = validateWalletAddressUseCase(
userWalletId = userWalletId,
network = cryptoCurrency.network,
address = value,
).getOrElse { false }
onEnteredValidAddress(isValidAddress)
return isValidAddress
}
private suspend fun checkIfXrpAddressValue(value: String): Boolean {
@ -498,6 +509,16 @@ internal class SendViewModel @Inject constructor(
true
} ?: false
}
private fun onEnteredValidAddress(isValidAddress: Boolean) {
val recipientState = uiState.recipientState ?: return
uiState = uiState.copy(
recipientState = recipientState.copy(
recent = recipientState.recent.map { it.copy(isVisible = !isValidAddress) }.toPersistentList(),
wallets = recipientState.wallets.map { it.copy(isVisible = !isValidAddress) }.toPersistentList(),
),
)
}
// endregion
// region fee
@ -516,21 +537,30 @@ internal class SendViewModel @Inject constructor(
updateFeeNotifications()
}
override fun onSubtractSelect(value: Boolean) {
uiState = feeStateFactory.onSubtractSelect(value)
updateFeeNotifications()
override fun onSubtractSelect() {
uiState = stateFactory.onSubtractSelect(true)
stateRouter.showSend()
analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount(true))
}
override fun onReadMoreClick() {
val locale = if (Locale.getDefault().language == RU_LOCALE) RU_LOCALE else EN_LOCALE
val url = buildString {
append(FEE_READ_MORE_URL_FIRST_PART)
append(locale)
append(FEE_READ_MORE_URL_SECOND_PART)
}
innerRouter.openUrl(url)
}
private fun loadFee() {
viewModelScope.launch(dispatchers.main) {
uiState = feeStateFactory.onFeeOnLoadingState()
if (uiState.feeState?.fee == null) {
uiState = feeStateFactory.onFeeOnLoadingState()
}
uiState = callFeeUseCase()?.fold(
ifRight = { fees ->
feeStateFactory.onFeeOnLoadedState(fees, isAmountSubtractAvailable)
},
ifLeft = {
feeStateFactory.onFeeOnErrorState()
},
ifRight = feeStateFactory::onFeeOnLoadedState,
ifLeft = { feeStateFactory.onFeeOnErrorState() },
) ?: feeStateFactory.onFeeOnErrorState()
updateFeeNotifications()
}.saveIn(feeJobHolder)
@ -569,52 +599,55 @@ internal class SendViewModel @Inject constructor(
onCheckFeeUpdate()
}
sendIdleTimer = System.currentTimeMillis()
analyticsEventHandler.send(SendAnalyticEvents.SendButtonClicked)
}
override fun showAmount() {
stateRouter.showAmount()
stateRouter.showAmount(isFromConfirmation = true)
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount))
}
override fun showRecipient() {
stateRouter.showRecipient()
stateRouter.showRecipient(isFromConfirmation = true)
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address))
}
override fun showFee() {
stateRouter.showFee()
stateRouter.showFee(isFromConfirmation = true)
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee))
}
override fun onExploreClick(txUrl: String) {
override fun showSend() {
stateRouter.showSend()
}
override fun onExploreClick() {
analyticsEventHandler.send(SendAnalyticEvents.ExploreButtonClicked)
innerRouter.openUrl(txUrl)
innerRouter.openUrl(uiState.sendState.txUrl)
}
override fun onShareClick() {
analyticsEventHandler.send(SendAnalyticEvents.ShareButtonClicked)
}
override fun onAmountReduceClick(reducedAmount: String) {
override fun onAmountReduceClick(reducedAmount: String, clazz: Class<out SendNotification>) {
uiState = amountStateFactory.getOnAmountValueChange(reducedAmount)
uiState = sendNotificationFactory.dismissHighFeeWarningState()
uiState = sendNotificationFactory.dismissNotificationState(clazz)
loadFee()
}
override fun onAmountReduceIgnoreClick() {
uiState = sendNotificationFactory.dismissHighFeeWarningState()
override fun onNotificationCancel(clazz: Class<out SendNotification>) {
uiState = sendNotificationFactory.dismissNotificationState(clazz)
}
private fun verifyAndSendTransaction() {
val recipient = uiState.recipientState?.addressTextField?.value ?: return
val feeState = uiState.feeState ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val fee = feeState.fee ?: return
val memo = uiState.recipientState?.memoTextField?.value
val fee = feeStateFactory.feeConverter.convert(feeSelectorState)
val amountValue = uiState.amountState?.amountTextField?.cryptoAmount?.value ?: return
val amountToSend = if (feeState.isSubtract && isAmountSubtractAvailable) {
feeState.receivedAmountValue
val amountToSend = if (uiState.sendState.isSubtract && isAmountSubtractAvailable) {
val feeValue = fee.amount.value ?: return
amountValue.minus(feeValue)
} else {
amountValue
}
@ -655,6 +688,7 @@ internal class SendViewModel @Inject constructor(
error = error,
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
)
analyticsEventHandler.send(SendAnalyticEvents.TransactionError(cryptoCurrency.symbol))
},
ifRight = {
uiState = stateFactory.getSendingStateUpdate(isSending = false)
@ -677,11 +711,10 @@ internal class SendViewModel @Inject constructor(
}
private fun onCheckFeeUpdate() {
val isSending = uiState.sendState.isSending
val isSuccess = uiState.sendState.isSuccess
val noErrorNotifications = uiState.sendState.notifications.none { it is SendNotification.Error }
if (!isSending && !isSuccess && noErrorNotifications) {
if (!isSuccess && noErrorNotifications) {
viewModelScope.launch(dispatchers.main) {
val feeUpdatedState = callFeeUseCase()?.fold(
ifRight = {
@ -720,5 +753,10 @@ internal class SendViewModel @Inject constructor(
companion object {
private const val CHECK_FEE_UPDATE_DELAY = 60_000L
private const val BALANCE_UPDATE_DELAY = 10_000L
private const val RU_LOCALE = "ru"
private const val EN_LOCALE = "en"
private const val FEE_READ_MORE_URL_FIRST_PART = "https://tangem.com/"
private const val FEE_READ_MORE_URL_SECOND_PART = "/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it/"
}
}

View file

@ -7,6 +7,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.swap.api"
}
dependencies {
/** DI */
implementation(deps.hilt.android)

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.swap.api" />

View file

@ -6,6 +6,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.swap.data"
}
dependencies {
/** AndroidX */

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.swap.data" />

View file

@ -152,7 +152,7 @@ class DefaultSwapTransactionRepository(
key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY,
)
val updatesMap = savedMap?.toMutableMap() ?: mutableMapOf()
val updatesMap = savedMap.toMutableMap()
updatesMap[txId] = status
mutablePreferences.setObjectMap(
@ -232,9 +232,9 @@ class DefaultSwapTransactionRepository(
val savedList = mutablePreferences.getObjectMap<ExchangeStatusModel>(
key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY,
)
val editedList = savedList?.filterNot { it.key == txId }
val editedList = savedList.filterNot { it.key == txId }
if (editedList.isNullOrEmpty()) {
if (editedList.isEmpty()) {
mutablePreferences.remove(key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY)
} else {
mutablePreferences.setObjectMap(

View file

@ -5,6 +5,7 @@ data class ExchangeStatusModel(
val status: ExchangeStatus? = null,
val txId: String? = null,
val txExternalUrl: String? = null,
val txExternalId: String? = null,
)
enum class ExchangeStatus {

View file

@ -3,5 +3,4 @@ package com.tangem.feature.swap.domain.models.domain
data class NetworkInfo(
val name: String,
val blockchainId: String,
val blockchainCurrency: String,
)

View file

@ -7,19 +7,18 @@ import com.tangem.feature.swap.domain.models.SwapAmount
*
* @property isAllowedToSpend shows is token allowed to spend
* @property isBalanceEnough shows is balance of token enough
* @property isFeeEnough shows is amount of main coin enough for fee
*/
// todo Refactor this state
data class PreparedSwapConfigState(
val isAllowedToSpend: Boolean,
val isBalanceEnough: Boolean,
val isFeeEnough: Boolean,
val feeState: SwapFeeState,
val hasOutgoingTransaction: Boolean,
val includeFeeInAmount: IncludeFeeInAmount,
)
sealed class IncludeFeeInAmount {
data class Included(val amountSubtractFee: SwapAmount) : IncludeFeeInAmount()
object Excluded : IncludeFeeInAmount()
object BalanceNotEnough : IncludeFeeInAmount()
data object Excluded : IncludeFeeInAmount()
data object BalanceNotEnough : IncludeFeeInAmount()
}

View file

@ -0,0 +1,12 @@
package com.tangem.feature.swap.domain.models.domain
import com.tangem.domain.tokens.model.CryptoCurrency
sealed class SwapFeeState {
data object Enough : SwapFeeState()
data class NotEnough(
val feeCurrency: CryptoCurrency? = null,
val currencyName: String? = null,
val currencySymbol: String? = null,
) : SwapFeeState()
}

View file

@ -3,10 +3,7 @@ package com.tangem.feature.swap.domain.models.ui
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.Warning
import com.tangem.feature.swap.domain.models.domain.*
import java.math.BigDecimal
sealed interface SwapState {
@ -15,11 +12,10 @@ sealed interface SwapState {
val fromTokenInfo: TokenSwapInfo,
val toTokenInfo: TokenSwapInfo,
val priceImpact: PriceImpact,
val networkCurrency: String,
val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState(
isAllowedToSpend = false,
isBalanceEnough = false,
isFeeEnough = false,
feeState = SwapFeeState.NotEnough(),
hasOutgoingTransaction = false,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
),
@ -98,7 +94,7 @@ sealed class TxFeeState {
val fee: TxFee,
) : TxFeeState()
object Empty : TxFeeState()
data object Empty : TxFeeState()
}
data class TxFee(

View file

@ -13,7 +13,6 @@ internal class DefaultBlockchainInteractor @Inject constructor(
NetworkInfo(
name = it.name,
blockchainId = it.blockchainId,
blockchainCurrency = it.blockchainCurrency,
)
}
}

View file

@ -13,7 +13,9 @@ import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.tokens.utils.convertToAmount
@ -61,6 +63,7 @@ internal class SwapInteractorImpl @Inject constructor(
private val swapTransactionRepository: SwapTransactionRepository,
private val currencyChecksRepository: CurrencyChecksRepository,
private val appCurrencyRepository: AppCurrencyRepository,
private val currenciesRepository: CurrenciesRepository,
private val initialToCurrencyResolver: InitialToCurrencyResolver,
) : SwapInteractor {
@ -251,7 +254,7 @@ internal class SwapInteractorImpl @Inject constructor(
if (amountDecimal == null || amountDecimal.signum() == 0) {
return providers.associateWith { createEmptyAmountState() }
}
val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency))
val amount = SwapAmount(amountDecimal, fromToken.currency.decimals)
val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null)
val networkId = fromToken.currency.network.backendId
when (provider.type) {
@ -433,7 +436,7 @@ internal class SwapInteractorImpl @Inject constructor(
return when (swapProvider.type) {
ExchangeProviderType.CEX -> {
val amountDecimal = toBigDecimalOrNull(amountToSwap)
val amount = SwapAmount(requireNotNull(amountDecimal), getTokenDecimals(currencyToSend.currency))
val amount = SwapAmount(requireNotNull(amountDecimal), currencyToSend.currency.decimals)
val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) {
includeFeeInAmount.amountSubtractFee
} else {
@ -471,17 +474,28 @@ internal class SwapInteractorImpl @Inject constructor(
if (amountDecimal == null || amountDecimal.signum() == 0) {
return state
}
val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency))
val amount = SwapAmount(amountDecimal, fromToken.currency.decimals)
val includeFeeInAmount = getIncludeFeeInAmount(
networkId = fromToken.currency.network.backendId,
txFee = state.txFee,
amount = amount,
fromToken = fromToken.currency,
)
val fee = when (val txFee = state.txFee) {
TxFeeState.Empty -> BigDecimal.ZERO
is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue
is TxFeeState.SingleFeeState -> txFee.fee.feeValue
}
val feeState = getFeeState(
fee = fee,
spendAmount = amount,
networkId = fromToken.currency.network.backendId,
fromTokenStatus = fromToken,
)
return state.copy(
permissionState = PermissionDataState.Empty,
preparedSwapConfigState = state.preparedSwapConfigState.copy(
isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough,
feeState = feeState,
isBalanceEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough,
includeFeeInAmount = includeFeeInAmount,
),
@ -497,7 +511,7 @@ internal class SwapInteractorImpl @Inject constructor(
fee: TxFee,
): TxState {
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
val amount = SwapAmount(amountDecimal, getTokenDecimals(currencyToSend))
val amount = SwapAmount(amountDecimal, currencyToSend.decimals)
val derivationPath = currencyToSend.network.derivationPath.value
val result = transactionManager.sendTransaction(
txData = SwapTxData(
@ -592,7 +606,7 @@ internal class SwapInteractorImpl @Inject constructor(
network = currencyToSend.currency.network,
)
val externalUrl = (exchangeData.transaction as? ExpressTransactionModel.CEX)?.externalTxUrl
val txCexModel = exchangeData.transaction as? ExpressTransactionModel.CEX
val derivationPath = currencyToSend.currency.network.derivationPath.value
return result.fold(
@ -607,6 +621,7 @@ internal class SwapInteractorImpl @Inject constructor(
},
ifRight = {
val timestamp = System.currentTimeMillis()
val txExternalUrl = txCexModel?.externalTxUrl
storeSwapTransaction(
currencyToSend = currencyToSend,
currencyToGet = currencyToGet,
@ -614,7 +629,8 @@ internal class SwapInteractorImpl @Inject constructor(
swapProvider = swapProvider,
swapDataModel = exchangeData,
timestamp = timestamp,
txExternalUrl = externalUrl.orEmpty(),
txExternalUrl = txExternalUrl.orEmpty(),
txExternalId = txCexModel?.externalTxId.orEmpty(),
)
storeLastCryptoCurrencyId(currencyToGet.currency)
TxState.TxSent(
@ -632,7 +648,7 @@ internal class SwapInteractorImpl @Inject constructor(
currencyToSend.currency.network.backendId,
derivationPath,
).orEmpty(),
txExternalUrl = externalUrl,
txExternalUrl = txExternalUrl,
timestamp = timestamp,
)
},
@ -683,6 +699,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapDataModel: SwapDataModel,
timestamp: Long,
txExternalUrl: String,
txExternalId: String,
) {
swapTransactionRepository.storeTransaction(
userWalletId = UserWalletId(userWalletManager.getWalletId()),
@ -699,6 +716,7 @@ internal class SwapInteractorImpl @Inject constructor(
status = ExchangeStatus.New,
txId = swapDataModel.transaction.txId,
txExternalUrl = txExternalUrl,
txExternalId = txExternalId,
),
),
)
@ -713,7 +731,7 @@ internal class SwapInteractorImpl @Inject constructor(
@Deprecated("used in old swap mechanism")
override fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount {
return SwapAmount(token.value.amount ?: BigDecimal.ZERO, getTokenDecimals(token.currency))
return SwapAmount(token.value.amount ?: BigDecimal.ZERO, token.currency.decimals)
}
@Deprecated("used in old swap mechanism")
@ -734,14 +752,6 @@ internal class SwapInteractorImpl @Inject constructor(
return repository.getNativeTokenForNetwork(networkId)
}
private fun getTokenDecimals(token: CryptoCurrency): Int {
return if (token is CryptoCurrency.Token) {
token.decimals
} else {
transactionManager.getNativeTokenDecimals(token.network.backendId)
}
}
private suspend fun isAllowedToSpend(
networkId: String,
fromToken: CryptoCurrency,
@ -755,7 +765,7 @@ internal class SwapInteractorImpl @Inject constructor(
userWalletId = userWallet.walletId,
networkId = networkId,
derivationPath = fromToken.network.derivationPath.value,
tokenDecimalCount = getTokenDecimals(fromToken),
tokenDecimalCount = fromToken.decimals,
tokenAddress = getTokenAddress(fromToken),
spenderAddress = spenderAddress,
)
@ -797,7 +807,7 @@ internal class SwapInteractorImpl @Inject constructor(
val toToken = toTokenStatus.currency
return coroutineScope {
val txFee = if (provider.type == ExchangeProviderType.CEX) {
getFeeForCex(amount, fromTokenStatus, networkId)
getFeeForCex(amount, fromTokenStatus)
} else {
TxFeeState.Empty
}
@ -885,10 +895,21 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
ExchangeProviderType.CEX -> {
val fee = when (txFee) {
TxFeeState.Empty -> BigDecimal.ZERO
is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue
is TxFeeState.SingleFeeState -> txFee.fee.feeValue
}
val feeState = getFeeState(
fee = fee,
spendAmount = amount,
networkId = networkId,
fromTokenStatus = fromToken,
)
swapState.copy(
permissionState = PermissionDataState.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough,
feeState = feeState,
isAllowedToSpend = isAllowedToSpend,
isBalanceEnough = isBalanceWithoutFeeEnough,
hasOutgoingTransaction = hasOutgoingTransaction(fromToken),
@ -911,26 +932,47 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
@Suppress("CyclomaticComplexMethod")
private suspend fun getIncludeFeeInAmount(
networkId: String,
txFee: TxFeeState,
amount: SwapAmount,
fromToken: CryptoCurrency,
): IncludeFeeInAmount {
val feeValue = when (txFee) {
TxFeeState.Empty -> BigDecimal.ZERO
is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue
is TxFeeState.SingleFeeState -> txFee.fee.feeValue
}
val feePaidCurrency = getFeePaidCurrency(
userWalletId = requireNotNull(getSelectedWallet()).walletId,
currency = fromToken,
)
return when (feePaidCurrency) {
is FeePaidCurrency.Token -> {
if (feePaidCurrency.balance > feeValue) {
IncludeFeeInAmount.Excluded
} else {
IncludeFeeInAmount.BalanceNotEnough
}
}
else -> getIncludeFeeAmountForCoinFee(networkId, amount, feeValue, fromToken)
}
}
private suspend fun getIncludeFeeAmountForCoinFee(
networkId: String,
amount: SwapAmount,
feeValue: BigDecimal,
fromToken: CryptoCurrency,
): IncludeFeeInAmount {
val tokenForFeeBalance =
userWalletManager.getNativeTokenBalance(
networkId,
fromToken.network.derivationPath.value,
) ?: ProxyAmount.empty()
val feeValue = when (txFee) {
TxFeeState.Empty -> BigDecimal.ZERO
is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue
is TxFeeState.SingleFeeState -> txFee.fee.feeValue
}
val amountWithFee = amount.value + feeValue
return when {
fromToken is CryptoCurrency.Token -> {
if (feeValue > tokenForFeeBalance.value || tokenForFeeBalance.value.signum() == 0) {
@ -950,7 +992,7 @@ internal class SwapInteractorImpl @Inject constructor(
IncludeFeeInAmount.Included(
SwapAmount(
tokenForFeeBalance.value - feeValue,
transactionManager.getNativeTokenDecimals(networkId),
getNativeToken(fromToken.network.backendId).decimals,
),
)
} else {
@ -960,11 +1002,18 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private suspend fun getFormattedFiatFees(networkId: String, vararg fees: BigDecimal): List<String> {
private suspend fun getFormattedFiatFees(fromToken: CryptoCurrency, vararg fees: BigDecimal): List<String> {
val appCurrency = getSelectedAppCurrencyUseCase.unwrap()
val nativeToken = repository.getNativeTokenForNetwork(networkId)
val rates = getQuotes(nativeToken.id)
return rates[nativeToken.id]?.fiatRate?.let { rate ->
val feePaidCurrency = getFeePaidCurrency(
userWalletId = requireNotNull(getSelectedWallet()).walletId,
currency = fromToken,
)
val feeCurrencyId: CryptoCurrency.ID = when (feePaidCurrency) {
is FeePaidCurrency.Token -> feePaidCurrency.tokenId
else -> getNativeToken(networkId = fromToken.network.backendId).id
}
val rates = getQuotes(feeCurrencyId)
return rates[feeCurrencyId]?.fiatRate?.let { rate ->
fees.map { fee ->
fee.toFiatString(rate, appCurrency.symbol, true)
}
@ -1006,16 +1055,16 @@ internal class SwapInteractorImpl @Inject constructor(
derivationPath = fromToken.currency.network.derivationPath.value,
)
val txFeeState = when (feeData) {
is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(networkId)
is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(networkId)
is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(fromToken.currency)
is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(fromToken.currency)
}
val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = txFeeState)
val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeByPriority)
val isFeeEnough = checkFeeIsEnough(
val feeState = getFeeState(
fee = feeByPriority,
spendAmount = amount,
networkId = networkId,
fromToken = fromToken.currency,
fromTokenStatus = fromToken,
)
val swapState = updateBalances(
networkId = networkId,
@ -1032,7 +1081,7 @@ internal class SwapInteractorImpl @Inject constructor(
preparedSwapConfigState = PreparedSwapConfigState(
isAllowedToSpend = true,
isBalanceEnough = isBalanceIncludeFeeEnough,
isFeeEnough = isFeeEnough,
feeState = feeState,
hasOutgoingTransaction = hasOutgoingTransaction(fromToken),
includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex
),
@ -1089,17 +1138,12 @@ internal class SwapInteractorImpl @Inject constructor(
toTokenAmount = toTokenAmount.value,
toRate = rates[toToken.id]?.fiatRate?.toDouble() ?: 0.0,
),
networkCurrency = userWalletManager.getNetworkCurrency(networkId),
swapDataModel = swapData,
txFee = txFeeState,
)
}
private suspend fun getFeeForCex(
amount: SwapAmount,
fromToken: CryptoCurrencyStatus,
networkId: String,
): TxFeeState {
private suspend fun getFeeForCex(amount: SwapAmount, fromToken: CryptoCurrencyStatus): TxFeeState {
getSelectedWalletSyncUseCase().getOrNull()?.walletId?.let { userWalletId ->
val txFeeResult = estimateFeeUseCase(
amount = amount.value,
@ -1111,7 +1155,7 @@ internal class SwapInteractorImpl @Inject constructor(
TxFeeState.Empty
},
ifRight = { txFee ->
txFee.toTxFeeState(networkId)
txFee.toTxFeeState(fromToken.currency)
},
) ?: TxFeeState.Empty
}
@ -1170,8 +1214,8 @@ internal class SwapInteractorImpl @Inject constructor(
}
val feeState = feeData?.let {
when (feeData) {
is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(networkId)
is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(networkId)
is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(fromToken)
is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(fromToken)
}
} ?: TxFeeState.Empty
val fee = when (feeState) {
@ -1179,11 +1223,11 @@ internal class SwapInteractorImpl @Inject constructor(
is TxFeeState.MultipleFeeState -> feeState.normalFee.feeValue
is TxFeeState.SingleFeeState -> feeState.fee.feeValue
}
val isFeeEnough = checkFeeIsEnough(
val swapFeeState = getFeeState(
fee = fee,
spendAmount = SwapAmount.zeroSwapAmount(),
networkId = networkId,
fromToken = fromToken,
fromTokenStatus = fromTokenStatus,
)
return quotesLoadedState.copy(
permissionState = PermissionDataState.PermissionReadyForRequest(
@ -1199,28 +1243,26 @@ internal class SwapInteractorImpl @Inject constructor(
),
),
preparedSwapConfigState = quotesLoadedState.preparedSwapConfigState.copy(
isFeeEnough = isFeeEnough,
feeState = swapFeeState,
),
)
}
private suspend fun ProxyFees.MultipleFees.proxyFeesToFeeState(networkId: String): TxFeeState {
private suspend fun ProxyFees.MultipleFees.proxyFeesToFeeState(fromToken: CryptoCurrency): TxFeeState {
val normalFeeValue = this.minFee.fee.value // in swap for normal use min fee
val normalFeeGas = this.minFee.gasLimit.toInt()
val priorityFeeValue = this.normalFee.fee.value // in swap for priority use normal fee
val priorityFeeGas = this.normalFee.gasLimit.toInt()
val feesFiat = getFormattedFiatFees(networkId, normalFeeValue, priorityFeeValue)
val feesFiat = getFormattedFiatFees(fromToken, normalFeeValue, priorityFeeValue)
val normalFiatFee = requireNotNull(feesFiat.getOrNull(0)) { "feesFiat item 0 couldn't be null" }
val priorityFiatFee = requireNotNull(feesFiat.getOrNull(1)) { "feesFiat item 1 couldn't be null" }
val networkCurrency = userWalletManager.getNetworkCurrency(networkId)
val decimals = transactionManager.getNativeTokenDecimals(networkId)
val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI(
amount = normalFeeValue,
decimals = decimals,
decimals = minFee.fee.decimals,
)
val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI(
amount = priorityFeeValue,
decimals = decimals,
decimals = normalFee.fee.decimals,
)
return TxFeeState.MultipleFeeState(
normalFee = TxFee(
@ -1228,8 +1270,8 @@ internal class SwapInteractorImpl @Inject constructor(
gasLimit = normalFeeGas,
feeFiatFormatted = normalFiatFee,
feeCryptoFormatted = normalCryptoFee,
decimals = decimals,
cryptoSymbol = networkCurrency,
decimals = minFee.fee.decimals,
cryptoSymbol = minFee.fee.currencySymbol,
feeType = FeeType.NORMAL,
),
priorityFee = TxFee(
@ -1237,23 +1279,21 @@ internal class SwapInteractorImpl @Inject constructor(
gasLimit = priorityFeeGas,
feeFiatFormatted = priorityFiatFee,
feeCryptoFormatted = priorityCryptoFee,
decimals = decimals,
cryptoSymbol = networkCurrency,
decimals = normalFee.fee.decimals,
cryptoSymbol = normalFee.fee.currencySymbol,
feeType = FeeType.PRIORITY,
),
)
}
private suspend fun ProxyFees.SingleFee.proxyFeesToFeeState(networkId: String): TxFeeState {
private suspend fun ProxyFees.SingleFee.proxyFeesToFeeState(fromToken: CryptoCurrency): TxFeeState {
val normalFeeValue = this.singleFee.fee.value
val normalFeeGas = this.singleFee.gasLimit.toInt()
val networkCurrency = userWalletManager.getNetworkCurrency(networkId)
val feesFiat = getFormattedFiatFees(networkId, normalFeeValue)
val feesFiat = getFormattedFiatFees(fromToken, normalFeeValue)
val normalFiatFee = requireNotNull(feesFiat.getOrNull(0)) { "feesFiat item 0 couldn't be null" }
val decimals = transactionManager.getNativeTokenDecimals(networkId)
val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI(
amount = normalFeeValue,
decimals = decimals,
decimals = singleFee.fee.decimals,
)
return TxFeeState.SingleFeeState(
fee = TxFee(
@ -1261,32 +1301,30 @@ internal class SwapInteractorImpl @Inject constructor(
gasLimit = normalFeeGas,
feeFiatFormatted = normalFiatFee,
feeCryptoFormatted = normalCryptoFee,
decimals = decimals,
cryptoSymbol = networkCurrency,
decimals = singleFee.fee.decimals,
cryptoSymbol = singleFee.fee.currencySymbol,
feeType = FeeType.NORMAL,
),
)
}
private suspend fun TransactionFee.toTxFeeState(networkId: String): TxFeeState {
val networkCurrency = userWalletManager.getNetworkCurrency(networkId)
val decimals = transactionManager.getNativeTokenDecimals(networkId)
private suspend fun TransactionFee.toTxFeeState(fromToken: CryptoCurrency): TxFeeState {
return when (this) {
is TransactionFee.Choosable -> {
val normalFee = this.normal.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND)
val priorityFee = this.priority.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND)
val feeNormal = normalFee.amount.value ?: BigDecimal.ZERO
val feePriority = priorityFee.amount.value ?: BigDecimal.ZERO
val normalFiatValue = getFormattedFiatFees(networkId, feeNormal)[0]
val priorityFiatValue = getFormattedFiatFees(networkId, feePriority)[0]
val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0]
val priorityFiatValue = getFormattedFiatFees(fromToken, feePriority)[0]
val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI(
amount = feeNormal,
decimals = decimals,
decimals = normalFee.amount.decimals,
)
val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI(
amount = feePriority,
decimals = decimals,
decimals = priorityFee.amount.decimals,
)
TxFeeState.MultipleFeeState(
normalFee = TxFee(
@ -1294,8 +1332,8 @@ internal class SwapInteractorImpl @Inject constructor(
gasLimit = normalFee.getGasLimit(),
feeFiatFormatted = normalFiatValue,
feeCryptoFormatted = normalCryptoFee,
decimals = decimals,
cryptoSymbol = networkCurrency,
decimals = normalFee.amount.decimals,
cryptoSymbol = normalFee.amount.currencySymbol,
feeType = FeeType.NORMAL,
),
priorityFee = TxFee(
@ -1303,18 +1341,18 @@ internal class SwapInteractorImpl @Inject constructor(
gasLimit = priorityFee.getGasLimit(),
feeFiatFormatted = priorityFiatValue,
feeCryptoFormatted = priorityCryptoFee,
decimals = decimals,
cryptoSymbol = networkCurrency,
decimals = priorityFee.amount.decimals,
cryptoSymbol = priorityFee.amount.currencySymbol,
feeType = FeeType.PRIORITY,
),
)
}
is TransactionFee.Single -> {
val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO
val normalFiatValue = getFormattedFiatFees(networkId, feeNormal)[0]
val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0]
val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI(
amount = feeNormal,
decimals = transactionManager.getNativeTokenDecimals(networkId),
decimals = this.normal.amount.decimals,
)
TxFeeState.SingleFeeState(
fee = TxFee(
@ -1322,8 +1360,8 @@ internal class SwapInteractorImpl @Inject constructor(
gasLimit = this.normal.getGasLimit(),
feeFiatFormatted = normalFiatValue,
feeCryptoFormatted = normalCryptoFee,
decimals = decimals,
cryptoSymbol = networkCurrency,
decimals = normal.amount.decimals,
cryptoSymbol = normal.amount.currencySymbol,
feeType = FeeType.NORMAL,
),
)
@ -1375,15 +1413,35 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private fun isBalanceEnough(fromToken: CryptoCurrencyStatus, amount: SwapAmount, fee: BigDecimal?): Boolean {
private suspend fun isBalanceEnough(
fromToken: CryptoCurrencyStatus,
amount: SwapAmount,
fee: BigDecimal?,
): Boolean {
val tokenBalance = getTokenBalance(fromToken).value
return if (fromToken.currency is CryptoCurrency.Token) {
tokenBalance >= amount.value
} else {
tokenBalance > amount.value.plus(fee ?: BigDecimal.ZERO)
val feePaidCurrency = getFeePaidCurrency(
userWalletId = requireNotNull(getSelectedWallet()).walletId,
currency = fromToken.currency,
)
return when (feePaidCurrency) {
is FeePaidCurrency.Token -> tokenBalance >= amount.value
else -> {
if (fromToken.currency is CryptoCurrency.Token) {
tokenBalance >= amount.value
} else {
tokenBalance > amount.value.plus(fee ?: BigDecimal.ZERO)
}
}
}
}
private suspend fun getFeePaidCurrency(userWalletId: UserWalletId, currency: CryptoCurrency): FeePaidCurrency {
return currenciesRepository.getFeePaidCurrency(
userWalletId = userWalletId,
currency = currency,
)
}
private suspend fun getWalletAddress(networkId: String, derivationPath: String?): String {
return userWalletManager.getWalletAddress(networkId, derivationPath)
}
@ -1399,36 +1457,72 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private suspend fun checkFeeIsEnough(
private fun toBigDecimalOrNull(amountToSwap: String): BigDecimal? {
return amountToSwap.replace(",", ".").toBigDecimalOrNull()
}
private suspend fun getFeeState(
fee: BigDecimal?,
spendAmount: SwapAmount,
networkId: String,
fromToken: CryptoCurrency,
): Boolean {
fromTokenStatus: CryptoCurrencyStatus,
): SwapFeeState {
val userWalletId = requireNotNull(getSelectedWallet()).walletId
if (fee == null) {
return false
return SwapFeeState.NotEnough()
}
val nativeTokenBalance = userWalletManager.getNativeTokenBalance(
networkId,
fromToken.network.derivationPath.value,
)
val percentsToFeeIncrease = BigDecimal.ONE
return when (fromToken) {
is CryptoCurrency.Coin -> {
nativeTokenBalance?.let { balance ->
return balance.value.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease)
} ?: false
}
is CryptoCurrency.Token -> {
nativeTokenBalance?.let { balance ->
return balance.value > fee.multiply(percentsToFeeIncrease)
} ?: false
}
}
}
private fun toBigDecimalOrNull(amountToSwap: String): BigDecimal? {
return amountToSwap.replace(",", ".").toBigDecimalOrNull()
val percentsToFeeIncrease = BigDecimal.ONE
return when (val feePaidCurrency = getFeePaidCurrency(userWalletId, fromTokenStatus.currency)) {
FeePaidCurrency.Coin -> {
val nativeTokenBalance = userWalletManager.getNativeTokenBalance(
networkId,
fromTokenStatus.currency.network.derivationPath.value,
)
nativeTokenBalance?.let { balance ->
if (balance.value.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease)) {
SwapFeeState.Enough
} else {
val nativeToken = getNativeToken(fromTokenStatus.currency.network.backendId)
SwapFeeState.NotEnough(
feeCurrency = nativeToken,
currencyName = nativeToken.network.name,
currencySymbol = nativeToken.symbol,
)
}
} ?: SwapFeeState.NotEnough()
}
FeePaidCurrency.SameCurrency -> {
val balance = fromTokenStatus.value.amount ?: return SwapFeeState.NotEnough()
if (balance.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease)) {
SwapFeeState.Enough
} else {
SwapFeeState.NotEnough(
feeCurrency = fromTokenStatus.currency,
currencyName = fromTokenStatus.currency.network.name,
currencySymbol = fromTokenStatus.currency.symbol,
)
}
}
is FeePaidCurrency.Token -> {
if (feePaidCurrency.balance > fee.multiply(percentsToFeeIncrease)) {
SwapFeeState.Enough
} else {
val token = currenciesRepository
.getMultiCurrencyWalletCurrenciesSync(userWalletId)
.find {
it is CryptoCurrency.Token &&
it.contractAddress.equals(feePaidCurrency.contractAddress, ignoreCase = true) &&
it.network.derivationPath == fromTokenStatus.currency.network.derivationPath
}
SwapFeeState.NotEnough(
feeCurrency = token,
currencyName = feePaidCurrency.name,
currencySymbol = feePaidCurrency.symbol,
)
}
}
}
}
private fun calculatePriceImpact(

View file

@ -47,6 +47,7 @@ class SwapDomainModule {
walletManagersFacade: WalletManagersFacade,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
initialToCurrencyResolver: InitialToCurrencyResolver,
currenciesRepository: CurrenciesRepository,
): SwapInteractor {
return SwapInteractorImpl(
transactionManager = transactionManager,
@ -62,6 +63,7 @@ class SwapDomainModule {
swapTransactionRepository = swapTransactionRepository,
appCurrencyRepository = appCurrencyRepository,
currencyChecksRepository = currencyChecksRepository,
currenciesRepository = currenciesRepository,
initialToCurrencyResolver = initialToCurrencyResolver,
)
}

View file

@ -7,6 +7,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.swap.presentation"
}
dependencies {
/** Core modules */
implementation(projects.core.analytics)

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.feature.swap.presentation" />

View file

@ -15,7 +15,6 @@ import com.tangem.feature.swap.models.states.ProviderState
data class SwapStateHolder(
val sendCardData: SwapCardState,
val receiveCardData: SwapCardState,
val networkCurrency: String,
val blockchainId: String, // not the same as networkId, its local id in app
val warnings: List<SwapWarning> = emptyList(),
val alert: SwapWarning.GenericWarning? = null,

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.models
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.feature.swap.domain.models.ui.TxFee
data class UiActions(
@ -20,7 +21,7 @@ data class UiActions(
val onSelectFeeType: (TxFee) -> Unit,
val onProviderClick: (String) -> Unit,
val onProviderSelect: (String) -> Unit,
val onBuyClick: () -> Unit,
val onBuyClick: (CryptoCurrency) -> Unit,
val onPolicyClick: (String) -> Unit,
val onTosClick: (String) -> Unit,
val onReceiveCardWarningClick: () -> Unit,

View file

@ -27,8 +27,8 @@ sealed class ProviderState {
val subtitle: TextReference,
val selectionType: SelectionType,
val additionalBadge: AdditionalBadge,
val percentLowerThenBest: PercentDifference = PercentDifference.Empty,
val namePrefix: PrefixType,
val percentLowerThenBest: PercentLowerThanBest = PercentLowerThanBest.Empty,
override val onProviderClick: (String) -> Unit,
) : ProviderState()
@ -61,9 +61,9 @@ sealed class ProviderState {
}
@Immutable
sealed class PercentLowerThanBest {
data class Value(val value: Float) : PercentLowerThanBest()
object Empty : PercentLowerThanBest()
sealed class PercentDifference {
data class Value(val value: Float) : PercentDifference()
object Empty : PercentDifference()
}
object ProviderPercentDiffComparator : Comparator<ProviderState> {
@ -77,14 +77,14 @@ object ProviderPercentDiffComparator : Comparator<ProviderState> {
if (o1 is ProviderState.Content && o2 is ProviderState.Content) {
val o1Percent = o1.percentLowerThenBest
val o2Percent = o2.percentLowerThenBest
if (o1Percent is PercentLowerThanBest.Value && o2Percent !is PercentLowerThanBest.Value) {
if (o1Percent is PercentDifference.Value && o2Percent !is PercentDifference.Value) {
return -1
}
if (o1Percent !is PercentLowerThanBest.Value && o2Percent is PercentLowerThanBest.Value) {
if (o1Percent !is PercentDifference.Value && o2Percent is PercentDifference.Value) {
return 1
}
return if (o1Percent is PercentLowerThanBest.Value && o2Percent is PercentLowerThanBest.Value) {
o1Percent.value.compareTo(o2Percent.value)
return if (o1Percent is PercentDifference.Value && o2Percent is PercentDifference.Value) {
o2Percent.value.compareTo(o1Percent.value)
} else {
0
}

View file

@ -10,7 +10,6 @@ import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@ -31,12 +30,13 @@ import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun AutoSizeTextField(
textFieldValue: TextFieldValue,
focusRequester: FocusRequester,
onAmountChange: (String) -> Unit,
onFocusChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
val textFieldFocusRequester = remember { FocusRequester() }
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
var shrunkFontSize = TangemTheme.typography.h2.fontSize
val calculateIntrinsics = @Composable {
@ -71,7 +71,7 @@ internal fun AutoSizeTextField(
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(textFieldFocusRequester)
.focusRequester(focusRequester)
.onFocusChanged { onFocusChange(it.hasFocus) },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,

View file

@ -19,7 +19,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
import com.tangem.feature.swap.models.states.PercentLowerThanBest
import com.tangem.feature.swap.models.states.PercentDifference
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.presentation.R
import kotlinx.collections.immutable.toImmutableList
@ -114,7 +114,7 @@ private fun ChooseProviderBottomSheet_Preview() {
iconUrl = "",
subtitle = stringReference("1 000 000"),
additionalBadge = ProviderState.AdditionalBadge.BestTrade,
percentLowerThenBest = PercentLowerThanBest.Value(-1.0f),
percentLowerThenBest = PercentDifference.Value(-1.0f),
selectionType = ProviderState.SelectionType.SELECT,
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = {},

View file

@ -26,7 +26,7 @@ import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.states.PercentLowerThanBest
import com.tangem.feature.swap.models.states.PercentDifference
import com.tangem.feature.swap.models.states.ProviderState
/**
@ -94,11 +94,8 @@ private fun ProviderContentState(
.padding(start = TangemTheme.dimens.spacing12)
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.iconUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
model = ImageRequest.Builder(context = LocalContext.current).data(state.iconUrl)
.crossfade(enable = true).allowHardware(false).build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
error = {
ErrorProviderIcon(
@ -138,10 +135,12 @@ private fun ProviderContentState(
)
}
when (state.additionalBadge) {
ProviderState.AdditionalBadge.BestTrade ->
BestTradeItem(Modifier.padding(start = TangemTheme.dimens.spacing4))
ProviderState.AdditionalBadge.PermissionRequired ->
PermissionBadgeItem(Modifier.padding(start = TangemTheme.dimens.spacing4))
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(
Modifier.padding(start = TangemTheme.dimens.spacing4),
)
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(
Modifier.padding(start = TangemTheme.dimens.spacing4),
)
ProviderState.AdditionalBadge.Empty -> {
// no-op
}
@ -162,14 +161,19 @@ private fun ProviderContentState(
maxLines = 1,
)
}
if (state.percentLowerThenBest is PercentLowerThanBest.Value &&
state.percentLowerThenBest.value > 0
if (state.percentLowerThenBest is PercentDifference.Value &&
state.percentLowerThenBest.value != 0f
) {
val textColor = if (state.percentLowerThenBest.value > 0) {
TangemTheme.colors.icon.accent
} else {
TangemTheme.colors.text.warning
}
AnimatedContent(targetState = state.percentLowerThenBest.value, label = "") {
Text(
text = "-$it%",
text = if (it > 0) "+$it%" else "$it%",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.warning,
color = textColor,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
@ -198,11 +202,8 @@ private fun ProviderUnavailableState(
.padding(start = TangemTheme.dimens.spacing12)
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.iconUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
model = ImageRequest.Builder(context = LocalContext.current).data(state.iconUrl)
.crossfade(enable = true).allowHardware(false).build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
error = {
ErrorProviderIcon(
@ -298,8 +299,7 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) {
@Composable
private fun BoxScope.ProviderChevron(selectionType: ProviderState.SelectionType, isSelected: Boolean) {
when (selectionType) {
ProviderState.SelectionType.NONE -> {
/* no-op */
ProviderState.SelectionType.NONE -> { /* no-op */
}
ProviderState.SelectionType.CLICK -> {
Icon(
@ -345,11 +345,10 @@ private fun BaseContainer(modifier: Modifier = Modifier, content: @Composable Bo
@Composable
private fun ErrorProviderIcon(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(
color = TangemTheme.colors.background.secondary,
shape = TangemTheme.shapes.roundedCorners8,
),
modifier = modifier.background(
color = TangemTheme.colors.background.secondary,
shape = TangemTheme.shapes.roundedCorners8,
),
contentAlignment = Alignment.Center,
) {
Icon(
@ -369,7 +368,7 @@ private fun BestTradeItem(modifier: Modifier = Modifier) {
),
) {
Text(
text = "Best rate",
text = stringResource(R.string.express_provider_best_rate),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
@ -432,7 +431,7 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
iconUrl = "",
subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"),
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentLowerThanBest.Empty,
percentLowerThenBest = PercentDifference.Value(value = 12.0f),
selectionType = ProviderState.SelectionType.SELECT,
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
onProviderClick = {},
@ -440,7 +439,7 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
val contentState2 = contentState.copy(
subtitle = stringReference(value = "1 132,46 MATIC"),
additionalBadge = ProviderState.AdditionalBadge.PermissionRequired,
percentLowerThenBest = PercentLowerThanBest.Value(value = 5f),
percentLowerThenBest = PercentDifference.Value(value = 5f),
)
val unavailableState = ProviderState.Unavailable(
id = "1",

View file

@ -79,7 +79,6 @@ internal class StateBuilder(
isBalanceHidden = true,
),
fee = FeeItemState.Empty,
networkCurrency = networkInfo.blockchainCurrency,
swapButton = SwapButton(enabled = false, onClick = {}),
onRefresh = {},
onBackClicked = actions.onBackClicked,
@ -250,7 +249,6 @@ internal class StateBuilder(
balance = toCurrencyStatus.getFormattedAmount(isNeedSymbol = false),
isBalanceHidden = isBalanceHiddenProvider(),
),
networkCurrency = quoteModel.networkCurrency,
warnings = warnings,
permissionState = convertPermissionState(
lastPermissionState = uiStateHolder.permissionState,
@ -317,7 +315,7 @@ internal class StateBuilder(
val warnings = mutableListOf<SwapWarning>()
addDomainWarnings(quoteModel, warnings)
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.isFeeEnough &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest
) {
warnings.add(
@ -412,7 +410,8 @@ internal class StateBuilder(
fromToken: CryptoCurrency,
warnings: MutableList<SwapWarning>,
) {
if (!quoteModel.preparedSwapConfigState.isFeeEnough &&
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState
if (feeEnoughState is SwapFeeState.NotEnough &&
quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.permissionState !is PermissionDataState.PermissionLoading
) {
@ -420,7 +419,9 @@ internal class StateBuilder(
SwapWarning.UnableToCoverFeeWarning(
createUnableToCoverFeeNotificationConfig(
fromToken = fromToken,
onBuyClick = actions.onBuyClick,
feeCurrency = feeEnoughState.feeCurrency,
currencyName = feeEnoughState.currencyName ?: fromToken.network.name,
currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol,
),
),
)
@ -438,7 +439,7 @@ internal class StateBuilder(
IncludeFeeInAmount.Excluded ->
preparedSwapConfigState.isAllowedToSpend &&
preparedSwapConfigState.isBalanceEnough &&
preparedSwapConfigState.isFeeEnough
preparedSwapConfigState.feeState is SwapFeeState.Enough
is IncludeFeeInAmount.Included -> true
}
}
@ -693,6 +694,32 @@ internal class StateBuilder(
)
}
fun updateSendCurrencyBalance(
uiState: SwapStateHolder,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): SwapStateHolder {
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
return uiState.copy(
sendCardData = uiState.sendCardData.copy(
balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false),
),
)
}
fun updateReceiveCurrencyBalance(
uiState: SwapStateHolder,
cryptoCurrencyStatus: CryptoCurrencyStatus,
): SwapStateHolder {
if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState
return uiState.copy(
receiveCardData = uiState.receiveCardData.copy(
balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false),
),
)
}
fun updateBalanceHiddenState(uiState: SwapStateHolder, isBalanceHidden: Boolean): SwapStateHolder {
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState
@ -998,6 +1025,7 @@ internal class StateBuilder(
fun showSelectProviderBottomSheet(
uiState: SwapStateHolder,
selectedProviderId: String,
bestRatedProviderId: String,
pricesLowerBest: Map<String, Float>,
providersStates: Map<SwapProvider, SwapState>,
unavailableProviders: List<SwapProvider>,
@ -1005,7 +1033,7 @@ internal class StateBuilder(
): SwapStateHolder {
val availableProvidersStates = providersStates.entries
.mapNotNull {
it.convertToProviderBottomSheetState(pricesLowerBest, actions.onProviderSelect)
it.convertToProviderBottomSheetState(pricesLowerBest, bestRatedProviderId, actions.onProviderSelect)
}
.sortedWith(ProviderPercentDiffComparator)
val unavailableProviderStates = unavailableProviders.map {
@ -1046,8 +1074,8 @@ internal class StateBuilder(
it.copy(
subtitle = stringReference(rateString),
percentLowerThenBest = pricesLowerBest[it.id]?.let { percent ->
PercentLowerThanBest.Value(percent)
} ?: PercentLowerThanBest.Empty,
PercentDifference.Value(percent)
} ?: PercentDifference.Value(0f),
)
} else {
it
@ -1093,7 +1121,7 @@ internal class StateBuilder(
},
readMoreUrl = buildReadMoreUrl(),
feeItems = txFeeState.toFeeItemState(),
readMore = resourceReference(R.string.common_fee_selector_link_description),
readMore = resourceReference(R.string.common_read_more),
onReadMoreClick = actions.onFeeReadMoreClick,
)
return uiState.copy(
@ -1153,18 +1181,21 @@ internal class StateBuilder(
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderBottomSheetState(
pricesLowerBest: Map<String, Float>,
bestRatedProviderId: String,
onProviderSelect: (String) -> Unit,
): ProviderState? {
val provider = this.key
return when (val state = this.value) {
is SwapState.EmptyAmountState -> null
is SwapState.QuotesLoadedState -> provider.convertToContentSelectableProviderState(
isBestRate = false, // not show best rate in bottom sheet
state = state,
onProviderClick = onProviderSelect,
pricesLowerBest = pricesLowerBest,
selectionType = ProviderState.SelectionType.SELECT,
)
is SwapState.QuotesLoadedState -> {
provider.convertToContentSelectableProviderState(
isBestRate = bestRatedProviderId == provider.providerId,
state = state,
onProviderClick = onProviderSelect,
pricesLowerBest = pricesLowerBest,
selectionType = ProviderState.SelectionType.SELECT,
)
}
is SwapState.SwapError -> getProviderStateForError(
swapProvider = provider,
fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency,
@ -1189,8 +1220,16 @@ internal class StateBuilder(
private fun createUnableToCoverFeeNotificationConfig(
fromToken: CryptoCurrency,
onBuyClick: () -> Unit,
feeCurrency: CryptoCurrency?,
currencyName: String,
currencySymbol: String,
): NotificationConfig {
val buttonState = feeCurrency?.let {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)),
onClick = { actions.onBuyClick(it) },
)
}
return NotificationConfig(
title = resourceReference(
R.string.warning_express_not_enough_fee_for_token_tx_title,
@ -1198,20 +1237,17 @@ internal class StateBuilder(
),
subtitle = resourceReference(
R.string.warning_express_not_enough_fee_for_token_tx_description,
wrappedList(fromToken.network.name, fromToken.network.currencySymbol),
wrappedList(currencyName, currencySymbol),
),
iconResId = fromToken.networkIconResId,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_buy_currency, wrappedList(fromToken.network.currencySymbol)),
onClick = onBuyClick,
),
buttonsState = buttonState,
)
}
private fun createNetworkFeeCoverageNotificationConfig(): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.send_network_fee_warning_title),
subtitle = resourceReference(R.string.send_network_fee_warning_content),
subtitle = resourceReference(R.string.swapping_network_fee_warning_content),
iconResId = R.drawable.img_attention_20,
)
}
@ -1256,7 +1292,7 @@ internal class StateBuilder(
subtitle = stringReference(rateString),
additionalBadge = badge,
selectionType = selectionType,
percentLowerThenBest = PercentLowerThanBest.Empty,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
onProviderClick = onProviderClick,
)
@ -1287,8 +1323,8 @@ internal class StateBuilder(
additionalBadge = additionalBadge,
selectionType = selectionType,
percentLowerThenBest = pricesLowerBest[this.providerId]?.let { percent ->
PercentLowerThanBest.Value(percent)
} ?: PercentLowerThanBest.Value(0f),
PercentDifference.Value(percent)
} ?: PercentDifference.Value(0f),
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick,
)
@ -1323,7 +1359,7 @@ internal class StateBuilder(
selectionType = selectionType,
subtitle = alertText,
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentLowerThanBest.Empty,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = if (selectionType != ProviderState.SelectionType.SELECT) {
ProviderState.PrefixType.PROVIDED_BY
} else {

View file

@ -490,7 +490,6 @@ private val state = SwapStateHolder(
),
),
),
networkCurrency = "MATIC",
swapButton = SwapButton(enabled = true, onClick = {}),
onRefresh = {},
onBackClicked = {},

View file

@ -19,6 +19,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
@ -262,12 +263,19 @@ private fun Content(
}
}
is TransactionCardType.Inputtable -> {
val focusRequester = remember { FocusRequester() }
AutoSizeTextField(
modifier = sumTextModifier,
focusRequester = focusRequester,
textFieldValue = textFieldValue ?: TextFieldValue(),
onAmountChange = { type.onAmountChanged(it) },
onFocusChange = type.onFocusChanged,
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -14,11 +15,13 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
@ -47,7 +50,6 @@ import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.absoluteValue
import kotlin.properties.Delegates
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
@ -63,6 +65,7 @@ internal class SwapViewModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver {
@ -105,6 +108,9 @@ internal class SwapViewModel @Inject constructor(
(it.error is DataError.ExchangeTooSmallAmountError || it.error is DataError.ExchangeTooBigAmountError)
}
private val fromTokenBalanceJobHolder = JobHolder()
private val toTokenBalanceJobHolder = JobHolder()
val currentScreen: SwapNavScreen
get() = swapRouter.currentScreen
@ -174,6 +180,23 @@ internal class SwapViewModel @Inject constructor(
state,
),
)
val userWalletId = swapInteractor.getSelectedWallet()?.walletId ?: return@launch
(dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let {
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = it,
isFromCurrency = true,
)
}
(dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let {
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = it,
isFromCurrency = false,
)
}
}.onFailure {
Timber.tag(loggingTag).e(it)
@ -302,7 +325,7 @@ internal class SwapViewModel @Inject constructor(
val (provider, state) = updateLoadedQuotes(providersState)
setupLoadedState(provider, state, fromToken)
val successStates = providersState.getLastLoadedSuccessStates()
val pricesLowerBest = getPricesLowerBest(successStates)
val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates)
uiState = stateBuilder.updateProvidersBottomSheetContent(
uiState = uiState,
pricesLowerBest = pricesLowerBest,
@ -623,7 +646,6 @@ internal class SwapViewModel @Inject constructor(
unavailable = unavailable,
afterSearch = true,
),
)
} else {
tokenDataState.copy(
@ -653,15 +675,35 @@ internal class SwapViewModel @Inject constructor(
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = true, token = it))
}
val userWalletId = swapInteractor.getSelectedWallet()?.walletId
if (foundToken != null) {
val fromToken: CryptoCurrencyStatus
val toToken: CryptoCurrencyStatus
if (isOrderReversed) {
fromToken = foundToken.currencyStatus
toToken = initialCryptoCurrencyStatus
val newToken = fromToken.currency as? CryptoCurrency.Coin
if (userWalletId != null && newToken != null) {
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = newToken,
isFromCurrency = true,
)
}
} else {
fromToken = initialCryptoCurrencyStatus
toToken = foundToken.currencyStatus
val newToken = toToken.currency as? CryptoCurrency.Coin
if (userWalletId != null && newToken != null) {
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = newToken,
isFromCurrency = false,
)
}
}
dataState = dataState.copy(
fromCryptoCurrency = fromToken,
@ -679,6 +721,36 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun subscribeToCoinBalanceUpdates(
userWalletId: UserWalletId,
coin: CryptoCurrency.Coin,
isFromCurrency: Boolean,
) {
Timber.d("Subscribe to ${coin.id} balance updates")
getCurrencyStatusUpdatesUseCase(
userWalletId = userWalletId,
currencyId = coin.id,
isSingleWalletWithTokens = false,
)
.mapNotNull { (it as? Either.Right)?.value }
.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes
.onEach {
Timber.d("${coin.id} balance is ${it.value.amount}")
uiState = if (isFromCurrency) {
dataState = dataState.copy(fromCryptoCurrency = it)
stateBuilder.updateSendCurrencyBalance(uiState, it)
} else {
dataState = dataState.copy(toCryptoCurrency = it)
stateBuilder.updateReceiveCurrencyBalance(uiState, it)
}
}
.flowOn(dispatchers.main)
.launchIn(viewModelScope)
.saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder)
}
private fun onChangeCardsClicked() {
val newFromToken = dataState.toCryptoCurrency
val newToToken = dataState.fromCryptoCurrency
@ -847,13 +919,14 @@ internal class SwapViewModel @Inject constructor(
onProviderClick = { providerId ->
analyticsEventHandler.send(SwapEvents.ProviderClicked)
val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
val pricesLowerBest = getPricesLowerBest(states)
val pricesLowerBest = getPricesLowerBest(providerId, states)
val unavailableProviders = getUnavailableProvidersFor(dataState.lastLoadedSwapStates)
uiState = stateBuilder.showSelectProviderBottomSheet(
uiState = uiState,
selectedProviderId = providerId,
pricesLowerBest = pricesLowerBest,
unavailableProviders = unavailableProviders,
bestRatedProviderId = findBestQuoteProvider(states)?.providerId ?: providerId,
providersStates = dataState.lastLoadedSwapStates,
) { uiState = stateBuilder.dismissBottomSheet(uiState) }
},
@ -871,12 +944,11 @@ internal class SwapViewModel @Inject constructor(
)
}
},
onBuyClick = {
swapInteractor.getSelectedWallet()?.let {
val fromToken = dataState.fromCryptoCurrency ?: return@let
onBuyClick = { currency ->
swapInteractor.getSelectedWallet()?.let { userWallet ->
swapRouter.openTokenDetails(
it.walletId,
swapInteractor.getNativeToken(fromToken.currency.network.backendId),
userWalletId = userWallet.walletId,
currency = currency,
)
}
},
@ -962,17 +1034,18 @@ internal class SwapViewModel @Inject constructor(
}?.key
}
private fun getPricesLowerBest(state: SuccessLoadedSwapData): Map<String, Float> {
val bestRateEntry = state.maxByOrNull { it.value.toTokenInfo.tokenAmount.value } ?: return emptyMap()
val bestRate = bestRateEntry.value.toTokenInfo.tokenAmount.value
private fun getPricesLowerBest(selectedProviderId: String, state: SuccessLoadedSwapData): Map<String, Float> {
val selectedProviderEntry = state.filter { it.key.providerId == selectedProviderId }.entries.firstOrNull()
?: return emptyMap()
val selectedProviderRate = selectedProviderEntry.value.toTokenInfo.tokenAmount.value
val hundredPercent = BigDecimal("100")
return state.entries.mapNotNull {
if (it.key != bestRateEntry.key) {
if (it.key != selectedProviderEntry.key) {
val amount = it.value.toTokenInfo.tokenAmount.value
val percentDiff = BigDecimal.ONE.minus(
amount.divide(bestRate, RoundingMode.HALF_UP),
selectedProviderRate.divide(amount, RoundingMode.HALF_UP),
).multiply(hundredPercent)
it.key.providerId to percentDiff.setScale(2, RoundingMode.HALF_UP).toFloat().absoluteValue
it.key.providerId to percentDiff.setScale(2, RoundingMode.HALF_UP).toFloat()
} else {
null
}

View file

@ -2,4 +2,8 @@ plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.tester.api"
}

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.tangem.features.tester.api" />

View file

@ -6,6 +6,10 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.feature.tester.impl"
}
dependencies {
/** AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tangem.feature.tester.impl">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity

View file

@ -13,6 +13,7 @@ internal data class SwapTransactionsState(
val txId: String,
val provider: SwapProvider,
val txUrl: String? = null,
val txExternalId: String? = null,
val timestamp: TextReference,
val fiatSymbol: String,
val activeStatus: ExchangeStatus?,

View file

@ -99,6 +99,7 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
private val networkName: String,
private val feeCurrencyName: String,
private val feeCurrencySymbol: String,
val mergeFeeNetworkName: Boolean = false,
private val onBuyClick: () -> Unit,
) : Warning(
title = TextReference.Res(
@ -120,7 +121,13 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) {
NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(
id = R.string.common_buy_currency,
formatArgs = wrappedList(feeCurrencySymbol),
formatArgs = wrappedList(
if (mergeFeeNetworkName) {
"$feeCurrencyName ($feeCurrencySymbol)"
} else {
feeCurrencySymbol
},
),
),
onClick = onBuyClick,
),

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
@ -18,7 +19,6 @@ import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class TokenDetailsLoadedBalanceConverter(
private val currentStateProvider: Provider<TokenDetailsState>,
@ -118,9 +118,7 @@ internal class TokenDetailsLoadedBalanceConverter(
}
private fun getPriceChangeType(status: CryptoCurrencyStatus.Status): PriceChangeType {
val priceChange = status.priceChange ?: return PriceChangeType.DOWN
return if (priceChange > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN
return PriceChangeConverter.fromBigDecimal(status.priceChange)
}
private fun formatPriceChange(status: CryptoCurrencyStatus.Status): String {

Some files were not shown because too many files have changed in this diff Show more