Updated on 2026-08-14
This commit is contained in:
commit
6d0b295ca3
219 changed files with 3720 additions and 1687 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -33,11 +33,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 +66,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 +75,17 @@ 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 val currenciesListJobHolder: JobHolder = JobHolder()
|
||||
|
||||
private var wallets: List<UserWallet> by Delegates.notNull()
|
||||
private val debouncer = Debouncer()
|
||||
|
||||
private var addedCurrenciesByWallet: MutableMap<UserWallet, MutableList<CryptoCurrency>> = mutableMapOf()
|
||||
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 +122,53 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun subscribeToCurrencies(userWallets: List<UserWallet>) {
|
||||
wallets = CopyOnWriteArrayList(userWallets.filter { it.isMultiCurrency && !it.isLocked })
|
||||
|
||||
combine(wallets.map { getCurrenciesUseCase.invoke(it.walletId).distinctUntilChanged() }) {
|
||||
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) }
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,6 @@ internal class SendOnNextScreenAnalyticSender(
|
|||
}
|
||||
analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(selectedFee.name))
|
||||
}
|
||||
if (feeState.isSubtract) {
|
||||
analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount)
|
||||
}
|
||||
}
|
||||
SendUiStateType.Amount -> {
|
||||
val isFiatSelected = state.amountState?.amountTextField?.isFiatValue ?: return
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.tangem.features.send.impl.presentation.state
|
|||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.core.ui.event.consumedEvent
|
||||
import com.tangem.core.ui.event.triggeredEvent
|
||||
import com.tangem.core.ui.utils.parseToBigDecimal
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
|
||||
import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory
|
||||
|
|
@ -43,6 +44,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 +89,29 @@ internal class SendEventStateFactory(
|
|||
}
|
||||
}
|
||||
|
||||
fun getFeeTooLowAlert(onConsume: () -> Unit): SendUiState {
|
||||
val state = currentStateProvider()
|
||||
val feeSelectorState = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return state
|
||||
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return state
|
||||
val minimumValue = multipleFees.minimum.amount.value ?: return state
|
||||
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return state
|
||||
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
|
||||
|
||||
val isFeeTooLow = feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue
|
||||
if (!isFeeTooLow) return state
|
||||
|
||||
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,15 @@
|
|||
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.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.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 +21,47 @@ 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,
|
||||
) {
|
||||
|
||||
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 +169,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 +221,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 +254,40 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
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,13 +85,20 @@ 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,
|
||||
|
|
|
|||
|
|
@ -14,58 +14,58 @@ 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) {
|
||||
isEditingDisabled -> when (type) {
|
||||
SendUiStateType.Send -> {
|
||||
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee))
|
||||
showFee()
|
||||
}
|
||||
else -> popBackStack()
|
||||
}
|
||||
else -> when (currentState.value) {
|
||||
else -> when (type) {
|
||||
SendUiStateType.Amount -> {
|
||||
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Address))
|
||||
showRecipient()
|
||||
continueToSend(::showRecipient)
|
||||
}
|
||||
SendUiStateType.Fee -> {
|
||||
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount))
|
||||
showAmount()
|
||||
continueToSend(::showAmount)
|
||||
}
|
||||
SendUiStateType.Send -> {
|
||||
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee))
|
||||
showFee()
|
||||
continueToSend(::showFee)
|
||||
}
|
||||
else -> popBackStack()
|
||||
else -> continueToSend(::popBackStack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNextClick(): SendUiStateType {
|
||||
val prevState = currentState.value
|
||||
when (currentState.value) {
|
||||
val prevState = currentState.value.type
|
||||
when (currentState.value.type) {
|
||||
SendUiStateType.Recipient -> {
|
||||
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Amount))
|
||||
showAmount()
|
||||
continueToSend(::showAmount)
|
||||
}
|
||||
SendUiStateType.Amount -> {
|
||||
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee))
|
||||
showFee()
|
||||
continueToSend(::showFee)
|
||||
}
|
||||
SendUiStateType.Fee -> {
|
||||
analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee))
|
||||
|
|
@ -83,7 +83,7 @@ internal class StateRouter(
|
|||
if (isEditingDisabled) {
|
||||
popBackStack()
|
||||
} else {
|
||||
when (currentState.value) {
|
||||
when (currentState.value.type) {
|
||||
SendUiStateType.Amount -> {
|
||||
analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount))
|
||||
showRecipient()
|
||||
|
|
@ -97,23 +97,39 @@ internal class StateRouter(
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
package com.tangem.features.send.impl.presentation.state.fee
|
||||
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
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
|
||||
}
|
||||
|
|
@ -1,17 +1,19 @@
|
|||
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.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.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 +24,19 @@ 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,
|
||||
) {
|
||||
|
||||
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 +49,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 +62,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 +77,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,6 +92,7 @@ internal class FeeNotificationFactory(
|
|||
ifRight = { it },
|
||||
) ?: return
|
||||
|
||||
val mergeFeeNetworkName = cryptoCurrencyStatus.shouldMergeFeeNetworkName()
|
||||
when (warning) {
|
||||
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> {
|
||||
add(
|
||||
|
|
@ -127,6 +102,7 @@ internal class FeeNotificationFactory(
|
|||
currencyName = cryptoCurrencyStatus.currency.name,
|
||||
feeName = warning.coinCurrency.name,
|
||||
feeSymbol = warning.coinCurrency.symbol,
|
||||
mergeFeeNetworkName = mergeFeeNetworkName,
|
||||
onClick = {
|
||||
clickIntents.onTokenDetailsClick(
|
||||
userWalletId = userWalletId,
|
||||
|
|
@ -145,6 +121,7 @@ internal class FeeNotificationFactory(
|
|||
feeName = warning.feeCurrencyName,
|
||||
feeSymbol = warning.feeCurrencySymbol,
|
||||
networkName = warning.networkName,
|
||||
mergeFeeNetworkName = mergeFeeNetworkName,
|
||||
onClick = currency?.let {
|
||||
{
|
||||
clickIntents.onTokenDetailsClick(
|
||||
|
|
@ -160,6 +137,11 @@ internal class FeeNotificationFactory(
|
|||
}
|
||||
}
|
||||
|
||||
// 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,9 +52,8 @@ 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,
|
||||
|
|
@ -68,43 +64,15 @@ internal class FeeStateFactory(
|
|||
)
|
||||
|
||||
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 +86,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 +102,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 +128,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 +136,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 +147,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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ 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 +23,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: State<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 +54,13 @@ internal fun SendNavigationButtons(uiState: SendUiState) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SendSecondaryNavigationButton(uiState: SendUiState) {
|
||||
val currentState = uiState.currentState.collectAsState()
|
||||
private fun SendSecondaryNavigationButton(uiState: SendUiState, currentState: State<SendUiCurrentScreen>) {
|
||||
val isEditingDisabled = uiState.isEditingDisabled
|
||||
val isCorrectScreen = currentState.value == SendUiStateType.Amount || currentState.value == SendUiStateType.Fee
|
||||
val isFromConfirmation = currentState.value.isFromConfirmation
|
||||
val isCorrectScreen =
|
||||
currentState.value.type == SendUiStateType.Amount || currentState.value.type == SendUiStateType.Fee
|
||||
AnimatedVisibility(
|
||||
visible = !isEditingDisabled && isCorrectScreen,
|
||||
visible = !isEditingDisabled && isCorrectScreen && !isFromConfirmation,
|
||||
enter = expandHorizontally(expandFrom = Alignment.End),
|
||||
exit = shrinkHorizontally(shrinkTowards = Alignment.End),
|
||||
) {
|
||||
|
|
@ -77,8 +81,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: State<SendUiCurrentScreen>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isSuccess = uiState.sendState.isSuccess
|
||||
val isSending = uiState.sendState.isSending
|
||||
val txUrl = uiState.sendState.txUrl
|
||||
|
|
@ -100,7 +107,7 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier
|
|||
modifier = modifier,
|
||||
) { textId ->
|
||||
when {
|
||||
currentState.value == SendUiStateType.Send && !isSuccess -> {
|
||||
currentState.value.type == SendUiStateType.Send && !isSuccess -> {
|
||||
val hapticFeedback = rememberHapticFeedback(state = currentState, onAction = buttonClick)
|
||||
PrimaryButtonIconEnd(
|
||||
text = stringResource(textId),
|
||||
|
|
@ -110,11 +117,11 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier
|
|||
showProgress = isSending,
|
||||
)
|
||||
}
|
||||
currentState.value == SendUiStateType.Send && isSuccess -> {
|
||||
currentState.value.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 +184,19 @@ private fun PrimaryButtonsDone(
|
|||
|
||||
private fun getButtonData(
|
||||
uiState: SendUiState,
|
||||
currentState: State<SendUiStateType>,
|
||||
currentState: State<SendUiCurrentScreen>,
|
||||
isSuccess: Boolean,
|
||||
): Pair<Int, () -> Unit> {
|
||||
return when (currentState.value) {
|
||||
return when (currentState.value.type) {
|
||||
SendUiStateType.None,
|
||||
SendUiStateType.Amount,
|
||||
SendUiStateType.Recipient,
|
||||
SendUiStateType.Fee,
|
||||
-> R.string.common_next to uiState.clickIntents::onNextClick
|
||||
-> if (currentState.value.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 +205,8 @@ private fun getButtonData(
|
|||
}
|
||||
}
|
||||
|
||||
private fun isButtonEnabled(currentState: State<SendUiStateType>, uiState: SendUiState): Boolean {
|
||||
return when (currentState.value) {
|
||||
private fun isButtonEnabled(currentState: State<SendUiCurrentScreen>, uiState: SendUiState): Boolean {
|
||||
return when (currentState.value.type) {
|
||||
SendUiStateType.Amount -> uiState.amountState?.isPrimaryButtonEnabled ?: false
|
||||
SendUiStateType.Recipient -> uiState.recipientState?.isPrimaryButtonEnabled ?: false
|
||||
SendUiStateType.Fee -> uiState.feeState?.isPrimaryButtonEnabled ?: false
|
||||
|
|
|
|||
|
|
@ -15,21 +15,21 @@ 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 +40,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
|
||||
|
|
@ -67,7 +67,7 @@ internal fun SendScreen(uiState: SendUiState) {
|
|||
modifier = Modifier
|
||||
.weight(1f),
|
||||
)
|
||||
SendNavigationButtons(uiState)
|
||||
SendNavigationButtons(uiState, currentState)
|
||||
}
|
||||
|
||||
SendEventEffect(
|
||||
|
|
@ -79,16 +79,15 @@ internal fun SendScreen(uiState: SendUiState) {
|
|||
@Composable
|
||||
private fun SendScreenContent(
|
||||
uiState: SendUiState,
|
||||
currentState: State<SendUiStateType>,
|
||||
currentState: State<SendUiCurrentScreen>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val recipientList = uiState.recipientList.collectAsLazyPagingItems()
|
||||
AnimatedContent(
|
||||
targetState = currentState.value,
|
||||
label = "Send Scree Navigation",
|
||||
modifier = modifier,
|
||||
) { state ->
|
||||
when (state) {
|
||||
when (state.type) {
|
||||
SendUiStateType.Amount -> SendAmountContent(
|
||||
amountState = uiState.amountState,
|
||||
isBalanceHiding = uiState.isBalanceHidden,
|
||||
|
|
@ -97,7 +96,6 @@ private fun SendScreenContent(
|
|||
SendUiStateType.Recipient -> SendRecipientContent(
|
||||
uiState = uiState.recipientState,
|
||||
clickIntents = uiState.clickIntents,
|
||||
recipientList = recipientList,
|
||||
)
|
||||
SendUiStateType.Fee -> SendSpeedAndFeeContent(
|
||||
state = uiState.feeState,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -44,18 +42,20 @@ 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.checkFeeCoverage
|
||||
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 +70,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 +103,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 +124,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,6 +140,7 @@ internal class SendViewModel @Inject constructor(
|
|||
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
|
||||
currentStateProvider = Provider { uiState },
|
||||
userWalletProvider = Provider { userWallet },
|
||||
stateRouterProvider = Provider { stateRouter },
|
||||
clickIntents = this,
|
||||
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
|
||||
)
|
||||
|
|
@ -150,6 +150,7 @@ internal class SendViewModel @Inject constructor(
|
|||
coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus },
|
||||
currentStateProvider = Provider { uiState },
|
||||
userWalletProvider = Provider { userWallet },
|
||||
stateRouterProvider = Provider { stateRouter },
|
||||
currencyChecksRepository = currencyChecksRepository,
|
||||
clickIntents = this,
|
||||
)
|
||||
|
|
@ -170,6 +171,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 +181,35 @@ 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 +221,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 +259,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 +310,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 +330,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,35 +355,21 @@ 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) {
|
||||
SendUiStateType.Fee -> loadFee()
|
||||
when (it.type) {
|
||||
SendUiStateType.Fee -> if (!it.isFromConfirmation) loadFee()
|
||||
SendUiStateType.Send -> sendIdleTimer = System.currentTimeMillis()
|
||||
else -> Unit
|
||||
}
|
||||
|
|
@ -407,6 +401,23 @@ internal class SendViewModel @Inject constructor(
|
|||
override fun popBackStack() = stateRouter.popBackStack()
|
||||
override fun onBackClick() = stateRouter.onBackClick(uiState.sendState.isSuccess)
|
||||
override fun onNextClick() {
|
||||
val currentState = stateRouter.currentState.value
|
||||
val isCurrentFee = currentState.type == SendUiStateType.Fee
|
||||
if (isCurrentFee) {
|
||||
uiState = eventStateFactory.getFeeTooLowAlert(
|
||||
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
|
||||
)
|
||||
val isFeeCoverage = checkFeeCoverage(uiState, cryptoCurrencyStatus)
|
||||
if (isAmountSubtractAvailable && isFeeCoverage) {
|
||||
uiState = eventStateFactory.getFeeCoverageAlert(
|
||||
onConsume = { uiState = eventStateFactory.onConsumeEventState() },
|
||||
)
|
||||
return
|
||||
} else {
|
||||
uiState = stateFactory.onSubtractSelect(false)
|
||||
}
|
||||
}
|
||||
|
||||
val prevScreen = stateRouter.onNextClick()
|
||||
sendOnNextScreenAnalyticSender.send(prevScreen, uiState)
|
||||
}
|
||||
|
|
@ -482,11 +493,13 @@ internal class SendViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
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 +511,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 +539,27 @@ 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()
|
||||
}
|
||||
|
||||
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()
|
||||
uiState = callFeeUseCase()?.fold(
|
||||
ifRight = { fees ->
|
||||
feeStateFactory.onFeeOnLoadedState(fees, isAmountSubtractAvailable)
|
||||
},
|
||||
ifLeft = {
|
||||
feeStateFactory.onFeeOnErrorState()
|
||||
},
|
||||
ifRight = feeStateFactory::onFeeOnLoadedState,
|
||||
ifLeft = { feeStateFactory.onFeeOnErrorState() },
|
||||
) ?: feeStateFactory.onFeeOnErrorState()
|
||||
updateFeeNotifications()
|
||||
}.saveIn(feeJobHolder)
|
||||
|
|
@ -573,48 +602,55 @@ internal class SendViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
override fun showAmount() {
|
||||
stateRouter.showAmount()
|
||||
uiState = stateFactory.onSubtractSelect(false)
|
||||
stateRouter.showAmount(isFromConfirmation = true)
|
||||
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount))
|
||||
}
|
||||
|
||||
override fun showRecipient() {
|
||||
stateRouter.showRecipient()
|
||||
uiState = stateFactory.onSubtractSelect(false)
|
||||
stateRouter.showRecipient(isFromConfirmation = true)
|
||||
analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address))
|
||||
}
|
||||
|
||||
override fun showFee() {
|
||||
stateRouter.showFee()
|
||||
uiState = stateFactory.onSubtractSelect(false)
|
||||
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
|
||||
}
|
||||
|
|
@ -677,11 +713,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 +755,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" />
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -693,6 +693,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 +1024,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 +1032,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 +1073,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 +1120,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 +1180,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,
|
||||
|
|
@ -1211,7 +1241,7 @@ internal class StateBuilder(
|
|||
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 +1286,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 +1317,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 +1353,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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -13,11 +14,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
|
||||
|
|
@ -46,7 +49,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>
|
||||
|
|
@ -62,6 +64,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 {
|
||||
|
||||
|
|
@ -104,6 +107,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
|
||||
|
||||
|
|
@ -173,6 +179,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)
|
||||
|
||||
|
|
@ -301,7 +324,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,
|
||||
|
|
@ -617,7 +640,6 @@ internal class SwapViewModel @Inject constructor(
|
|||
unavailable = unavailable,
|
||||
afterSearch = true,
|
||||
),
|
||||
|
||||
)
|
||||
} else {
|
||||
tokenDataState.copy(
|
||||
|
|
@ -647,15 +669,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,
|
||||
|
|
@ -673,6 +715,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
|
||||
|
|
@ -835,13 +907,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) }
|
||||
},
|
||||
|
|
@ -950,17 +1023,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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.domain.common.extensions.fromNetworkId
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
|
||||
|
|
@ -28,9 +31,10 @@ internal class TokenDetailsNotificationConverter(
|
|||
return when (warning) {
|
||||
is CryptoCurrencyWarning.BalanceNotEnoughForFee -> NetworkFeeWithBuyButton(
|
||||
currency = warning.tokenCurrency,
|
||||
networkName = warning.coinCurrency.name,
|
||||
networkName = warning.coinCurrency.network.name,
|
||||
feeCurrencyName = warning.coinCurrency.name,
|
||||
feeCurrencySymbol = warning.coinCurrency.symbol,
|
||||
mergeFeeNetworkName = warning.coinCurrency.shouldMergeFeeNetworkName(),
|
||||
onBuyClick = { clickIntents.onBuyCoinClick(warning.coinCurrency) },
|
||||
)
|
||||
is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> {
|
||||
|
|
@ -41,6 +45,7 @@ internal class TokenDetailsNotificationConverter(
|
|||
networkName = feeCurrency.network.name,
|
||||
feeCurrencyName = warning.feeCurrencyName,
|
||||
feeCurrencySymbol = warning.feeCurrencySymbol,
|
||||
mergeFeeNetworkName = warning.currency.shouldMergeFeeNetworkName(),
|
||||
onBuyClick = { clickIntents.onBuyCoinClick(feeCurrency) },
|
||||
)
|
||||
} else {
|
||||
|
|
@ -75,4 +80,9 @@ internal class TokenDetailsNotificationConverter(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
// workaround for networks that users have misunderstanding
|
||||
private fun CryptoCurrency.shouldMergeFeeNetworkName(): Boolean {
|
||||
return Blockchain.fromNetworkId(this.network.backendId) == Blockchain.Arbitrum
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
|
|||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.feature.swap.domain.SwapTransactionRepository
|
||||
import com.tangem.feature.swap.domain.api.SwapRepository
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
|
||||
import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsCurrencyStatusAnalyticsSender
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender
|
||||
|
|
@ -84,6 +85,7 @@ internal class TokenDetailsViewModel @Inject constructor(
|
|||
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
|
||||
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
|
||||
private val shouldShowSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase,
|
||||
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
|
||||
private val swapRepository: SwapRepository,
|
||||
private val swapTransactionRepository: SwapTransactionRepository,
|
||||
private val quotesRepository: QuotesRepository,
|
||||
|
|
@ -301,6 +303,9 @@ internal class TokenDetailsViewModel @Inject constructor(
|
|||
val config = uiState.bottomSheetConfig
|
||||
val exchangeBottomSheet = config?.content as? ExchangeStatusBottomSheetConfig
|
||||
val currentTx = swapTxs.firstOrNull { it.txId == exchangeBottomSheet?.value?.txId }
|
||||
if (currentTx?.activeStatus == ExchangeStatus.Finished) {
|
||||
updateNetworkToSwapBalance(currentTx.toCryptoCurrency)
|
||||
}
|
||||
uiState = uiState.copy(
|
||||
swapTxs = swapTxs,
|
||||
bottomSheetConfig = currentTx?.let(
|
||||
|
|
@ -309,6 +314,16 @@ internal class TokenDetailsViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun updateNetworkToSwapBalance(toCryptoCurrency: CryptoCurrency) {
|
||||
viewModelScope.launch {
|
||||
updateDelayedCurrencyStatusUseCase(
|
||||
userWalletId = userWalletId,
|
||||
network = toCryptoCurrency.network,
|
||||
refresh = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param refresh - invalidate cache and get data from remote
|
||||
* @param showItemsLoading - show loading items placeholder.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ plugins {
|
|||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.features.wallet.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** AndroidX */
|
||||
implementation(deps.androidx.fragment.ktx)
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.tangem.features.wallet.api" />
|
||||
|
|
@ -6,6 +6,10 @@ plugins {
|
|||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.feature.wallet.impl"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** AndroidX */
|
||||
implementation(deps.androidx.activity.compose)
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.tangem.feature.wallet.impl">
|
||||
</manifest>
|
||||
|
|
@ -87,11 +87,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,
|
||||
)
|
||||
|
|
@ -106,6 +108,7 @@ private fun PriceChangeText(type: PriceChangeType?, text: String?, modifier: Mod
|
|||
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.tertiary
|
||||
},
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert
|
|||
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.utils.BigDecimalFormatter
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
|
||||
import com.tangem.utils.converter.Converter
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class SingleWalletMarketPriceConverter(
|
||||
private val status: CryptoCurrencyStatus.Status,
|
||||
|
|
@ -61,8 +61,6 @@ internal class SingleWalletMarketPriceConverter(
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert
|
|||
|
||||
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
|
||||
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.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
|
||||
|
|
@ -97,8 +98,6 @@ internal class TokenItemStateConverter(
|
|||
priceChangePercent = BigDecimalFormatter.formatPercent(
|
||||
percent = priceChange,
|
||||
useAbsoluteValue = true,
|
||||
maxFractionDigits = 1,
|
||||
minFractionDigits = 1,
|
||||
),
|
||||
type = priceChange.getPriceChangeType(),
|
||||
)
|
||||
|
|
@ -117,6 +116,6 @@ internal class TokenItemStateConverter(
|
|||
}
|
||||
|
||||
private fun BigDecimal.getPriceChangeType(): PriceChangeType {
|
||||
return if (this > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN
|
||||
return PriceChangeConverter.fromBigDecimal(value = this)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
package com.tangem.feature.wallet.presentation.wallet.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
|
|
@ -13,16 +17,19 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
|||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import com.tangem.core.ui.components.Keyboard
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.components.SystemBarsEffect
|
||||
import com.tangem.core.ui.components.atoms.Hand
|
||||
import com.tangem.core.ui.components.atoms.handComposableComponentHeight
|
||||
import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheet
|
||||
|
|
@ -64,6 +71,13 @@ internal fun WalletScreen(
|
|||
val snackbarHostState = remember(::SnackbarHostState)
|
||||
val isAutoScroll = remember { mutableStateOf(value = false) }
|
||||
|
||||
var alertConfig by remember { mutableStateOf<WalletAlertState?>(value = null) }
|
||||
|
||||
val config = alertConfig
|
||||
if (config != null) {
|
||||
WalletAlert(state = config, onDismiss = { alertConfig = null })
|
||||
}
|
||||
|
||||
WalletContent(
|
||||
state = state,
|
||||
walletsListState = walletsListState,
|
||||
|
|
@ -72,14 +86,9 @@ internal fun WalletScreen(
|
|||
onAutoScrollReset = { isAutoScroll.value = false },
|
||||
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
|
||||
bottomSheetContent = bottomSheetContent,
|
||||
alertConfig = alertConfig,
|
||||
)
|
||||
|
||||
var alertConfig by remember { mutableStateOf<WalletAlertState?>(value = null) }
|
||||
|
||||
alertConfig?.let {
|
||||
WalletAlert(state = it, onDismiss = { alertConfig = null })
|
||||
}
|
||||
|
||||
WalletEventEffect(
|
||||
event = state.event,
|
||||
selectedWalletIndex = state.selectedWalletIndex,
|
||||
|
|
@ -100,8 +109,9 @@ private fun WalletContent(
|
|||
bottomSheetHeaderHeightProvider: () -> Dp,
|
||||
onAutoScrollReset: () -> Unit,
|
||||
bottomSheetContent: @Composable () -> Unit,
|
||||
alertConfig: WalletAlertState?,
|
||||
) {
|
||||
var selectedWalletIndex by remember { mutableIntStateOf(state.selectedWalletIndex) }
|
||||
var selectedWalletIndex by remember(state.selectedWalletIndex) { mutableIntStateOf(state.selectedWalletIndex) }
|
||||
val selectedWallet = state.wallets[selectedWalletIndex]
|
||||
|
||||
val scaffoldContent: @Composable () -> Unit = {
|
||||
|
|
@ -207,6 +217,7 @@ private fun WalletContent(
|
|||
snackbarHostState = snackbarHostState,
|
||||
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
|
||||
bottomSheetContent = bottomSheetContent,
|
||||
alertConfig = alertConfig,
|
||||
) {
|
||||
scaffoldContent()
|
||||
}
|
||||
|
|
@ -222,7 +233,7 @@ private fun WalletContent(
|
|||
}
|
||||
|
||||
@Suppress("LongParameterList", "LongMethod")
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BaseScaffoldManageTokenRedesign(
|
||||
state: WalletScreenState,
|
||||
|
|
@ -230,49 +241,46 @@ private fun BaseScaffoldManageTokenRedesign(
|
|||
snackbarHostState: SnackbarHostState,
|
||||
bottomSheetHeaderHeightProvider: () -> Dp,
|
||||
bottomSheetContent: @Composable () -> Unit,
|
||||
alertConfig: WalletAlertState?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scaffoldState = rememberBottomSheetScaffoldState()
|
||||
// show the bottom sheet if there is at least one multicurrency wallet
|
||||
val showManageTokensBottomSheet = remember(state.wallets) {
|
||||
state.wallets.any { it is WalletState.MultiCurrency }
|
||||
}
|
||||
val bottomSheetState = rememberSheetStateEnhanced(
|
||||
initialValue = if (showManageTokensBottomSheet) SheetValue.PartiallyExpanded else SheetValue.Hidden,
|
||||
confirmValueChange = { sheetValue ->
|
||||
when {
|
||||
sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false
|
||||
sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
skipHiddenState = showManageTokensBottomSheet,
|
||||
)
|
||||
|
||||
val keyboardShown = keyboardAsState()
|
||||
|
||||
BottomSheetStateEffects(
|
||||
bottomSheetState = bottomSheetState,
|
||||
showManageTokensBottomSheet = showManageTokensBottomSheet,
|
||||
alertConfig = alertConfig,
|
||||
keyboardShown = keyboardShown,
|
||||
)
|
||||
|
||||
val scaffoldState = rememberBottomSheetScaffoldState(
|
||||
bottomSheetState = bottomSheetState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
)
|
||||
|
||||
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
|
||||
val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val navigationBarColor = TangemTheme.colors.background.primary
|
||||
val navigationBarColorWithout = TangemTheme.colors.background.secondary
|
||||
|
||||
DisposableEffect(
|
||||
navigationBarColor,
|
||||
navigationBarColorWithout,
|
||||
) {
|
||||
systemUiController.setNavigationBarColor(navigationBarColor)
|
||||
onDispose {
|
||||
systemUiController.setNavigationBarColor(navigationBarColorWithout)
|
||||
}
|
||||
}
|
||||
|
||||
val keyboardShown by keyboardAsState()
|
||||
// expand bottom sheet when keyboard appears
|
||||
LaunchedEffect(keyboardShown is Keyboard.Opened) {
|
||||
if (keyboardShown is Keyboard.Opened) {
|
||||
scaffoldState.bottomSheetState.expand()
|
||||
}
|
||||
}
|
||||
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val sheetHasBeenHidden = scaffoldState.bottomSheetState.targetValue == SheetValue.PartiallyExpanded
|
||||
// hide keyboard when bottom sheet is about to be hidden
|
||||
LaunchedEffect(sheetHasBeenHidden) {
|
||||
if (sheetHasBeenHidden) {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
BottomSheetScaffold(
|
||||
topBar = {
|
||||
WalletTopBar(config = state.topBarConfig)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
},
|
||||
|
|
@ -296,10 +304,10 @@ private fun BaseScaffoldManageTokenRedesign(
|
|||
|
||||
// hide bottom sheet when back pressed
|
||||
BackHandler(
|
||||
keyboardShown is Keyboard.Closed &&
|
||||
scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded,
|
||||
keyboardShown.value is Keyboard.Closed &&
|
||||
bottomSheetState.currentValue == SheetValue.Expanded,
|
||||
) {
|
||||
coroutineScope.launch { scaffoldState.bottomSheetState.partialExpand() }
|
||||
coroutineScope.launch { bottomSheetState.partialExpand() }
|
||||
}
|
||||
},
|
||||
content = { paddingValues ->
|
||||
|
|
@ -308,23 +316,153 @@ private fun BaseScaffoldManageTokenRedesign(
|
|||
onRefresh = selectedWallet.pullToRefreshConfig.onRefresh,
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.pullRefresh(pullRefreshState)
|
||||
.padding(paddingValues),
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
) {
|
||||
content()
|
||||
WalletTopBar(config = state.topBarConfig)
|
||||
Box(
|
||||
modifier = Modifier.pullRefresh(pullRefreshState),
|
||||
) {
|
||||
content()
|
||||
|
||||
WalletPullToRefreshIndicator(
|
||||
isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
WalletPullToRefreshIndicator(
|
||||
isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BottomSheetScrim(
|
||||
color = BottomSheetDefaults.ScrimColor,
|
||||
visible = bottomSheetState.targetValue == SheetValue.Expanded,
|
||||
onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomSheetScrim(color: Color, visible: Boolean, onDismissRequest: () -> Unit) {
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = TweenSpec(),
|
||||
label = "scrim",
|
||||
)
|
||||
val dismissSheet = if (visible) {
|
||||
Modifier
|
||||
.pointerInput(onDismissRequest) {
|
||||
detectTapGestures {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics {}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(dismissSheet),
|
||||
) {
|
||||
drawRect(color = color, alpha = alpha)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BottomSheetStateEffects(
|
||||
bottomSheetState: SheetState,
|
||||
showManageTokensBottomSheet: Boolean,
|
||||
alertConfig: WalletAlertState?,
|
||||
keyboardShown: State<Keyboard>,
|
||||
) {
|
||||
// Bottom sheet during initialization internally expand partially after its content was remeasured,
|
||||
// therefore initialValue = SheetValue.Hidden in rememberStandardBottomSheetState doesn't work as expected
|
||||
// so we have to manually restrict expansion in this case
|
||||
LaunchedEffect(bottomSheetState.targetValue, bottomSheetState.currentValue) {
|
||||
if (!showManageTokensBottomSheet &&
|
||||
(bottomSheetState.targetValue != SheetValue.Hidden || bottomSheetState.currentValue != SheetValue.Hidden)
|
||||
) {
|
||||
bottomSheetState.hide()
|
||||
}
|
||||
}
|
||||
// react to changes in wallet list
|
||||
LaunchedEffect(showManageTokensBottomSheet) {
|
||||
when {
|
||||
showManageTokensBottomSheet && bottomSheetState.currentValue != SheetValue.PartiallyExpanded -> {
|
||||
bottomSheetState.partialExpand()
|
||||
}
|
||||
!showManageTokensBottomSheet && bottomSheetState.targetValue != SheetValue.Hidden -> {
|
||||
bottomSheetState.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val navigationBarColor = TangemTheme.colors.background.primary
|
||||
val navigationBarColorWithout = TangemTheme.colors.background.secondary
|
||||
|
||||
SystemBarsEffect {
|
||||
if (showManageTokensBottomSheet) {
|
||||
setNavigationBarColor(navigationBarColor)
|
||||
}
|
||||
}
|
||||
DisposableEffect(
|
||||
showManageTokensBottomSheet,
|
||||
) {
|
||||
onDispose {
|
||||
if (showManageTokensBottomSheet) {
|
||||
systemUiController.setNavigationBarColor(navigationBarColorWithout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expand bottom sheet when keyboard appears
|
||||
LaunchedEffect(keyboardShown.value is Keyboard.Opened) {
|
||||
if (keyboardShown.value is Keyboard.Opened && alertConfig == null) {
|
||||
bottomSheetState.expand()
|
||||
}
|
||||
}
|
||||
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
// hide keyboard when bottom sheet is about to be hidden
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow {
|
||||
bottomSheetState.currentValue == SheetValue.Expanded &&
|
||||
bottomSheetState.targetValue == SheetValue.PartiallyExpanded
|
||||
}.collect { sheetHasBeenHidden ->
|
||||
if (sheetHasBeenHidden) {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a standard method when this is fixed https://issuetracker.google.com/issues/314796718
|
||||
* Current material3 version: 1.2.0
|
||||
*/
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
private fun rememberSheetStateEnhanced(
|
||||
skipPartiallyExpanded: Boolean = false,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
initialValue: SheetValue = SheetValue.Hidden,
|
||||
skipHiddenState: Boolean = false,
|
||||
): SheetState {
|
||||
val density = LocalDensity.current
|
||||
return remember(initialValue, skipPartiallyExpanded, confirmValueChange, skipHiddenState) {
|
||||
SheetState(
|
||||
skipPartiallyExpanded = skipPartiallyExpanded,
|
||||
density = density,
|
||||
initialValue = initialValue,
|
||||
confirmValueChange = confirmValueChange,
|
||||
skipHiddenState = skipHiddenState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
private fun BaseScaffold(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.tangem.feature.wallet.presentation.wallet.ui.utils.LazyListItemData
|
||||
import com.tangem.feature.wallet.presentation.wallet.ui.utils.ScrollOffsetCollector
|
||||
import com.tangem.feature.wallet.presentation.wallet.ui.utils.WalletsListInteractionsCollector
|
||||
|
||||
|
|
@ -19,7 +20,11 @@ internal fun WalletsListEffects(
|
|||
onAutoScrollReset: () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(key1 = lazyListState, key2 = onWalletChange) {
|
||||
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo }
|
||||
snapshotFlow {
|
||||
lazyListState.layoutInfo.visibleItemsInfo.map {
|
||||
LazyListItemData(it.index, it.size, it.offset)
|
||||
}
|
||||
}
|
||||
.collect(
|
||||
collector = ScrollOffsetCollector(
|
||||
selectedWalletIndex = selectedWalletIndex,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,405 @@
|
|||
package com.tangem.feature.wallet.presentation.wallet.ui.components
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.gestures.ScrollScope
|
||||
import androidx.compose.ui.MotionDurationScale
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.sign
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
class TangemSnapFlingBehavior(
|
||||
private val snapLayoutInfoProvider: SnapLayoutInfoProvider,
|
||||
private val lowVelocityAnimationSpec: AnimationSpec<Float>,
|
||||
private val highVelocityAnimationSpec: DecayAnimationSpec<Float>,
|
||||
private val snapAnimationSpec: AnimationSpec<Float>,
|
||||
private val density: Density,
|
||||
private val shortSnapVelocityThreshold: Dp = MinFlingVelocityDp,
|
||||
) : FlingBehavior {
|
||||
|
||||
private val velocityThreshold = with(density) { shortSnapVelocityThreshold.toPx() }
|
||||
private var motionScaleDuration = DefaultScrollMotionDurationScale
|
||||
|
||||
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
|
||||
return performFling(initialVelocity) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a snapping fling animation with given velocity and suspend until fling has
|
||||
* finished. This will behave the same way as [performFling] except it will report on
|
||||
* each remainingOffsetUpdate using the [onSettlingDistanceUpdated] lambda.
|
||||
*
|
||||
* @param initialVelocity velocity available for fling in the orientation specified in
|
||||
* [androidx.compose.foundation.gestures.scrollable] that invoked this method.
|
||||
*
|
||||
* @param onSettlingDistanceUpdated a lambda that will be called anytime the
|
||||
* distance to the settling offset is updated. The settling offset is the final offset where
|
||||
* this fling will stop and may change depending on the snapping animation progression.
|
||||
*
|
||||
* @return remaining velocity after fling operation has ended
|
||||
*/
|
||||
private suspend fun ScrollScope.performFling(
|
||||
initialVelocity: Float,
|
||||
onSettlingDistanceUpdated: (Float) -> Unit,
|
||||
): Float {
|
||||
val (remainingOffset, remainingState) = fling(initialVelocity, onSettlingDistanceUpdated)
|
||||
|
||||
// No remaining offset means we've used everything, no need to propagate velocity. Otherwise
|
||||
// we couldn't use everything (probably because we have hit the min/max bounds of the
|
||||
// containing layout) we should propagate the offset.
|
||||
return if (remainingOffset == 0f) NoVelocity else remainingState.velocity
|
||||
}
|
||||
|
||||
private suspend fun ScrollScope.fling(
|
||||
initialVelocity: Float,
|
||||
onRemainingScrollOffsetUpdate: (Float) -> Unit,
|
||||
): AnimationResult<Float, AnimationVector1D> {
|
||||
// If snapping from scroll (short snap) or fling (long snap)
|
||||
val result = withContext(motionScaleDuration) {
|
||||
if (abs(initialVelocity) <= abs(velocityThreshold)) {
|
||||
shortSnap(initialVelocity, onRemainingScrollOffsetUpdate)
|
||||
} else {
|
||||
longSnap(initialVelocity, onRemainingScrollOffsetUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
onRemainingScrollOffsetUpdate(0f) // Animation finished or was cancelled
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun ScrollScope.shortSnap(
|
||||
velocity: Float,
|
||||
onRemainingScrollOffsetUpdate: (Float) -> Unit,
|
||||
): AnimationResult<Float, AnimationVector1D> {
|
||||
val closestOffset = with(snapLayoutInfoProvider) {
|
||||
density.calculateSnappingOffset(0f)
|
||||
}
|
||||
|
||||
var remainingScrollOffset = closestOffset
|
||||
|
||||
val animationState = AnimationState(NoDistance, velocity)
|
||||
return animateSnap(
|
||||
closestOffset,
|
||||
closestOffset,
|
||||
animationState,
|
||||
snapAnimationSpec,
|
||||
) { delta ->
|
||||
remainingScrollOffset -= delta
|
||||
onRemainingScrollOffsetUpdate(remainingScrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ScrollScope.longSnap(
|
||||
initialVelocity: Float,
|
||||
onAnimationStep: (remainingScrollOffset: Float) -> Unit,
|
||||
): AnimationResult<Float, AnimationVector1D> {
|
||||
val initialOffset =
|
||||
with(snapLayoutInfoProvider) { density.calculateApproachOffset(initialVelocity) }.let {
|
||||
abs(it) * sign(initialVelocity) // ensure offset sign is correct
|
||||
}
|
||||
var remainingScrollOffset = initialOffset
|
||||
|
||||
onAnimationStep(remainingScrollOffset) // First Scroll Offset
|
||||
|
||||
val (remainingOffset, animationState) = runApproach(
|
||||
initialOffset,
|
||||
initialVelocity,
|
||||
) { delta ->
|
||||
remainingScrollOffset -= delta
|
||||
onAnimationStep(remainingScrollOffset)
|
||||
}
|
||||
|
||||
remainingScrollOffset = remainingOffset
|
||||
|
||||
return animateSnap(
|
||||
remainingOffset,
|
||||
remainingOffset,
|
||||
animationState.copy(value = 0f),
|
||||
snapAnimationSpec,
|
||||
) { delta ->
|
||||
remainingScrollOffset -= delta
|
||||
onAnimationStep(remainingScrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ScrollScope.runApproach(
|
||||
initialTargetOffset: Float,
|
||||
initialVelocity: Float,
|
||||
onAnimationStep: (delta: Float) -> Unit,
|
||||
): AnimationResult<Float, AnimationVector1D> {
|
||||
val animation =
|
||||
if (isDecayApproachPossible(offset = initialTargetOffset, velocity = initialVelocity)) {
|
||||
HighVelocityApproachAnimation(highVelocityAnimationSpec)
|
||||
} else {
|
||||
LowVelocityApproachAnimation(
|
||||
lowVelocityAnimationSpec,
|
||||
snapLayoutInfoProvider,
|
||||
density,
|
||||
)
|
||||
}
|
||||
|
||||
return approach(
|
||||
initialTargetOffset,
|
||||
initialVelocity,
|
||||
animation,
|
||||
snapLayoutInfoProvider,
|
||||
density,
|
||||
onAnimationStep,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* If we can approach the target and still have velocity left
|
||||
*/
|
||||
private fun isDecayApproachPossible(offset: Float, velocity: Float): Boolean {
|
||||
val decayOffset = highVelocityAnimationSpec.calculateTargetValue(NoDistance, velocity)
|
||||
val snapStepSize = with(snapLayoutInfoProvider) { density.calculateSnapStepSize() }
|
||||
return decayOffset.absoluteValue >= offset.absoluteValue + snapStepSize
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return if (other is TangemSnapFlingBehavior) {
|
||||
other.snapAnimationSpec == this.snapAnimationSpec &&
|
||||
other.highVelocityAnimationSpec == this.highVelocityAnimationSpec &&
|
||||
other.lowVelocityAnimationSpec == this.lowVelocityAnimationSpec &&
|
||||
other.snapLayoutInfoProvider == this.snapLayoutInfoProvider &&
|
||||
other.density == this.density &&
|
||||
other.shortSnapVelocityThreshold == this.shortSnapVelocityThreshold
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = 0
|
||||
.let { 31 * it + snapAnimationSpec.hashCode() }
|
||||
.let { 31 * it + highVelocityAnimationSpec.hashCode() }
|
||||
.let { 31 * it + lowVelocityAnimationSpec.hashCode() }
|
||||
.let { 31 * it + snapLayoutInfoProvider.hashCode() }
|
||||
.let { 31 * it + density.hashCode() }
|
||||
.let { 31 * it + shortSnapVelocityThreshold.hashCode() }
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private suspend fun ScrollScope.approach(
|
||||
initialTargetOffset: Float,
|
||||
initialVelocity: Float,
|
||||
animation: ApproachAnimation<Float, AnimationVector1D>,
|
||||
snapLayoutInfoProvider: SnapLayoutInfoProvider,
|
||||
density: Density,
|
||||
onAnimationStep: (delta: Float) -> Unit,
|
||||
): AnimationResult<Float, AnimationVector1D> {
|
||||
val (_, currentAnimationState) = animation.approachAnimation(
|
||||
this,
|
||||
initialTargetOffset,
|
||||
initialVelocity,
|
||||
onAnimationStep,
|
||||
)
|
||||
|
||||
val remainingOffset = with(snapLayoutInfoProvider) {
|
||||
density.calculateSnappingOffset(currentAnimationState.velocity)
|
||||
}
|
||||
|
||||
// will snap the remainder
|
||||
return AnimationResult(remainingOffset, currentAnimationState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a [AnimationSpec] to snap the list into [targetOffset]. Uses [cancelOffset] to stop this
|
||||
* animation before it reaches the target.
|
||||
*
|
||||
* @param targetOffset The final target of this animation
|
||||
* @param cancelOffset If we'd like to finish the animation earlier we use this value
|
||||
* @param animationState The current animation state for continuation purposes
|
||||
* @param snapAnimationSpec The [AnimationSpec] that will drive this animation
|
||||
* @param onAnimationStep Called for each new scroll delta emitted by the animation cycle.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
private suspend fun ScrollScope.animateSnap(
|
||||
targetOffset: Float,
|
||||
cancelOffset: Float,
|
||||
animationState: AnimationState<Float, AnimationVector1D>,
|
||||
snapAnimationSpec: AnimationSpec<Float>,
|
||||
onAnimationStep: (delta: Float) -> Unit,
|
||||
): AnimationResult<Float, AnimationVector1D> {
|
||||
var consumedUpToNow = 0f
|
||||
val initialVelocity = animationState.velocity
|
||||
animationState.animateTo(
|
||||
targetOffset,
|
||||
animationSpec = snapAnimationSpec,
|
||||
sequentialAnimation = animationState.velocity != 0f,
|
||||
) {
|
||||
val realValue = value.coerceToTarget(cancelOffset)
|
||||
val delta = realValue - consumedUpToNow
|
||||
val consumed = scrollBy(delta)
|
||||
onAnimationStep(consumed)
|
||||
// stop when unconsumed or when we reach the desired value
|
||||
if (abs(delta - consumed) > 0.5f || realValue != value) {
|
||||
cancelAnimation()
|
||||
}
|
||||
consumedUpToNow += consumed
|
||||
}
|
||||
|
||||
// Always course correct velocity so they don't become too large.
|
||||
val finalVelocity = animationState.velocity.coerceToTarget(initialVelocity)
|
||||
return AnimationResult(
|
||||
targetOffset - consumedUpToNow,
|
||||
animationState.copy(velocity = finalVelocity),
|
||||
)
|
||||
}
|
||||
|
||||
private class HighVelocityApproachAnimation(
|
||||
private val decayAnimationSpec: DecayAnimationSpec<Float>,
|
||||
) : ApproachAnimation<Float, AnimationVector1D> {
|
||||
override suspend fun approachAnimation(
|
||||
scope: ScrollScope,
|
||||
offset: Float,
|
||||
velocity: Float,
|
||||
onAnimationStep: (delta: Float) -> Unit,
|
||||
): AnimationResult<Float, AnimationVector1D> {
|
||||
val animationState = AnimationState(initialValue = 0f, initialVelocity = velocity)
|
||||
return with(scope) {
|
||||
animateDecay(offset, animationState, decayAnimationSpec, onAnimationStep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LowVelocityApproachAnimation @OptIn(ExperimentalFoundationApi::class) constructor(
|
||||
private val lowVelocityAnimationSpec: AnimationSpec<Float>,
|
||||
private val layoutInfoProvider: SnapLayoutInfoProvider,
|
||||
private val density: Density,
|
||||
) : ApproachAnimation<Float, AnimationVector1D> {
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
override suspend fun approachAnimation(
|
||||
scope: ScrollScope,
|
||||
offset: Float,
|
||||
velocity: Float,
|
||||
onAnimationStep: (delta: Float) -> Unit,
|
||||
): AnimationResult<Float, AnimationVector1D> {
|
||||
val animationState = AnimationState(initialValue = 0f, initialVelocity = velocity)
|
||||
val targetOffset =
|
||||
(abs(offset) + with(layoutInfoProvider) { density.calculateSnapStepSize() }) * sign(
|
||||
velocity,
|
||||
)
|
||||
return with(scope) {
|
||||
animateSnap(
|
||||
targetOffset = targetOffset,
|
||||
cancelOffset = offset,
|
||||
animationState = animationState,
|
||||
snapAnimationSpec = lowVelocityAnimationSpec,
|
||||
onAnimationStep = onAnimationStep,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private suspend fun ScrollScope.animateDecay(
|
||||
targetOffset: Float,
|
||||
animationState: AnimationState<Float, AnimationVector1D>,
|
||||
decayAnimationSpec: DecayAnimationSpec<Float>,
|
||||
onAnimationStep: (delta: Float) -> Unit,
|
||||
): AnimationResult<Float, AnimationVector1D> {
|
||||
var previousValue = 0f
|
||||
|
||||
fun AnimationScope<Float, AnimationVector1D>.consumeDelta(delta: Float) {
|
||||
val consumed = scrollBy(delta)
|
||||
onAnimationStep(consumed)
|
||||
if (abs(delta - consumed) > 0.5f) cancelAnimation()
|
||||
}
|
||||
|
||||
animationState.animateDecay(
|
||||
animationSpec = decayAnimationSpec,
|
||||
sequentialAnimation = animationState.velocity != 0f,
|
||||
) {
|
||||
previousValue = if (abs(value) >= abs(targetOffset)) {
|
||||
val finalValue = value.coerceToTarget(targetOffset)
|
||||
val finalDelta = finalValue - previousValue
|
||||
consumeDelta(finalDelta)
|
||||
cancelAnimation()
|
||||
finalValue
|
||||
} else {
|
||||
val delta = value - previousValue
|
||||
consumeDelta(delta)
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
return AnimationResult(
|
||||
targetOffset - previousValue,
|
||||
animationState,
|
||||
)
|
||||
}
|
||||
|
||||
private interface ApproachAnimation<T, V : AnimationVector> {
|
||||
suspend fun approachAnimation(
|
||||
scope: ScrollScope,
|
||||
offset: T,
|
||||
velocity: T,
|
||||
onAnimationStep: (delta: T) -> Unit,
|
||||
): AnimationResult<T, V>
|
||||
}
|
||||
|
||||
private fun Float.coerceToTarget(target: Float): Float {
|
||||
if (target == 0f) return 0f
|
||||
return if (target > 0) coerceAtMost(target) else coerceAtLeast(target)
|
||||
}
|
||||
|
||||
private class AnimationResult<T, V : AnimationVector>(
|
||||
val remainingOffset: T,
|
||||
val currentAnimationState: AnimationState<T, V>,
|
||||
) {
|
||||
operator fun component1(): T = remainingOffset
|
||||
operator fun component2(): AnimationState<T, V> = currentAnimationState
|
||||
}
|
||||
|
||||
@Suppress("TopLevelPropertyNaming")
|
||||
private const val DefaultScrollMotionDurationScaleFactor = 1f
|
||||
|
||||
@Suppress("TopLevelPropertyNaming")
|
||||
val DefaultScrollMotionDurationScale = object : MotionDurationScale {
|
||||
override val scaleFactor: Float
|
||||
get() = DefaultScrollMotionDurationScaleFactor
|
||||
}
|
||||
|
||||
@Suppress("TopLevelPropertyNaming")
|
||||
internal val MinFlingVelocityDp = 400.dp
|
||||
|
||||
@Suppress("TopLevelPropertyNaming")
|
||||
internal const val NoDistance = 0f
|
||||
|
||||
@Suppress("TopLevelPropertyNaming")
|
||||
internal const val NoVelocity = 0f
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
interface SnapLayoutInfoProvider {
|
||||
/**
|
||||
* The minimum offset that snapping will use to animate.(e.g. an item size)
|
||||
*/
|
||||
fun Density.calculateSnapStepSize(): Float
|
||||
|
||||
/**
|
||||
* Calculate the distance to navigate before settling into the next snapping bound.
|
||||
*
|
||||
* @param initialVelocity The current fling movement velocity. You can use this tho calculate a
|
||||
* velocity based offset.
|
||||
*/
|
||||
fun Density.calculateApproachOffset(initialVelocity: Float): Float
|
||||
|
||||
/**
|
||||
* Given a target placement in a layout, the snapping offset is the next snapping position
|
||||
* this layout can be placed in. If this is a short snapping, [currentVelocity] is guaranteed
|
||||
* to be 0.If it is a long snapping, this method will be called
|
||||
* after [calculateApproachOffset].
|
||||
*
|
||||
* @param currentVelocity The current fling movement velocity. This may change throughout the
|
||||
* fling animation.
|
||||
*/
|
||||
fun Density.calculateSnappingOffset(currentVelocity: Float): Float
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package com.tangem.feature.wallet.presentation.wallet.ui.components
|
||||
|
||||
import androidx.compose.animation.core.DecayAnimationSpec
|
||||
import androidx.compose.animation.core.calculateTargetValue
|
||||
import androidx.compose.animation.splineBasedDecay
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior
|
||||
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.ui.unit.Density
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.sign
|
||||
|
||||
/**
|
||||
* A [SnapLayoutInfoProvider] for LazyLists.
|
||||
*
|
||||
* @param lazyListState The [LazyListState] with information about the current state of the list
|
||||
* @param positionInLayout The desired positioning of the snapped item within the main layout.
|
||||
* This position should be considered with regard to the start edge of the item and the placement
|
||||
* within the viewport.
|
||||
*
|
||||
* @return A [SnapLayoutInfoProvider] that can be used with [SnapFlingBehavior]
|
||||
*/
|
||||
@Suppress("FunctionNaming")
|
||||
@ExperimentalFoundationApi
|
||||
fun TangemSnapLayoutInfoProvider(
|
||||
lazyListState: LazyListState,
|
||||
positionInLayout: SnapPositionInLayout = SnapPositionInLayout.CenterToCenter,
|
||||
): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider {
|
||||
|
||||
private val layoutInfo: LazyListLayoutInfo
|
||||
get() = lazyListState.layoutInfo
|
||||
|
||||
// Decayed page snapping is the default
|
||||
override fun Density.calculateApproachOffset(initialVelocity: Float): Float {
|
||||
val decayAnimationSpec: DecayAnimationSpec<Float> = splineBasedDecay(this)
|
||||
val offset =
|
||||
decayAnimationSpec.calculateTargetValue(NoDistance, initialVelocity).absoluteValue
|
||||
val finalDecayOffset = (offset - calculateSnapStepSize()).coerceAtLeast(0f)
|
||||
return if (finalDecayOffset == 0f) {
|
||||
finalDecayOffset
|
||||
} else {
|
||||
finalDecayOffset * initialVelocity.sign
|
||||
}
|
||||
}
|
||||
|
||||
override fun Density.calculateSnappingOffset(currentVelocity: Float): Float {
|
||||
var lowerBoundOffset = Float.NEGATIVE_INFINITY
|
||||
var upperBoundOffset = Float.POSITIVE_INFINITY
|
||||
|
||||
layoutInfo.visibleItemsInfo.fastForEach { item ->
|
||||
val offset =
|
||||
calculateDistanceToDesiredSnapPosition(
|
||||
mainAxisViewPortSize = layoutInfo.singleAxisViewportSize,
|
||||
beforeContentPadding = layoutInfo.beforeContentPadding,
|
||||
afterContentPadding = layoutInfo.afterContentPadding,
|
||||
itemSize = item.size,
|
||||
itemOffset = item.offset,
|
||||
itemIndex = item.index,
|
||||
snapPositionInLayout = positionInLayout,
|
||||
)
|
||||
|
||||
// Find item that is closest to the center
|
||||
if (offset <= 0 && offset > lowerBoundOffset) {
|
||||
lowerBoundOffset = offset
|
||||
}
|
||||
|
||||
// Find item that is closest to center, but after it
|
||||
if (offset >= 0 && offset < upperBoundOffset) {
|
||||
upperBoundOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
return calculateFinalOffset(currentVelocity, lowerBoundOffset, upperBoundOffset)
|
||||
}
|
||||
|
||||
override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) {
|
||||
if (visibleItemsInfo.isNotEmpty()) {
|
||||
visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BanInlineOptIn")
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
inline fun <T> List<T>.fastSumBy(selector: (T) -> Int): Int {
|
||||
contract { callsInPlace(selector) }
|
||||
var sum = 0
|
||||
fastForEach { element ->
|
||||
sum += selector(element)
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
internal fun calculateFinalOffset(velocity: Float, lowerBound: Float, upperBound: Float): Float {
|
||||
fun Float.isValidDistance(): Boolean {
|
||||
return this != Float.POSITIVE_INFINITY && this != Float.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
val finalDistance = when (sign(velocity)) {
|
||||
0f -> {
|
||||
if (abs(upperBound) <= abs(lowerBound)) {
|
||||
upperBound
|
||||
} else {
|
||||
lowerBound
|
||||
}
|
||||
}
|
||||
|
||||
1f -> upperBound
|
||||
-1f -> lowerBound
|
||||
else -> NoDistance
|
||||
}
|
||||
|
||||
return if (finalDistance.isValidDistance()) {
|
||||
finalDistance
|
||||
} else {
|
||||
NoDistance
|
||||
}
|
||||
}
|
||||
|
||||
internal val LazyListLayoutInfo.singleAxisViewportSize: Int
|
||||
get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width
|
||||
|
||||
@Suppress("BanInlineOptIn")
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
|
||||
contract { callsInPlace(action) }
|
||||
for (index in indices) {
|
||||
val item = get(index)
|
||||
action(item)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun Density.calculateDistanceToDesiredSnapPosition(
|
||||
mainAxisViewPortSize: Int,
|
||||
beforeContentPadding: Int,
|
||||
afterContentPadding: Int,
|
||||
itemSize: Int,
|
||||
itemOffset: Int,
|
||||
itemIndex: Int,
|
||||
snapPositionInLayout: SnapPositionInLayout,
|
||||
): Float {
|
||||
val containerSize = mainAxisViewPortSize - beforeContentPadding - afterContentPadding
|
||||
|
||||
val desiredDistance = with(snapPositionInLayout) {
|
||||
position(containerSize, itemSize, itemIndex)
|
||||
}.toFloat()
|
||||
|
||||
return itemOffset - desiredDistance
|
||||
}
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
fun interface SnapPositionInLayout {
|
||||
/**
|
||||
* Calculates an offset positioning between a container and an element within this container.
|
||||
* The offset calculation is the necessary diff that should be applied to the item offset to
|
||||
* align the item with a position within the container. As a base line, if we wanted to align
|
||||
* the start of the container and the start of the item, we would return 0 in this function.
|
||||
*/
|
||||
fun Density.position(layoutSize: Int, itemSize: Int, itemIndex: Int): Int
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Aligns the center of the item with the center of the containing layout.
|
||||
*/
|
||||
val CenterToCenter =
|
||||
SnapPositionInLayout { layoutSize, itemSize, _ -> layoutSize / 2 - itemSize / 2 }
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,6 @@ import androidx.compose.animation.core.*
|
|||
import androidx.compose.animation.rememberSplineBasedDecay
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior
|
||||
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
|
||||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
|
|
@ -31,7 +29,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.common.Wallet
|
|||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
private const val SHORT_SNAP_ELEMENT_COUNT = 50
|
||||
private const val SHORT_SNAP_ELEMENT_COUNT = 25
|
||||
|
||||
/**
|
||||
* Wallets list component
|
||||
|
|
@ -76,23 +74,20 @@ internal fun WalletsList(
|
|||
|
||||
/**
|
||||
* Custom implementation of fling behaviour that overrides 'shortSnapVelocityThreshold'.
|
||||
* Every user's drag action will similar to a short snap
|
||||
* if drag offset is less than [SHORT_SNAP_ELEMENT_COUNT] * item width.
|
||||
*
|
||||
* @param lazyListState lazy list state
|
||||
* @param itemWidth list item width
|
||||
*
|
||||
* @see rememberSnapFlingBehavior
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun rememberWalletsFlingBehaviour(lazyListState: LazyListState, itemWidth: Dp): SnapFlingBehavior {
|
||||
val snappingLayout = remember(lazyListState) { SnapLayoutInfoProvider(lazyListState) }
|
||||
private fun rememberWalletsFlingBehaviour(lazyListState: LazyListState, itemWidth: Dp): TangemSnapFlingBehavior {
|
||||
val snappingLayout = remember(lazyListState) { TangemSnapLayoutInfoProvider(lazyListState) }
|
||||
val density = LocalDensity.current
|
||||
val highVelocityApproachSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay()
|
||||
|
||||
return remember(key1 = snappingLayout, key2 = highVelocityApproachSpec, key3 = density) {
|
||||
SnapFlingBehavior(
|
||||
TangemSnapFlingBehavior(
|
||||
snapLayoutInfoProvider = snappingLayout,
|
||||
lowVelocityAnimationSpec = tween(durationMillis = 1000, easing = LinearEasing),
|
||||
highVelocityAnimationSpec = highVelocityApproachSpec,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.feature.wallet.presentation.wallet.ui.utils
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlin.math.abs
|
||||
|
|
@ -20,14 +19,14 @@ internal class ScrollOffsetCollector(
|
|||
selectedWalletIndex: Int,
|
||||
private val lazyListState: LazyListState,
|
||||
private val onWalletChange: (Int) -> Unit,
|
||||
) : FlowCollector<List<LazyListItemInfo>> {
|
||||
) : FlowCollector<List<LazyListItemData>> {
|
||||
|
||||
private val LazyListItemInfo.halfItemSize
|
||||
private val LazyListItemData.halfItemSize
|
||||
get() = size.div(other = 2)
|
||||
|
||||
private var currentIndex = selectedWalletIndex
|
||||
|
||||
override suspend fun emit(value: List<LazyListItemInfo>) {
|
||||
override suspend fun emit(value: List<LazyListItemData>) {
|
||||
if (!lazyListState.isScrollInProgress || value.size <= 1) return
|
||||
|
||||
val firstItem = value.firstOrNull() ?: return
|
||||
|
|
@ -46,4 +45,10 @@ internal class ScrollOffsetCollector(
|
|||
onWalletChange(newIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class LazyListItemData(
|
||||
val index: Int,
|
||||
val size: Int,
|
||||
val offset: Int,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue