Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-02 15:06:56 +07:00
commit dcda504df6
40 changed files with 1442 additions and 182 deletions

View file

@ -83,7 +83,9 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.SwapAlertUM
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.router.SwapNavScreen
@ -215,7 +217,7 @@ internal class SwapModel @Inject constructor(
val feeSelectorRepository = FeeSelectorRepository()
// shows currency order (direct - swap initial to selected, reversed = selected to initial)
private var isOrderReversed by mutableStateOf(false)
private val isOrderReversed = MutableStateFlow(value = params.isInitialReverseOrder)
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO)
private val swapRouter: SwapRouter = SwapRouter(router = router)
@ -427,20 +429,22 @@ internal class SwapModel @Inject constructor(
selectedAccountCurrency?.status to selectedAccountCurrency?.account
}
applyInitialTokenChoice(
val isApplied = applyInitialTokenChoice(
state = state,
selectedCurrency = selectedCurrency,
selectedAccount = selectedAccount,
isReverseFromTo = isReverseFromTo,
)
val fromCryptoCurrency = if (isOrderReversed) {
dataState.toCryptoCurrency
} else {
dataState.fromCryptoCurrency
}
// assume that fromCryptoCurrency selected according reverse flag,
// so update fee paid currency according to it
val fromCryptoCurrency = dataState.fromCryptoCurrency
if (fromCryptoCurrency != null) {
if (isApplied && fromCryptoCurrency != null) {
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${fromCryptoCurrency.currency.id}, " +
"isReverseFromTo: $isReverseFromTo",
)
updateFeePaidCryptoCurrencyFor(fromCryptoCurrency)
} else {
TangemLogger.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null")
@ -498,7 +502,7 @@ internal class SwapModel @Inject constructor(
state = state,
selectedCurrency = selectedCurrency,
selectedAccount = selectedAccount,
isReverseFromTo = isOrderReversed,
isReverseFromTo = isOrderReversed.value,
)
subscribeToCoinBalanceUpdatesIfNeeded()
@ -527,29 +531,34 @@ internal class SwapModel @Inject constructor(
}
}
/**
* returns true if tokens are selected and dataState is updated,
* false if selected token is null and alert is shown with error message
*/
private fun applyInitialTokenChoice(
state: TokensDataStateExpress,
selectedCurrency: CryptoCurrencyStatus?,
selectedAccount: Account.CryptoPortfolio?,
isReverseFromTo: Boolean,
) {
): Boolean {
// exceptional case
if (selectedCurrency == null) {
TangemLogger.e("No available tokens to swap for ${initialCurrencyFrom.symbol}")
analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap())
uiState = stateBuilder.createNoAvailableTokensToSwapState(
uiStateHolder = uiState,
fromToken = initialFromStatus,
)
return
return false
}
isOrderReversed = isReverseFromTo
val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed) {
isOrderReversed.value = isReverseFromTo
val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed.value) {
selectedCurrency to initialFromStatus
} else {
initialFromStatus to selectedCurrency
}
val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) {
if (isOrderReversed) {
if (isOrderReversed.value) {
selectedAccount to requireNotNull(fromAccountCurrencyStatus).account
} else {
requireNotNull(fromAccountCurrencyStatus).account to selectedAccount
@ -573,7 +582,7 @@ internal class SwapModel @Inject constructor(
toAccount = toAccount,
)
) {
return
return true
}
startLoadingQuotes(
@ -585,10 +594,11 @@ internal class SwapModel @Inject constructor(
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus),
)
return true
}
private fun updateTokensState(tokenDataState: TokensDataStateExpress) {
val tokensDataState = if (isOrderReversed) tokenDataState.fromGroup else tokenDataState.toGroup
val tokensDataState = if (isOrderReversed.value) tokenDataState.fromGroup else tokenDataState.toGroup
chooseTokenBridge.updateCurrenciesGroup(tokensDataState)
}
@ -1162,7 +1172,7 @@ internal class SwapModel @Inject constructor(
private fun onSearchEntered(searchQuery: String) {
val tokenDataState = dataState.tokensDataState ?: return
val group = if (isOrderReversed) {
val group = if (isOrderReversed.value) {
tokenDataState.fromGroup
} else {
tokenDataState.toGroup
@ -1192,7 +1202,7 @@ internal class SwapModel @Inject constructor(
)
}
val filteredTokenDataState = if (isOrderReversed) {
val filteredTokenDataState = if (isOrderReversed.value) {
tokenDataState.copy(
fromGroup = tokenDataState.fromGroup.copy(
available = available,
@ -1238,7 +1248,7 @@ internal class SwapModel @Inject constructor(
val fromAccount: Account.CryptoPortfolio?
val toToken: CryptoCurrencyStatus
val toAccount: Account.CryptoPortfolio?
if (isOrderReversed) {
if (isOrderReversed.value) {
fromToken = foundToken
fromAccount = foundAccount
toToken = initialFromStatus
@ -1295,6 +1305,17 @@ internal class SwapModel @Inject constructor(
return
}
modelScope.launch {
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " +
"isOrderReversed: ${isOrderReversed.value}",
)
if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) {
uiState = stateBuilder.createInitialLoadingState(
initialCurrencyFrom = fromToken.currency,
initialCurrencyTo = toToken.currency,
fromNetworkInfo = fromToken.currency.getNetworkInfo(),
)
}
updateFeePaidCryptoCurrencyFor(fromToken)
startLoadingQuotes(
fromToken = fromToken,
@ -1314,7 +1335,7 @@ internal class SwapModel @Inject constructor(
tokens: TokensDataStateExpress,
id: String,
): Pair<CryptoCurrencyStatus?, Account.CryptoPortfolio?> {
val accountCryptoCurrencyStatus = if (isOrderReversed) {
val accountCryptoCurrencyStatus = if (isOrderReversed.value) {
tokens.fromGroup
} else {
tokens.toGroup
@ -1346,7 +1367,23 @@ internal class SwapModel @Inject constructor(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = currencyStatus,
).getOrNull() ?: currencyStatus,
)
.onLeft {
TangemLogger.e(
"Coin balance: Unable to get fee paid crypto currency status for " +
"${currencyStatus.currency.id}",
)
}
.onRight { status ->
if (status == null) {
TangemLogger.e(
"Coin balance: Fee paid crypto currency status is null " +
"for ${currencyStatus.currency.id}",
)
}
}
.getOrNull()
?: currencyStatus,
)
}
@ -1392,7 +1429,11 @@ internal class SwapModel @Inject constructor(
toCryptoCurrency = newToToken,
toAccount = newToAccount,
)
isOrderReversed = !isOrderReversed
isOrderReversed.value = !isOrderReversed.value
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${newFromToken.currency.id}, " +
"isOrderReversed: ${isOrderReversed.value}",
)
updateFeePaidCryptoCurrencyFor(newFromToken)
dataState.tokensDataState?.let { tokensDataState ->
updateTokensState(tokensDataState)
@ -1824,13 +1865,13 @@ internal class SwapModel @Inject constructor(
}
private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List<SwapProvider> {
val groupToFind = if (isOrderReversed) {
val groupToFind = if (isOrderReversed.value) {
dataState.tokensDataState?.fromGroup
} else {
dataState.tokensDataState?.toGroup
} ?: return emptyList()
val idToFind = if (isOrderReversed) {
val idToFind = if (isOrderReversed.value) {
fromToken.currency.id.value
} else {
toToken.currency.id.value
@ -1856,8 +1897,8 @@ internal class SwapModel @Inject constructor(
fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?,
): Boolean {
val selectedCurrency = if (isOrderReversed) fromToken else toToken
if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed)) return false
val selectedCurrency = if (isOrderReversed.value) fromToken else toToken
if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false
analyticsEventHandler.send(
SwapEvents.NoticeUnavailableToSwapPair(
@ -1867,6 +1908,14 @@ internal class SwapModel @Inject constructor(
receiveBlockchain = toToken.currency.network.name,
),
)
// Cancel periodic quote task if selected token is not supported
singleTaskScheduler.cancelTask()
// Reset data state
dataState = SwapProcessDataState(
tokensDataState = dataState.tokensDataState,
)
lastReducedBalanceBy.value = BigDecimal.ZERO
lastAmount.value = INITIAL_AMOUNT
uiState = stateBuilder.createSwapNotSupportedState(
uiStateHolder = uiState,
fromToken = fromToken,
@ -1917,13 +1966,13 @@ internal class SwapModel @Inject constructor(
val from = dataState.fromCryptoCurrency ?: return false
val to = dataState.toCryptoCurrency ?: return false
val currenciesGroup = if (isOrderReversed) {
val currenciesGroup = if (isOrderReversed.value) {
dataState.tokensDataState?.toGroup
} else {
dataState.tokensDataState?.fromGroup
} ?: return false
val chosen = if (isOrderReversed) from else to
val chosen = if (isOrderReversed.value) from else to
return currenciesGroup.accountCurrencyList.flatMap { accountSwapAvailability ->
accountSwapAvailability.currencyList.map { accountSwapCurrency ->

View file

@ -183,10 +183,12 @@ internal class StateBuilder(
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable).copy(
type = TransactionCardType.ReadOnly(
accountTitleUM = getFromCardAccountTitle(fromAccount),
),
amountTextFieldValue = null,
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
token = fromToken,
tokenIconUrl = fromToken.currency.iconUrl,

View file

@ -771,7 +771,12 @@ internal class WalletModel @Inject constructor(
}
private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) {
when (val target = resolveQrSendTargetsUseCase(qrCode)) {
val target = resolveQrSendTargetsUseCase(qrCode)
handleQrTarget(target, resultSource)
}
private fun handleQrTarget(target: QrSendTarget, resultSource: QrResultSource) {
when (target) {
is QrSendTarget.WalletConnect -> {
val source = when (resultSource) {
QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD
@ -801,6 +806,17 @@ internal class WalletModel @Inject constructor(
is QrSendTarget.Multiple -> {
innerWalletRouter.openNetworkSelectionBottomSheet(target)
}
is QrSendTarget.AddressSameAsWallet -> {
uiMessageSender.send(WalletAlertUM.qrCodeAddressSameAsWallet())
}
is QrSendTarget.Warning -> {
uiMessageSender.send(
WalletAlertUM.qrCodeUnsupportedParams(
unsupportedParams = target.unsupportedParams,
onContinue = { handleQrTarget(target.target, resultSource) },
),
)
}
is QrSendTarget.Error -> handleQrError(target.error)
}
}

View file

@ -211,6 +211,7 @@ internal object WalletScreenPreviewData {
isHidingMode = false,
showMarketsOnboarding = false,
onDismissMarketsTooltip = {},
isRedesignEnabled = true,
)
val defaultAccountState = defaultState.copy(

View file

@ -248,6 +248,7 @@ internal object WalletScreenPreviewDataLegacy {
isHidingMode = false,
showMarketsOnboarding = false,
onDismissMarketsTooltip = {},
isRedesignEnabled = false,
)
internal val accountScreenState =

View file

@ -19,6 +19,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor(
private val multiWalletActionButtonsSubscriberFactory: MultiWalletActionButtonsSubscriber.Factory,
private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory,
private val tokenSyncSubscriberFactory: TokenSyncSubscriber.Factory,
private val tokenListAnalyticsSubscriberFactory: TokenListAnalyticsSubscriber.Factory,
private val designFeatureToggles: DesignFeatureToggles,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : WalletContentLoader(id = userWallet.walletId) {
@ -34,6 +35,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor(
},
multiWalletActionButtonsSubscriberFactory.create(userWallet),
tangemPayMainSubscriberFactory.create(userWallet),
tokenListAnalyticsSubscriberFactory.create(userWallet),
if (hotWalletFeatureToggles.isTokenSyncEnabled && userWallet is UserWallet.Hot) {
tokenSyncSubscriberFactory.create(userWallet)
} else {

View file

@ -139,6 +139,7 @@ internal class WalletStateController @Inject constructor(
isHidingMode = false,
showMarketsOnboarding = false,
onDismissMarketsTooltip = {},
isRedesignEnabled = designFeatureToggles.isRedesignEnabled,
)
}
}

View file

@ -76,6 +76,30 @@ internal object WalletAlertUM {
)
}
fun qrCodeUnsupportedParams(unsupportedParams: Map<String, String>, onContinue: () -> Unit): DialogMessage {
val paramsList = unsupportedParams.entries.joinToString { "${it.key} = ${it.value}" }
return DialogMessage(
title = resourceReference(R.string.qr_scanner_warning_unknown_parameters_title),
message = resourceReference(
id = R.string.qr_scanner_warning_unknown_parameters_message,
formatArgs = WrappedList(listOf(paramsList)),
),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_continue),
onClick = onContinue,
)
},
secondActionBuilder = { cancelAction() },
)
}
fun qrCodeAddressSameAsWallet(): DialogMessage {
return DialogMessage(
message = resourceReference(R.string.send_error_address_same_as_wallet),
)
}
fun confirmExpressStatusHide(onConfirmClick: () -> Unit): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.express_status_hide_dialog_title),

View file

@ -15,4 +15,5 @@ internal data class WalletScreenState(
val isHidingMode: Boolean,
val showMarketsOnboarding: Boolean,
val onDismissMarketsTooltip: () -> Unit,
val isRedesignEnabled: Boolean,
)

View file

@ -15,17 +15,22 @@ internal abstract class WalletStateTransformer(
abstract fun transform(walletUM: WalletUM): WalletUM
final override fun transform(prevState: WalletScreenState): WalletScreenState {
return prevState.copy(
wallets = prevState.wallets
.map { state ->
if (state.walletCardState.id == userWalletId) transform(state) else state
}
.toImmutableList(),
wallets2 = prevState.wallets2
.map { walletUM ->
if (walletUM.walletsBalanceUM.id == userWalletId) transform(walletUM) else walletUM
}
.toImmutableList(),
)
return if (prevState.isRedesignEnabled) {
prevState.copy(
wallets2 = prevState.wallets2
.map { walletUM ->
if (walletUM.walletsBalanceUM.id == userWalletId) transform(walletUM) else walletUM
}
.toImmutableList(),
)
} else {
prevState.copy(
wallets = prevState.wallets
.map { state ->
if (state.walletCardState.id == userWalletId) transform(state) else state
}
.toImmutableList(),
)
}
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.testTag
@ -107,6 +108,7 @@ internal fun WalletTopBar(
*
* @param config component config
*/
@Suppress("MagicNumber")
@Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -122,7 +124,9 @@ internal fun WalletTopBar(config: WalletTopBarConfig) {
Icon(
painter = painterResource(id = action.iconRes),
contentDescription = null,
modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON),
modifier = Modifier
.rotate(if (action.iconRes == R.drawable.ic_more_default_24) 90f else 0f)
.testTag(MainScreenTestTags.MORE_BUTTON),
)
}
}