Updated on 2026-08-14

This commit is contained in:
Tangem 2023-01-17 16:23:33 +03:00
parent 78818a6efa
commit bd3ee9e237
33 changed files with 643 additions and 207 deletions

View file

@ -0,0 +1,35 @@
package com.tangem.feature.swap.converters
import com.tangem.feature.swap.domain.models.ui.FoundTokensState
import com.tangem.feature.swap.domain.models.ui.TokenWithBalance
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenBalanceData
import com.tangem.feature.swap.models.TokenToSelect
import com.tangem.utils.converter.Converter
class TokensDataConverter(
private val onSearchEntered: (String) -> Unit,
private val onTokenSelected: (String) -> Unit,
) : Converter<FoundTokensState, SwapSelectTokenStateHolder> {
override fun convert(value: FoundTokensState): SwapSelectTokenStateHolder {
return SwapSelectTokenStateHolder(
tokens = (value.tokensInWallet + value.loadedTokens).map { tokenWithBalanceToTokenToSelect(it) },
onSearchEntered = onSearchEntered,
onTokenSelected = onTokenSelected,
)
}
private fun tokenWithBalanceToTokenToSelect(tokenWithBalance: TokenWithBalance): TokenToSelect {
return TokenToSelect(
id = tokenWithBalance.token.id,
name = tokenWithBalance.token.name,
symbol = tokenWithBalance.token.symbol,
iconUrl = tokenWithBalance.token.logoUrl,
addedTokenBalanceData = TokenBalanceData(
amount = tokenWithBalance.tokenBalanceData?.amount,
amountEquivalent = tokenWithBalance.tokenBalanceData?.amountEquivalent,
),
)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.feature.swap.models
data class UiActions(
val onSearchEntered: (String) -> Unit,
val onTokenSelected: (String) -> Unit,
val onAmountChanged: (String) -> Unit,
val onSwapClick: () -> Unit,
val onGivePermissionClick: () -> Unit,
val onChangeCardsClicked: () -> Unit,
val onBackClicked: () -> Unit,
)

View file

@ -1,9 +1,11 @@
package com.tangem.feature.swap.ui
import com.tangem.feature.swap.domain.models.Currency
import com.tangem.feature.swap.domain.models.PermissionDataState
import com.tangem.feature.swap.domain.models.SwapState
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
import com.tangem.feature.swap.domain.models.ui.FoundTokensState
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.models.ApprovePermissionButton
import com.tangem.feature.swap.models.CancelPermissionButton
import com.tangem.feature.swap.models.FeeState
@ -13,16 +15,19 @@ import com.tangem.feature.swap.models.SwapPermissionState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.SwapWarning
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.models.UiActions
/**
* State builder creates a specific states for SwapScreen
*/
class StateBuilder {
class StateBuilder(val actions: UiActions) {
fun createInitialLoadingState(networkCurrency: String, onAmountChanged: (String) -> Unit): SwapStateHolder {
private val tokensDataConverter = TokensDataConverter(actions.onSearchEntered, actions.onTokenSelected)
fun createInitialLoadingState(networkCurrency: String): SwapStateHolder {
return SwapStateHolder(
sendCardData = SwapCardData(
type = TransactionCardType.SendCard(onAmountChanged),
type = TransactionCardType.SendCard(actions.onAmountChanged),
amount = null,
amountEquivalent = null,
tokenIconUrl = "",
@ -43,8 +48,8 @@ class StateBuilder {
networkCurrency = networkCurrency,
swapButton = SwapButton(enabled = false, loading = true, onClick = {}),
onRefresh = {},
onBackClicked = {},
onChangeCardsClicked = {},
onBackClicked = actions.onBackClicked,
onChangeCardsClicked = actions.onChangeCardsClicked,
)
}
@ -66,8 +71,8 @@ class StateBuilder {
),
receiveCardData = SwapCardData(
type = TransactionCardType.ReceiveCard(),
amount = uiStateHolder.receiveCardData.amount,
amountEquivalent = uiStateHolder.receiveCardData.amountEquivalent,
amount = null,
amountEquivalent = null,
tokenIconUrl = toToken.logoUrl,
tokenCurrency = toToken.symbol,
canSelectAnotherToken = mainTokenId != toToken.id,
@ -75,6 +80,7 @@ class StateBuilder {
),
fee = FeeState.Loading,
swapButton = SwapButton(enabled = false, loading = true, onClick = {}),
permissionState = uiStateHolder.permissionState,
)
}
@ -82,8 +88,6 @@ class StateBuilder {
uiStateHolder: SwapStateHolder,
quoteModel: SwapState.QuotesLoadedState,
fromToken: Currency,
onSwapClick: () -> Unit,
onGivePermissionClick: () -> Unit,
): SwapStateHolder {
return uiStateHolder.copy(
sendCardData = SwapCardData(
@ -109,16 +113,31 @@ class StateBuilder {
} else {
emptyList()
},
permissionState = convertPermissionState(quoteModel.permissionState, onGivePermissionClick),
permissionState = convertPermissionState(quoteModel.permissionState, actions.onGivePermissionClick),
fee = FeeState.Loaded(quoteModel.fee),
swapButton = SwapButton(
enabled = quoteModel.isAllowedToSpend,
loading = false,
onClick = onSwapClick,
onClick = actions.onSwapClick,
),
)
}
fun createSwapInProgressState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
swapButton = uiState.swapButton.copy(
loading = true,
enabled = false,
),
)
}
fun addTokensToState(uiState: SwapStateHolder, dataState: FoundTokensState): SwapStateHolder {
return uiState.copy(
selectTokenState = tokensDataConverter.convert(dataState),
)
}
private fun convertPermissionState(
permissionDataState: PermissionDataState,
onGivePermissionClick: () -> Unit,
@ -144,4 +163,13 @@ class StateBuilder {
)
}
}
fun updateSwapAmount(uiState: SwapStateHolder, amount: String, amountEquivalent: String): SwapStateHolder {
return uiState.copy(
sendCardData = uiState.sendCardData.copy(
amount = amount,
amountEquivalent = amountEquivalent,
),
)
}
}

View file

@ -208,7 +208,7 @@ private fun Content(
}
is TransactionCardType.SendCard -> {
AutoSizeTextField(
amount = amount ?: "0",
amount = amount ?: "1",
onAmoutChanged = { type.onAmountChanged(it) },
)
}

View file

@ -0,0 +1,43 @@
package com.tangem.feature.swap.viewmodels
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
class PeriodicTask(
private val delay: Long,
private val task: suspend () -> Unit,
) {
private var isActive: AtomicBoolean = AtomicBoolean(false)
suspend fun runTaskWithDelay() {
isActive.set(true)
while (isActive.get()) {
task.invoke()
delay(delay)
}
}
fun cancel() {
isActive.set(false)
}
}
class SingleTaskScheduler {
private var lastTask: PeriodicTask? = null
fun scheduleTask(scope: CoroutineScope, task: PeriodicTask) {
lastTask?.cancel()
lastTask = task
scope.launch {
task.runTaskWithDelay()
}
}
fun cancelTask() {
lastTask?.cancel()
}
}

View file

@ -8,18 +8,21 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.models.Currency
import com.tangem.feature.swap.domain.models.SwapState
import com.tangem.feature.swap.domain.models.createFromAmountWithoutOffset
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.ui.FoundTokensState
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.presentation.SwapFragment
import com.tangem.feature.swap.router.SwapRouter
import com.tangem.feature.swap.router.SwapScreen
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import com.tangem.utils.coroutines.runCatching
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import javax.inject.Inject
@ -32,23 +35,31 @@ internal class SwapViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val stateBuilder = StateBuilder()
private val stateBuilder = StateBuilder(
actions = createUiActions(),
)
private val amountDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler()
private val currency = Json.decodeFromString<Currency>(
savedStateHandle[SwapFragment.CURRENCY_BUNDLE_KEY]
?: error("no expected parameter Currency found"),
)
var uiState: SwapStateHolder by mutableStateOf(
stateBuilder.createInitialLoadingState(currency.symbol)
{ /** [REDACTED_TODO_COMMENT]*/ },
)
var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState(currency.symbol))
private set
//shows currency order (direct - swap initial to selected, reversed = selected to initial)
private var isOrderReversed = false
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
private var swapRouter: SwapRouter by Delegates.notNull()
var currentScreen = SwapScreen.Main
get() = swapRouter.currentScreen
init {
initTokens(currency)
}
fun setRouter(router: SwapRouter) {
swapRouter = router
uiState = uiState.copy(
@ -58,21 +69,18 @@ internal class SwapViewModel @Inject constructor(
)
}
init {
private fun initTokens(currency: Currency) {
viewModelScope.launch(dispatchers.main) {
runCatching {
withContext(dispatchers.io) {
swapInteractor.getTokensToSwap(currency.networkId)
}
runCatching(dispatchers.io) {
swapInteractor.initTokensToSwap(currency)
}
.onSuccess { tokens ->
if (tokens.size > MIN_TOKENS_IN_LIST) {
tokens.firstOrNull { token ->
token.id == currency.id
}?.let {
loadQuotes(it, tokens[1], INITIAL_AMOUNT)
}
}
.onSuccess { state ->
updateTokensState(dataState = state.foundTokensState)
startLoadingQuotes(
fromToken = state.preselectTokens.fromToken,
toToken = state.preselectTokens.toToken,
amount = lastAmount.value,
)
}
.onFailure {
Log.e("SwapViewModel", it.message ?: it.cause.toString())
@ -80,20 +88,38 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun loadQuotes(fromToken: Currency, toToken: Currency, amount: String) {
private fun updateTokensState(dataState: FoundTokensState) {
uiState = stateBuilder.addTokensToState(uiState, dataState)
}
private fun startLoadingQuotes(fromToken: Currency, toToken: Currency, amount: String) {
singleTaskScheduler.cancelTask()
uiState = stateBuilder.createQuotesLoadingState(uiState, fromToken, toToken, currency.id)
singleTaskScheduler.scheduleTask(
viewModelScope,
PeriodicTask(
delay = UPDATE_DELAY,
) {
loadQuotesInternal(
fromToken = fromToken,
toToken = toToken,
amount = amount,
)
},
)
}
private fun loadQuotesInternal(fromToken: Currency, toToken: Currency, amount: String) {
viewModelScope.launch(dispatchers.main) {
runCatching {
withContext(dispatchers.io) {
swapInteractor.findBestQuote(
fromToken = fromToken,
toToken = toToken,
amount = createFromAmountWithoutOffset(
amountWithoutOffset = amount,
decimals = swapInteractor.getTokenDecimals(fromToken),
),
)
}
runCatching(dispatchers.io) {
swapInteractor.findBestQuote(
fromToken = fromToken,
toToken = toToken,
amount = createFromAmountWithoutOffset(
amountWithoutOffset = amount,
decimals = swapInteractor.getTokenDecimals(fromToken),
),
)
}
.onSuccess { swapState ->
when (swapState) {
@ -102,13 +128,8 @@ internal class SwapViewModel @Inject constructor(
uiStateHolder = uiState,
quoteModel = swapState,
fromToken = fromToken,
onSwapClick = { onSwapClick(toToken, swapState) },
onGivePermissionClick = { givePermissionsToSwap(fromToken) },
)
}
is SwapState.SwapSuccess -> {
//todo implement
}
is SwapState.SwapError -> {
}
}
@ -117,46 +138,106 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun onSwapClick(toToken: Currency, quoteModel: SwapState.QuotesLoadedState) {
private fun createUiActions(): UiActions {
return UiActions(
onSearchEntered = { onSearchEntered(it) },
onTokenSelected = { onTokenSelect(it) },
onAmountChanged = { onAmountChanged(it) },
onSwapClick = { onSwapClick() },
onGivePermissionClick = { givePermissionsToSwap() },
onChangeCardsClicked = { onChangeCardsClicked() },
onBackClicked = { onSearchEntered("") },
)
}
private fun onSwapClick() {
singleTaskScheduler.cancelTask()
uiState = stateBuilder.createSwapInProgressState(uiState)
viewModelScope.launch(dispatchers.main) {
runCatching {
withContext(dispatchers.io) {
if (!quoteModel.isAllowedToSpend) {
swapInteractor.givePermissionToSwap(toToken)
} else {
swapInteractor.onSwap()
}
}
runCatching(dispatchers.io) {
swapInteractor.onSwap()
}
.onSuccess {
if (it is SwapState) {
when (it) {
is SwapState.SwapSuccess -> {
}
is SwapState.SwapError -> {
}
else -> {}
when (it) {
is SwapState.SwapError -> {
}
else -> {}
}
}
.onFailure { }
}
}
private fun givePermissionsToSwap(tokenToApprove: Currency) {
private fun givePermissionsToSwap() {
viewModelScope.launch(dispatchers.main) {
runCatching {
withContext(dispatchers.io) {
swapInteractor.givePermissionToSwap(tokenToApprove)
}
runCatching(dispatchers.io) {
swapInteractor.givePermissionToSwap()
}
.onSuccess { }
.onFailure { }
}
}
private fun onSearchEntered(searchQuery: String) {
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
swapInteractor.onSearchToken(searchQuery)
}
.onSuccess {
updateTokensState(it)
}
.onFailure { }
}
}
private fun onTokenSelect(id: String) {
val foundToken = swapInteractor.findTokenById(id)
if (foundToken != null) {
val fromToken: Currency
val toToken: Currency
if (isOrderReversed) {
fromToken = foundToken
toToken = currency
} else {
fromToken = currency
toToken = foundToken
}
swapRouter.openScreen(SwapScreen.Main)
startLoadingQuotes(fromToken, toToken, lastAmount.value)
}
}
private fun onChangeCardsClicked() {
val currencies = swapInteractor.getExchangeCurrencies()
val newFromToken = currencies?.toCurrency
val newToToken = currencies?.fromCurrency
if (newFromToken != null && newToToken != null) {
isOrderReversed = !isOrderReversed
startLoadingQuotes(newFromToken, newToToken, lastAmount.value)
}
}
private fun onAmountChanged(value: String) {
uiState = stateBuilder.updateSwapAmount(uiState, value, value)
lastAmount.value = value
amountDebouncer.debounce(DEBOUNCE_AMOUNT_DELAY, viewModelScope) {
val currencies = swapInteractor.getExchangeCurrencies()
val fromToken = currencies?.fromCurrency
val toToken = currencies?.toCurrency
if (fromToken != null && toToken != null) {
startLoadingQuotes(fromToken, toToken, lastAmount.value)
}
}
}
override fun onCleared() {
singleTaskScheduler.cancelTask()
super.onCleared()
}
companion object {
private const val MIN_TOKENS_IN_LIST = 2
private const val INITIAL_AMOUNT = "1"
private const val UPDATE_DELAY = 10000L
private const val DEBOUNCE_AMOUNT_DELAY = 1000L
}
}