Updated on 2026-08-14
This commit is contained in:
commit
3f386274ec
286 changed files with 4852 additions and 2134 deletions
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.features.managetokens.navigation
|
||||
|
||||
enum class ExpandableState {
|
||||
EXPANDED,
|
||||
COLLAPSED,
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ plugins {
|
|||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.feature.onboarding"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Core modules */
|
||||
implementation(project(":common"))
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.tangem.feature.onboarding" />
|
||||
|
|
@ -20,6 +20,7 @@ dependencies {
|
|||
|
||||
implementation(deps.androidx.fragment.ktx)
|
||||
implementation(deps.androidx.activity.compose)
|
||||
implementation(deps.lifecycle.compose)
|
||||
|
||||
/** Camera */
|
||||
implementation(deps.camera.camera2)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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/*"
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@ plugins {
|
|||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.feature.referral.data"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
/** Project */
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.tangem.feature.referral.data" />
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.tangem.feature.referral.domain" />
|
||||
|
|
@ -6,6 +6,10 @@ plugins {
|
|||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.feature.referral.presentation"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Core modules */
|
||||
implementation(project(":core:analytics"))
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.tangem.feature.referral.presentation" />
|
||||
|
|
@ -73,7 +73,7 @@ internal class SendFragment : ComposeFragment() {
|
|||
SystemBarsEffect {
|
||||
setSystemBarsColor(systemBarsColor)
|
||||
}
|
||||
SendScreen(viewModel.uiState)
|
||||
SendScreen(viewModel.uiState, viewModel.stateRouter.currentState)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ internal class SendAmountStateConverter(
|
|||
),
|
||||
SendAmountSegmentedButtonsConfig(
|
||||
title = stringReference(appCurrency.code),
|
||||
iconState = iconStateConverter.convert(status),
|
||||
iconUrl = appCurrency.iconSmallUrl,
|
||||
isFiat = true,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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/"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,10 @@ plugins {
|
|||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.feature.swap.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.tangem.feature.swap.api" />
|
||||
|
|
@ -6,6 +6,10 @@ plugins {
|
|||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.feature.swap.data"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
/** AndroidX */
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.tangem.feature.swap.data" />
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -3,5 +3,4 @@ package com.tangem.feature.swap.domain.models.domain
|
|||
data class NetworkInfo(
|
||||
val name: String,
|
||||
val blockchainId: String,
|
||||
val blockchainCurrency: String,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ internal class DefaultBlockchainInteractor @Inject constructor(
|
|||
NetworkInfo(
|
||||
name = it.name,
|
||||
blockchainId = it.blockchainId,
|
||||
blockchainCurrency = it.blockchainCurrency,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ plugins {
|
|||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.feature.swap.presentation"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Core modules */
|
||||
implementation(projects.core.analytics)
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.tangem.feature.swap.presentation" />
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -490,7 +490,6 @@ private val state = SwapStateHolder(
|
|||
),
|
||||
),
|
||||
),
|
||||
networkCurrency = "MATIC",
|
||||
swapButton = SwapButton(enabled = true, onClick = {}),
|
||||
onRefresh = {},
|
||||
onBackClicked = {},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,8 @@ plugins {
|
|||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.features.tester.api"
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.tangem.features.tester.api" />
|
||||
|
|
@ -6,6 +6,10 @@ plugins {
|
|||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.feature.tester.impl"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** AndroidX */
|
||||
implementation(deps.androidx.activity.compose)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue