Updated on 2026-08-14
This commit is contained in:
parent
78818a6efa
commit
bd3ee9e237
33 changed files with 643 additions and 207 deletions
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -208,7 +208,7 @@ private fun Content(
|
|||
}
|
||||
is TransactionCardType.SendCard -> {
|
||||
AutoSizeTextField(
|
||||
amount = amount ?: "0",
|
||||
amount = amount ?: "1",
|
||||
onAmoutChanged = { type.onAmountChanged(it) },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue